ingestlayer/blog

all posts
Post#playbook

Stripe webhook idempotency, without the dedupe table.

Stripe retries, so the same event arrives twice and you charge — or ping — twice. Here's Stripe webhook idempotency without hand-rolling a dedupe table.

ben7 min read


Stripe doesn't promise to deliver a webhook once. It promises to deliver it at least once, which is a polite way of saying sometimes twice, occasionally three times. If your handler grants access or sends a receipt, that's the difference between one welcome email and three. Stripe webhook idempotency is the property that makes the extra deliveries harmless: run the handler again on the same event and nothing new happens.

The duplicates aren't a bug you can fix on Stripe's side. They're how at-least-once delivery works, and every webhook sender does some version of it. So the job was never to stop the second delivery — it's to make the second delivery a no-op.

why the same event arrives twice

Stripe waits about twenty seconds for a 200. If your handler is slow, or your box hiccups, or the response just gets lost on the way back, Stripe never hears the acknowledgement — so it assumes the delivery failed and queues a retry. Your code may well have finished; Stripe has no way to know that. From its side, silence is failure, and the fix for failure is to send the event again.

The one fact that makes this tractable: a redelivered event is the same event, and it carries the same id. Stripe's event id — the evt_… on the envelope — is stable across every retry of that event. So “have I already handled this?” has a precise, cheap answer, as long as something is keeping track. (The deeper reason your handler went slow enough to trigger the retry in the first place is its own story — that's the twenty-second timeout problem, and it has the same root cause: doing real work inside the handler.)

the dedup table you keep rewriting

It almost always starts like this:

webhook.ts
// the version that runs twice when stripe retries a slow 200
app.post("/webhook/stripe", async (req) => {
  const charge = req.body.data.object;
  await fulfillOrder(charge);          // grant access, email the receipt
  await slack.post("#revenue", "+" + charge.amount);
  res.sendStatus(200);                 // 200 came back at 21s. too late.
});
// stripe already gave up waiting and queued a retry. the whole handler
// runs again on the next delivery: two receipts, two pings, one charge.

The textbook fix is a processed_events table. Insert the event id behind a unique constraint, catch the conflict, and skip the work if you've seen it. It works. It is also a migration, a table, a transaction wrapped around your handler, and a cleanup job so the table doesn't grow forever — and you stand all of that up again in the next service that happens to receive a webhook. It's load-bearing plumbing with nothing to do with your product, and you rewrite it every single time.

It's also easy to reach for the wrong tool here. Stripe's Idempotency-Key header is for requests you make to Stripe — so a retried API call doesn't double-charge. It does nothing for the webhooks Stripe sends back to you. Those deliveries are yours to dedupe, and the header everyone half-remembers won't touch them.

dedupe on the event id, once, in the pipe

The move is the same as the table, minus the table. dedupe keys on whatever makes the event unique — for a Stripe webhook, the event id — and collapses repeats inside a window. The first delivery passes through; any redelivery with the same id inside the window is dropped before it reaches anything downstream. within: 72h is deliberate: Stripe keeps retrying a failed event with backoff for up to three days, so you size the window to outlast the retries, not the other way around.

Where it sits is the part that earns its keep. dedupe runs before the event fans out, so one collapse protects every destination at once — fulfillment, the channel, the warehouse — instead of each consumer keeping its own little table and its own opinion about what counts as a duplicate. The dedup logic stops being something every handler reimplements and becomes one line the event flows through on its way in.

one charge, fulfilled once, pinged once

Put it together and the noisy, double-firing handler becomes this — as the diagram you'd see in the app, or the file you commit. Flip between them.

representation

01source

sourcehttp.webhookWebhook
matchcharge.succeeded

02pipeline · 1 steps

  • 01CTLdedupekey $event.id · within 72h

03destinations · 3

  • towebhookWebhook
    url$env.FULFILLMENT_URL
  • toslackSlack
    channel#revenue
  • towarehouse.pgPostgres
    tableevents.payments

A retried charge.succeeded carrying an id you've already seen never makes it past dedupe, so fulfillment runs once and #revenue lights up once. The warehouse keeps every charge, already deduped, so your numbers aren't inflated by Stripe's retry schedule either. The delivery that would have charged your customer's patience twice just… doesn't — and you didn't write a table to make that true. One event in, three destinations out, each of them seeing the charge exactly once. That's the same one-event-many-destinations fan-out, with the duplicate quietly removed up front.

how ingestlayer does this

In ingestlayer, dedupe is a first-class action — you key it on any field and give it a window, and it collapses repeats before they reach a single destination. You point the Stripe webhook at the pipeline, key on $event.id, and the redeliveries fold back into one event without a migration in sight.

It composes with the rest of the pipe. dedupe is one of the noise moves — alongside filter and throttle — that decide whether an event reaches a place at all. Idempotency is the boring half of webhook reliability, and boring is exactly what you want from the part of the system that decides whether a customer gets charged once or twice.

Dedupe your Stripe events with ingestlayer — wire one webhook, key on the event id, and the retries stop double-charging before they reach anything.


Read next

Get a Discord message when someone signs up.

Pasting a Discord webhook URL into your signup handler takes five minutes. Getting a Discord message that says who actually signed up is the real job.

← back to all posts