Making Stripe Webhook Handlers Idempotent After a Duplicate Purchase Grant
TIL that Stripe can and will deliver the same webhook event more than once, and a handler that reacts to checkout.session.completed by granting access will happily grant it twice — the fix was recording processed event IDs and treating the handler as a set operation, not an append.
The Problem #
Clonify.io grants download access to a product the moment Stripe confirms payment. One customer emailed asking why their order history showed the same UI kit purchased twice, a day apart, for a checkout they only completed once. Stripe had only charged their card once — the duplicate was on Clonify's side.
Context #
Access is granted from a webhook: Stripe calls a /webhooks/stripe route with a checkout.session.completed event once payment succeeds, and the handler inserts a row into an orders table and unlocks the product for that user. There's no polling or client-side confirmation involved — the webhook is the only signal the backend trusts that money actually changed hands.
What I Tried #
The handler looked reasonable in isolation:
router.post("/webhooks/stripe", async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
WEBHOOK_SECRET,
);
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await grantAccess(session.client_reference_id, session.metadata.productId);
}
res.sendStatus(200);
});grantAccess inserted an order row and flagged the product as owned. It worked for months.
What Went Wrong #
Stripe's own docs are explicit about this and I'd skimmed past it: webhook delivery is at-least-once, not exactly-once. If Clonify's server takes too long to respond, drops the connection, or returns anything other than a 2xx, Stripe retries the same event — same event.id, sent again later. In this case the server had responded slowly during a deploy, Stripe retried after its timeout, and both deliveries ran grantAccess to completion before either request finished. Two inserts, one payment.
The Solution #
Give the handler a memory of what it's already processed, and make processing conditional on that memory rather than on nothing:
router.post("/webhooks/stripe", async (req, res) => {
const event = stripe.webhooks.constructEvent(
req.body,
req.headers["stripe-signature"],
WEBHOOK_SECRET,
);
const inserted = await db.processedEvents.insertIfNotExists({ id: event.id });
if (!inserted) return res.sendStatus(200); // already handled this event.id
if (event.type === "checkout.session.completed") {
const session = event.data.object;
await grantAccess(session.client_reference_id, session.metadata.productId);
}
res.sendStatus(200);
});processedEvents.id has a unique index, so insertIfNotExists is a single atomic write: the second delivery's insert fails the uniqueness check, inserted comes back false, and grantAccess never runs a second time. The order of operations matters — the dedupe check has to happen before the side effect, not after, or two concurrent requests can both pass the check before either has recorded itself.
Why It Works #
The uniqueness constraint moves the "have I seen this before" check into the database itself, where two concurrent requests can't both get a false answer — one insert wins, the other hits a duplicate-key error and is treated as "already handled." That's the same trick as the duplicate-vote fix from a few weeks back: don't check-then-write in application code when two requests can interleave between the check and the write. Let the database's own constraint be the check.
Lessons Learned #
- "At-least-once" delivery isn't a Stripe quirk — it's how most webhook systems behave, because the alternative (exactly-once, guaranteed) requires distributed transactions the provider doesn't want to run on your behalf.
- A webhook handler that has side effects needs its own idempotency key, and
event.idis handed to you for exactly this reason — using it isn't optional hardening, it's the intended usage. - Slow handlers make retries more likely, which makes duplicate-processing bugs more likely. Responding fast and doing the real work after acknowledging isn't just about latency — it also shrinks the window this bug lives in.
What I Would Do Differently #
I'd add the processedEvents table on day one instead of after a support email. There's no version of a "grant access on payment" webhook that doesn't need it — it's not an edge case, it's the default behavior of the delivery mechanism.
Related Concepts #
Idempotency keys, at-least-once vs. exactly-once delivery, unique constraints as concurrency control, outbox pattern for reliable side effects from webhooks.
Related content
Live Poll Results Without WebSockets: Server-Sent Events in a Node.js API
TIL that once vote writes were race-free, the results screen still felt dead until a manual refresh — switching from client polling to Server-Sent Events made results update instantly without the bidirectional complexity WebSockets would have added for a channel that only ever sends in one direction.
Preventing Duplicate Votes in a Polling API Under Concurrent Requests
TIL that a 'check if the user already voted, then write' guard is racy under concurrent requests — the fix was a unique compound index plus an atomic $inc, so MongoDB rejects the duplicate instead of the app trying to catch it first.
Webhooks retry, so make the receiver idempotent — not the sender reliable
TIL that webhook delivery is at-least-once, not exactly-once. Instead of trying to prevent duplicate deliveries, derive a deterministic ID from the event and let the receiver refuse to redo work it already did.