What is idempotency? Idempotent webhooks and APIs

Idempotency is the property that doing an operation more than once has the same effect as doing it once. SET balance = 100 is idempotent; balance = balance + 100 is not. For webhooks and APIs it means a repeated request, from a retry, a replay or a duplicate delivery, doesn't create a second order, charge the card twice or send the email again.

CREATE TABLE processed_events (
  event_id    text PRIMARY KEY,
  received_at timestamptz NOT NULL DEFAULT now()
);

That one table, keyed on the event ID, is the core of most idempotent webhook handlers.

Why webhooks need idempotency

Webhooks are delivered at least once, not exactly once. Duplicates happen when:

  • Your handler processed the event but responded too slowly, so the provider timed out and retried.
  • Your handler returned 500 after the side effect had already happened.
  • The provider's own infrastructure delivered twice, which every large provider documents as possible.
  • You or your tooling replayed events after an outage.

If the handler isn't idempotent, each of these repeats whatever it did.

Making a webhook handler idempotent

Key on the provider's event ID, not on the payload contents. Stripe puts it in id, GitHub sends X-GitHub-Delivery, and Standard Webhooks providers send webhook-id, which stays the same across retries.

app.post('/webhooks', async (req, res) => {
  const event = verifyAndParse(req)                 // signature check first
  try {
    await db.tx(async (tx) => {
      await tx.query('INSERT INTO processed_events (event_id) VALUES ($1)', [event.id])
      await fulfilOrder(tx, event.data)             // side effect in the same transaction
    })
  } catch (err) {
    if (err.code !== '23505') throw err             // 23505 = unique violation: already processed
  }
  res.sendStatus(200)                               // duplicates are acknowledged, not reprocessed
})

What makes this work:

  1. A unique constraint, not a "check then insert". Two copies of the same event arriving at once will both pass a SELECT check.
  2. The same transaction for the dedupe record and the side effect, so a crash can't record one without the other.
  3. Return 200 for duplicates. Returning an error makes the provider retry a duplicate forever.

When the side effect is outside your database, for example calling a payment API or sending an email, pass the event ID on as an idempotency key to that service if it supports one, or record the outcome before acknowledging.

Idempotency keys in APIs

The same idea from the client side: an idempotency key is a unique value the client sends with a request so the server can spot a retry.

POST /v1/charges HTTP/1.1
Idempotency-Key: 5f1c2c9a-6f3e-4c1d-9b0a-2b7e1f8c4d11

The server stores the response for that key and returns the same response for any repeat, rather than charging twice. Stripe's API works this way, and an IETF draft standardises the Idempotency-Key header. A repeat with the same key but a different body is usually rejected, sometimes with 409 Conflict or 422.

The Webhook Relay Outbound Gateway works the same way when you publish a message: a repeat call with the same Idempotency-Key and content returns the original receipt and publishes nothing new, and reusing a key with different content is rejected with 409 Conflict.

Idempotent HTTP methods

MethodIdempotent by specNotes
GET, HEAD, OPTIONSYesAlso safe: no side effects
PUTYesReplaces the resource with the same state
DELETEYesDeleting twice leaves it deleted (the status code may differ)
POSTNoWebhooks use this, so you add idempotency yourself
PATCHNoDepends on the patch

Common mistakes

  • Deduplicating on a hash of the body. Some providers change a field, such as an attempt counter or timestamp, on each retry. Use the event ID.
  • Expiring dedupe records too soon. Keep IDs at least as long as the provider's retry and replay window, which can be days or weeks.
  • Assuming order. Idempotency stops duplicates, not reordering. A retried subscription.updated can arrive after a newer one. Compare timestamps or fetch current state.

With an idempotent handler, retries become harmless, which is what makes long retry windows like Webhook Relay's durable retries safe to turn on.

Frequently asked questions

What is idempotency?

Idempotency is the property that performing an operation more than once has the same effect as performing it once. Setting a value to 5 is idempotent, adding 5 to a value is not. In APIs and webhooks it means a repeated request does not create a second order, charge or email.

Why do webhook handlers need to be idempotent?

Because webhooks are delivered at least once. Providers retry when your endpoint times out or returns an error, sometimes after you already processed the event, and you or your tools may replay events after an outage. Without idempotency, each duplicate repeats the side effect.

How do I make a webhook handler idempotent?

Use the event ID from the payload or a header such as webhook-id. Insert it into a table with a unique constraint inside the same transaction as the side effect. If the insert fails because the ID already exists, skip processing and return 200.

What is an idempotency key?

An idempotency key is a unique value a client sends with a request, often in an Idempotency-Key header, so the server can recognise a retry of the same request. The server stores the result for that key and returns it again instead of repeating the operation. Stripe's API is a well-known example.

Are HTTP methods idempotent?

By definition in the HTTP spec, GET, HEAD, PUT, DELETE and OPTIONS are idempotent, and POST and PATCH are not. Webhooks are almost always POST requests, so the receiver has to add idempotency itself.