Stripe delivers events at least once, and it means it: a failed delivery is retried for up to three days with exponential backoff, and a human can resend the same event manually for weeks after that. Your handler will run twice on the same event. That is not a bug to prevent, it is the contract.
Every guide on this answers with the same recipe: keep a table of processed event IDs, insert before processing, treat a unique-constraint violation as a duplicate. That is the correct general solution. It is also more machinery than most handlers need, and there is a class of duplicate it does not catch.
Here is the smaller version, from a webhook running in production:
// The confirmation email is the only thing here that isn't idempotent,
// so gate it on the fact that makes this a genuinely new subscription.
const existing = await prisma.subscription.findUnique({
where: { userId },
select: { stripeSubscriptionId: true },
})
const isNew = existing?.stripeSubscriptionId !== subscription.id
await prisma.subscription.upsert({ where: { userId }, update: {...}, create: {...} })
if (isNew) await sendSubscriptionConfirmation(...)
No extra table, no TTL, nothing to clean up. The rest of this explains why that is enough, when it is not, and the bug that taught us the difference.
Why the same event arrives twice
Three reasons, and only the first is the one people picture.
Retries. If your endpoint answers with anything other than a 2xx, or takes too long, Stripe retries for up to three days in live mode. In a sandbox it retries three times over a few hours.
Manual resends. A developer debugging an integration can resend an event from the dashboard for 15 days, or from the CLI for 30. Your handler will see an event from three weeks ago, in production, on a Tuesday.
Slow responses that actually succeeded. Your handler does the work, takes twelve seconds, the connection times out. Stripe records a failure and retries. You now have the same work queued again, and the first pass already completed.
There is a fourth property that matters just as much and gets less attention: order is not guaranteed. Stripe says so plainly, and a single subscription can emit customer.subscription.created, invoice.created, invoice.paid and charge.created in whatever order they arrive. A handler that applies a difference rather than asserting the current truth will eventually apply them backwards.
The advice everyone gives, and the case it misses
The event-ID table works like this: every Event has an id such as evt_1OxYz..., that id is stable across every retry of that event, so you insert it into a table with a unique index before doing any work. If the insert fails, you have seen it, and you return 200.
It is a good pattern. But read what Stripe's own documentation says about duplicates, because it undercuts the recipe:
In some cases two separate Event objects are generated. To identify these duplicates, use the object id in
data.objecttogether with the event type.
Two different event IDs, one underlying change. Your table of event IDs sees two rows it has never seen before and lets both through. The deduplication you built is blind to exactly the duplicate Stripe warns you about, because it is keyed on the wrong thing.
What catches it is a key that describes the change rather than the notification. Which is the point of the next section.
Two kinds of work live in a handler
Open any webhook handler and you will find two categories of work, with completely different needs.
Writes that are already idempotent. An upsert keyed on the user, or on the subscription id, produces the same row whether it runs once or five times. Setting a status to ACTIVE is the same operation every time. This half needs no protection at all, and adding a dedupe table in front of it protects nothing.
Side effects that are not. Sending an email. Provisioning a licence. Posting to Slack. Incrementing a counter. Charging something. These are the ones that hurt, and they are usually a small minority of the handler.
Once you see the split, the question stops being "how do I deduplicate events" and becomes "what fact makes this side effect a first time". That fact is nearly always already in your database.
The bug we shipped
Our subscription handler upserted the subscription row and then sent a confirmation email. The upsert was idempotent from day one, so testing looked fine.
Then a delivery timed out, Stripe retried, the handler ran again, the upsert did nothing new, and the customer received a second "welcome to your subscription" email. Nothing errored. The data was perfect. The only visible symptom was in someone's inbox, which is the worst place to discover a bug because you find out from the customer.
The fix was three lines, and it is the snippet at the top of this article: before writing, read the subscription id you currently have stored. If it is already this subscription, the event is a replay of something you have applied, so skip the email and do the write anyway. The write is harmless; the email is not.
Note what the gate is keyed on. Not the event id, and not a timestamp: the subscription id, which is Stripe's identifier for the thing that actually changed. Two different events about the same subscription both find the same stored id, and only the first one gets through.
Natural keys, and where they come from
The same shape works for one-time payments, and there the key is even more obvious:
// The PaymentIntent id is the idempotency key: a replay finds the existing
// row, so the grant and the receipt both happen exactly once.
const existing = await prisma.purchase.findUnique({
where: { stripePaymentIntentId: paymentIntentId },
})
await prisma.purchase.upsert({
where: { stripePaymentIntentId: paymentIntentId },
update: {},
create: { userId, planId, stripePaymentIntentId: paymentIntentId, ... },
})
if (!existing) await sendReceipt(...)
The unique constraint on stripePaymentIntentId is doing the real work, and it is doing it in the database rather than in application logic, which means two concurrent deliveries cannot both win. That last property is the one people usually reach for the dedupe table to get, and a unique index on a column you were going to store anyway gives it to you for free.
Finding the key is mechanical. Ask what object this event is about, then ask whether you already store its Stripe id. Subscriptions have stripeSubscriptionId. One-time payments have the payment intent. Refunds have the charge. Metered usage has your own idempotency string, which Stripe deduplicates on its side for about a day. In nearly every case, the answer is yes, you already store it, because you needed it for something else.
When the event-ID table is the right answer
Being fair to the standard advice, because it exists for good reasons. Build the table when:
- The handler does work that is not tied to a domain object you store. Analytics, audit logs, an outbound notification with no row behind it. There is no natural key to hang the gate on.
- You process events with a queue and multiple workers. At that point you want a single choke point rather than a correctness argument repeated in every consumer.
- Volume is high enough that the extra read per event is cheaper than reasoning about each handler. A generic guard scales with your team, not just your traffic.
If you build it, key it on the event id, add a unique index, insert before processing, and give the rows a time to live of about a week: that covers the three-day retry window with room to spare. And keep the natural keys anyway, because they are what catches the duplicate that arrives as two different events.
Status codes are a control channel
The last piece is what you answer, because your status code is an instruction.
Return non-2xx when you want the event again. Your database was briefly unavailable, an upstream call failed: let the handler throw and answer 500. Stripe will bring it back, and now you have a retry policy you did not have to write.
Return 2xx when you never want it again. This one is easy to get wrong. Our checkout handler receives payment sessions that were not created by our own checkout flow, for example from a Payment Link, and those have no metadata we can use. There is nothing to process and there never will be, so the handler acknowledges them. Answering with an error instead would earn three days of retries on an event that can never succeed.
Answer quickly either way. Stripe recommends returning the 2xx before running expensive logic, and their signature check has a five minute tolerance on the timestamp by default. If your handler is slow enough to time out, you are generating the duplicates you are trying to defend against.
Frequently asked questions
How long does Stripe retry a failed webhook?
Up to three days in live mode, with exponential backoff. Sandbox events are retried three times over a few hours. On top of that, a human can resend an event manually for 15 days from the dashboard or 30 days from the CLI, so your handler can see the same event long after the original delivery.
Do I need a table of processed event IDs?
Sometimes, not always. It is the general solution and it is the right one at high volume or when a handler does work that is not tied to a domain object. But most handlers write rows keyed on something Stripe already gives you, like the subscription or payment intent id, and those writes are naturally idempotent without any extra table to maintain.
Does logging event IDs catch every duplicate?
No, and Stripe's own documentation says so. In some cases two separate Event objects are generated for the same underlying change, with different ids. Their guidance is to identify those using the object id in data.object together with the event type, which is exactly what a natural key does and what an event-id table cannot see.
Should I return 200 or 500 when my handler fails?
Return a non-2xx status when you want the event again, and 2xx when you do not. A database that was briefly unavailable is worth a retry, so let it fail loudly. An event you will never be able to process, like a payment session created outside your checkout flow, should be acknowledged, otherwise Stripe keeps retrying it for three days.
Can I rely on webhook events arriving in order?
No. Stripe states explicitly that it does not guarantee delivery in the order events were generated, and one subscription can produce several events at once. Write handlers that reach the same end state whatever the order, which usually means upserting the current truth rather than applying a diff.
The whole handler, not the snippets
Everything above is one file in a free, MIT licensed starter kit: signature verification, five event types, the gates shown here, and an integration test that signs its payloads with Stripe's own SDK so the verification path is exercised rather than mocked.
If you are wiring billing from scratch, the billing guide covers the plans, the checkout route and the customer portal that these events keep in sync. And if teams are on your roadmap, per-seat billing is a state machine is where these same events start producing wrong invoices instead of duplicate emails.