After a Stripe Checkout payment, two things try to give the buyer their purchase: Stripe's webhook, and the browser, which Stripe redirects back to your site. They race. Either can arrive first, either can arrive twice, and either can fail halfway. I didn't want the buyer to wait for the webhook alone.
This is how our shop handles it: one function, called by both, with three separate "only once" guards. It's an exam practice site, so "the purchase" is access to practice exams, but the pattern holds for anything you deliver after payment.
The two callers
The webhook verifies the signature, then hands the event to a handler. If anything throws, it answers 500 and Stripe retries:
try { const outcome = await handleStripeEvent(db, event, { email: getEmailSender(), baseUrl: appUrl() }); return Response.json({ received: true, outcome }); } catch (err) { console.error(`stripe webhook ${event.id} failed`, err); return new Response("processing failed", { status: 500 }); // Stripe retries } The return route retrieves the session from Stripe, and if it is paid, runs the same fulfilment and signs the browser in if it's the one that started checkout. If retrieving or fulfilling fails, it doesn't show an error page: it redirects to the result page in a "payment is being processed" state that refreshes itself, and leaves the work to the webhook:
try { target = await settle(id); } catch (err) { console.error(`checkout return for ${id} failed; showing the pending state, the webhook fulfils`, err); } redirect(target); Guard 1: one order per checkout session
fulfillCheckout turns a paid session into an order and an entitlement, in one transaction. The order insert is the first guard:
insert into orders (..., stripe_checkout_session_id, ...) values (...) on conflict (stripe_checkout_session_id) do nothing returning id If nothing comes back, someone else got there first. The function reads the existing order and returns it with created: false. The entitlement insert has its own on conflict (order_id, exam_id) do nothing. So running it twice, in either order, leaves one order and one entitlement per exam.
The function also refuses to guess. A paid session with no recorded consent or no email throws, so the webhook answers 500 instead of acknowledging an order it didn't create. A results-only order whose attempt has disappeared grants nothing, rather than widening into full access.
Guard 2: the confirmation email is claimed, not "checked"
Both paths also want to send the confirmation. A "was it sent?" check followed by a send is a race. The claim is one atomic update:
update orders o set confirmation_sent_at = now() from customers c where o.id = $1 and c.id = o.customer_id and o.confirmation_sent_at is null returning c.email, ... Only the caller that gets a row back sends. The other gets nothing and returns false. If the send then fails, the claim is released before the error is rethrown:
} catch (err) { await db.query(`update orders set confirmation_sent_at = null where id = $1`, [orderId]); throw err; } So a retry can send it. A claim that stuck after a failed send would mean a paying customer who never gets the email.
Guard 3: the event id is recorded last
handleStripeEvent checks stripe_events for the event id first and returns "duplicate" if it's there. The id is inserted only after the work succeeded:
const recorded = await db.query( `insert into stripe_events (event_id, type) values ($1, $2) on conflict do nothing returning event_id`, [event.id, event.type], ); return recorded.rows.length > 0 ? outcome : "duplicate"; Recording it first would be the common mistake: the handler crashes, Stripe retries, the retry sees a recorded id and skips the work. Here a failed attempt leaves no event row, so the retry runs the whole handler again, and guards 1 and 2 make that safe.
The same session also reaches fulfillCheckout from the return route, which has no event id at all, and the test replays it under a second event id. Guard 1 covers both, which is why the event id isn't the only guard.
The tests that matter
All of this runs against an in-process Postgres (PGlite) in tests/db/payments.test.ts, with the real SQL. Four of the tests:
- Duplicates are no-ops: the same event twice returns
"handled"then"duplicate"; the same session under a new event id doesn't create a second order. - A failed email leaves the event unrecorded: the sender throws, the handler rejects, and the retry with a working sender returns
"handled"(not"duplicate") and sends exactly one email. - A hanging provider: the fetch never resolves, the sender's timeout fires, and the test asserts that
confirmation_sent_atis back tonull, that nostripe_eventsrow exists, and that the retry sends the email once. The test doesn't check access, but the code grants it in its own transaction before the email is claimed, so only the email waited. - Unpaid sessions, other integrations and other event types are
"ignored".
The hanging-provider test is the one I'd copy. A timeout is the failure that stays quiet in production, and it only counts as handled if the claim comes back.
What I'd take from this
- Run one function from both paths, and make every step safe to repeat.
- Use a database constraint as the guard (
on conflict), not anifin code. - Claim with an update that returns a row, and release the claim when the work fails.
- Record the event id after the work, never before.
- On the buyer's path, fail soft. Show "being processed", and let the webhook finish.
If you're curious what the shop sells, the price page is in Dutch.
I wrote this post with help from an AI assistant. The code and the tests are from our own repository.
