Blog · Engineering

Idempotency-Key for push notifications: avoid duplicate sends

Duplicate pushes come from retries. With the Idempotency-Key header and a key derived from the event, repeating a request returns the original notification.

Leer en español

Duplicate push notifications almost always come from a retry: your server sent the request, the response got lost, and the second attempt created another send. The fix is the Idempotency-Key header: send the same key on every attempt of the same operation, and the API returns the original notification instead of creating a new one.

In short:

  • POST is not idempotent: repeating it can create two notifications and alert the same user twice.
  • With Idempotency-Key, retries that reuse the key within 24 hours return the first notification.
  • Derive the key from the business event (order:8812:shipped); never generate a fresh one per attempt.
  • Retry only network errors, 429 and 5xx, one attempt at a time, with growing delays.
  • For replays that happen days later, add a table with a unique key in your own database.

Where duplicates come from

HTTP defines which methods are idempotent, meaning you can repeat them without changing the outcome. GET, PUT and DELETE are; POST is not (see RFC 9110, section 9.2.2). Creating a notification is a POST, so by default every repeat is a brand-new send.

The classic case is a timeout. Your backend calls the API, the notification is created and queued, but the response takes longer than your ten-second limit or the connection drops. Your code cannot tell whether it worked, so it retries, and the customer gets "Your order is on its way" twice. Other sources are just as common:

  • Job queues with at-least-once delivery, which may hand the same message to your consumer twice.
  • Payment or shipping webhooks that the provider redelivers because your endpoint answered slowly.
  • A cron job that overlaps with its previous run, or runs on two instances right after a deploy.
  • A double click on the "Send" button in your internal admin panel.

With transactional pushes, duplicates are very visible: two "payment received" notifications in a row make people wonder whether they were charged twice.

How Idempotency-Key works

Payment APIs popularized the pattern, and there is now an IETF draft to standardize the header (it is not an RFC yet). The idea is simple: the client attaches a unique key per operation, the server stores the mapping from that key to the result, and if the key shows up again, it replies with the stored result without doing anything new.

In the castly API, POST /v1/notifications accepts the header and remembers each key for 24 hours:

curl -X POST https://castly.sh/v1/notifications \
  -H "Authorization: Bearer $CASTLY_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order:8812:shipped" \
  -d '{"to":{"type":"users","userIds":["u_42"]},
       "title":"Your order is on its way",
       "url":"https://yourshop.com/orders/8812"}'

# HTTP 202
# {"id":"…","status":"queued"}

Repeat that exact call an hour later and you get another 202 with the same id: no second notification is created and no new pushes are spent. Keep that id, because GET /v1/notifications/:id later shows the status of every device. All fields are listed in the API reference.

Designing good keys

The key must identify the business operation, not the HTTP attempt. If two attempts describe the same fact ("order 8812 left the warehouse"), they carry the same key; different facts get different keys.

EventKey
Order shippedorder:8812:shipped
Appointment reminder, 24 h beforebooking:5531:reminder-24h
New reply in a threadreply:99071
Campaign to a segmentcampaign:2026-11-sale:v1

Common mistakes that cancel the protection:

  • A new UUID on every attempt. A random UUID works if you generate it once, store it with the job and reuse it; created inside the retry loop, it protects nothing.
  • A timestamp in the key. order:8812:1763049600 changes on every retry if the time comes from the current clock.
  • A key that is too broad. user:42 would block every other legitimate push to that user for 24 hours.
  • Reusing a key with different content. If you fix the copy of a campaign that already went out, that is a new operation: use v2.

Retrying safely with the same key

The key only helps if your retry policy respects it. This TypeScript function retries network errors, 429 and 5xx with exponential backoff, and fails immediately on any other 4xx, since repeating a malformed request will not fix it:

const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function sendOnce(message: object, idempotencyKey: string) {
  for (let attempt = 0; attempt < 4; attempt++) {
    if (attempt > 0) await sleep(500 * 2 ** attempt); // 1 s, 2 s, 4 s
    let res: Response;
    try {
      res = await fetch('https://castly.sh/v1/notifications', {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.CASTLY_SECRET_KEY}`,
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey, // same key on every attempt
        },
        body: JSON.stringify(message),
        signal: AbortSignal.timeout(10_000),
      });
    } catch {
      continue; // network error or timeout: it may have been created, retry anyway
    }
    if (res.ok) return (await res.json()) as { id: string; status: string };
    if (res.status !== 429 && res.status < 500) {
      throw new Error(`Request rejected (${res.status}): ${await res.text()}`);
    }
  }
  throw new Error('No valid response after 4 attempts');
}

Two details matter. First, attempts run in sequence: never fire two parallel requests with the same key "just in case". Second, the 300 requests per minute limit applies per API key, so a 429 in the middle of a bulk send is solved by waiting, not by piling on retries. The same pattern works from an edge function; see sending web push from Cloudflare Workers.

Beyond 24 hours: deduplicate in your database

The 24-hour window covers immediate retries and short outages. It does not cover someone replaying a dead-letter queue three days later, or a migration script that re-emits old events. For that you need your own memory, and the simplest form is an outbox table with a unique key:

CREATE TABLE push_outbox (
  event_key       TEXT PRIMARY KEY,  -- 'order:8812:shipped'
  payload         TEXT NOT NULL,
  notification_id TEXT,              -- id returned by the API
  created_at      TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- When the event happens: if it already exists, nothing is inserted
INSERT INTO push_outbox (event_key, payload) VALUES (?, ?)
ON CONFLICT (event_key) DO NOTHING;

A worker picks rows without a notification_id, calls sendOnce with event_key as the Idempotency-Key, and stores the returned id. The two layers complement each other: the table guarantees one send per event forever, and the header covers the dangerous gap between the API accepting the request and you saving the id. If the worker crashes right there, the next attempt gets the same id instead of a duplicate.

Apply the same logic to what you receive. If a payment webhook triggers your pushes, record its event ID and drop repeats. In a Next.js app this usually lives in a server action or route handler, as shown in push notifications in Next.js. And if you consume castly webhooks, verify the signature and process each event only once.

Frequently asked questions

What happens if I retry with the same Idempotency-Key after 24 hours?

After that window the key is forgotten and the request is treated as new, so another notification is created. If your replays can arrive later than that, also deduplicate in your database with a unique key per event.

Can I use a random UUID as the key?

Yes, as long as you generate it once per operation and store it with the job so every attempt reuses it. A key derived from the event, such as order:8812:shipped, is often more practical because any process can rebuild it.

What does the API return when I repeat a request with the same key?

A 202 with the same id as the first notification. No new send is created, so no extra pushes count against your plan.

Which errors should I retry and which not?

Retry network errors, timeouts, 429 and 5xx responses, always with the same key and growing delays. A 400, 401 or 403 points to a problem with the request or the API key, and repeating it will not help.

Is Idempotency-Key a standard?

It is a widespread pattern in payment and messaging APIs, and there is an IETF draft to standardize the header. As of November 2026 it has not been published as an RFC.

Put it into practice with castlyWeb and PWA push notifications for developers: an SDK under 3 KB, a REST API and delivery logs for every device. Free up to 10,000 pushes a month.
Create a free account →

Send your first push notification today.

Create a project, add the SDK and send with one API call. Free to start.

Create a free account →