Your handler finishes, the response gets lost, and the sender delivers the same event again. We cover three ways to make receivers idempotent, how to handle duplicates that arrive at the same time, and where Stripe, GitHub, Shopify and others put the event ID.

Webhook senders retry anything they couldn't confirm was delivered. If your handler finished its work but the response never made it back, the sender sees a failure and sends the event again. So the same event will sometimes arrive twice or more, and deduplication is the receiver's job: record each event's ID under a unique constraint, and when you see an ID again, skip the work and return 2xx. This post covers why duplicates happen, three ways to guard against them, what to do when two copies arrive at once, and where each provider puts its event ID.

Why duplicates happen

  1. You received it, but your 2xx didn't reach the sender. The connection dropped after you'd done the work.
  2. Your work finished after the sender's timeout. The sender gave up and recorded a failure; your handler kept going and completed.
  3. Sender-side replays. A queue ran the same job twice, or someone replayed a delivery from the sender's log.

Shopify's docs say it plainly: your app might receive the same webhook more than once, for example after a network timeout or a retry (Shopify, checked on 2026-09-27). Senders promise at-least-once delivery; exactly-once processing is up to you.

Where each provider puts the event ID

Checked against official documentation on 2026-09-27.

Sender ID location Same on retry? Notes
Standard Webhooks webhook-id header Yes The spec recommends using it as the idempotency key
Stripe id in the body (evt_…) Yes Two separately generated Event objects can describe one change; identify those by data.object ID plus type
GitHub X-GitHub-Delivery header Yes, also on redelivery A GUID per delivery
Shopify X-Shopify-Webhook-Id header - Unique per delivery; use it to deduplicate. X-Shopify-Event-Id is shared by all deliveries from one merchant action
Slack event_id in the body Not stated Unique across all workspaces; retries carry x-slack-retry-num
LINE webhookEventId in the body - deliveryContext.isRedelivery is true on redeliveries

Sources: Standard Webhooks spec, Stripe, GitHub headers, GitHub best practices, Shopify duplicates, Shopify headers, Slack, LINE

Shopify's two IDs do different jobs: X-Shopify-Webhook-Id to drop duplicate deliveries, X-Shopify-Event-Id to correlate deliveries across topics that came from the same merchant action.

Three ways to guard against duplicates

1. A unique constraint

The simplest version. After verifying the signature, insert the ID into a table with a unique constraint. If nothing was inserted, it's a duplicate: return 2xx.

app.post('/webhooks', express.raw({ type: 'application/json' }), async (req, res) => { const event = await verifyOr400(req, res); // sends 400 and returns undefined on a bad signature if (!event) return; const id = req.header('webhook-id')!; const r = await db.query( 'INSERT INTO processed_webhooks (id, received_at) VALUES ($1, now()) ON CONFLICT (id) DO NOTHING', [id], ); if (r.rowCount === 0) return res.sendStatus(200); // seen before: acknowledge, don't process await queue.add('webhook', { id, event }); res.sendStatus(200); }); 

The weakness: if the process dies after the insert but before the job is enqueued, the event is marked as seen and never processed. If enqueueing isn't reliable, use the next approach.

2. Record the ID and apply the change in one transaction

Put the ID insert and the business update in the same transaction. Then "recorded but not processed" and "processed but not recorded" can't happen.

await db.transaction(async (tx) => { const r = await tx.query( 'INSERT INTO processed_webhooks (id, received_at) VALUES ($1, now()) ON CONFLICT (id) DO NOTHING', [id], ); if (r.rowCount === 0) return; // duplicate await tx.query('UPDATE invoices SET status = $1, paid_at = $2 WHERE id = $3', [ 'paid', event.data.paid_at, event.data.invoice_id, ]); }); // any failure rolls back both, and the next retry starts clean 

If the handler calls an external API, pass an idempotency key derived from the event ID so a retried handler doesn't repeat that call.

3. Make the write itself idempotent

Some operations produce the same result no matter how often they run: "mark the invoice paid," "overwrite the user's plan with this value." For those, an upsert (INSERT … ON CONFLICT DO UPDATE) makes duplicates harmless without any tracking.

Operations that accumulate - "add 100 points," "send an email" - are not like that, and need approach 1 or 2.

Two copies at the same moment

With short retry intervals, the second copy can arrive while the first is still being handled. If your code does "check whether the ID exists, then process and insert," both requests see "not yet" and both get processed.

Insert first, under the unique constraint, and only continue when the insert succeeds. The database lets exactly one request through. Both code samples above work this way. For long-running work, keep a status (processing / done) and a started_at, and treat rows stuck in "processing" past a deadline as failed so they can be retried. Stripe's guide to processing undelivered events uses the same processing / processed split (Stripe).

How long to keep IDs

Longer than the sender could plausibly send the same event again:

  • Automatic retry windows (Stripe: up to 3 days in live mode; Shopify: 4 hours)
  • Manual replays (Stripe: 15 days from the Dashboard, 30 days from the CLI; GitHub: deliveries from the past 3 days can be redelivered)

Keeping IDs for 30 to 90 days covers most cases. Delete older rows on a schedule.

Why not hash the payload

Distinct events can have identical bodies - two identical payments in the same second, for instance. And some senders change parts of the body, such as a timestamp, between attempts. Use the ID the sender assigns to each event.

Handling events that arrive out of order (discarding stale updates by timestamp or version) is covered in out-of-order webhooks.

What senders have to get right

Receivers can only deduplicate if the sender does two things:

  1. Give every event a unique ID and send the same ID on every retry.
  2. Include the ID in the signed content so it can't be altered in transit.

Standard Webhooks signs id.timestamp.body, which covers both. The sender-side equivalent - avoiding double sends when you call a delivery API - is covered in idempotency keys for event APIs.

Webhooks from Webhook Admin

Webhook Admin gives each message a msg_… ID and sends it in the webhook-id header. Retries and manual replays from the dashboard carry the same webhook-id; only webhook-timestamp and the signature are regenerated. After verifying the signature as described in the request format, store webhook-id under a unique constraint and duplicates are handled. Automatic retries end after about 28 hours by default (up to 3 days with a per-endpoint setting), but deliveries can be replayed by hand from the log, so keep IDs for longer than your plan's log retention (7 days on Free, 30 on Starter, 90 on Pro and Business).

If you'd rather not build stable IDs, retries and signing on the sending side, you can try Webhook Admin for free (50,000 messages a month): https://app.webhookadmin.com/signup

Quick FAQ

How long should I keep processed event IDs?

Longer than the sender might send the same event again, including manual resends, not just automatic retries. Stripe, for example, retries automatically for up to 3 days but allows CLI resends up to 30 days after an event is created.

Can I deduplicate on a hash of the payload?

Not reliably. Two distinct events can have identical bodies (two identical payments in the same second), and some senders change parts of the body between attempts. Use the ID the sender assigns to each event.

What if two copies of the same event arrive at the same time?

If your code checks for the ID and then inserts it, both requests can see "not there yet" and both get processed. Insert first under a unique constraint and only process when the insert succeeds; the database then lets exactly one through.

Thanks for reading! How do you deduplicate webhooks: a unique constraint, a cache, or something else? We'd love to hear in the comments.

This post first appeared on the Webhook Admin blog.