Jitterflow

← Blog

Webhook idempotency keys: why retries need them

A request that times out is indistinguishable from a request that never arrived. Your code doesn't know which one happened — only that it didn't get a response — so the safe move is usually to retry. That's correct for the network call, and wrong for the side effect: if the original request actually did land, the retry creates a second job for the same event.

Where this bites in practice

A serverless function calling POST /v1/ingest/:endpointKey hits a cold-start timeout right as the request completes server-side. The caller sees a timeout and retries. Without anything tying the two calls together, that's two jobs, two deliveries, and — depending on what the destination does with an order-created event — a duplicate charge, a duplicate email, or a duplicate row.

The fix: an idempotency key

POST /v1/ingest/:endpointKey accepts an optional idempotencyKey field alongside payload:

POST /v1/ingest/:endpointKey
{
  "targetIdentifier": "customer-123",
  "idempotencyKey": "order-456-created",
  "payload": { "any": "json" }
}

A repeat call to the same endpoint with an already-used idempotencyKey doesn't create a new job — it returns the original one, with idempotent: true so you can tell the difference in logs or tests:

{
  "jobId": "9c7e21a4-...",
  "status": "DELAYED",
  "idempotent": true,
  "note": "A job with this idempotencyKey already exists."
}

A fresh call still gets 202 with a new job in the DELAYED state:

{
  "jobId": "9c7e21a4-...",
  "scheduledFor": "2026-08-22T14:32:07.000Z",
  "status": "DELAYED"
}

Picking a good key

The key needs to be stable across retries of the same logical event and unique across different events. An id you already have — an order id, a database row's primary key, a message id from whatever queue triggered the send — is almost always the right choice. order-456-created works because re-sending the same order produces the same key; a freshly generated UUID on every call would defeat the point, since it would never match a prior call and every retry would look like a new event.

What it doesn't need to be

idempotencyKey is optional — omit it and every call creates a new job, which is correct for events that are genuinely allowed to repeat (a heartbeat, a periodic status ping). Set it specifically on calls where a duplicate delivery would be a real problem: anything that triggers a charge, a state transition, or a notification a customer would notice twice.

Full reference

Every field, response shape, and error case for the ingest call is on Sending a webhook.

Start free — takes 2 minutes