13 min read
Implemented in Checkout Kit
Full-stack Stripe checkout starter: product catalog, cart, orders, and admin — React + Go or Node + MongoDB
See Checkout KitThe webhook is the part of a Stripe integration that works perfectly in development and then charges someone twice in production. Everything in this article is a failure that only appears once real traffic, real network timeouts and real retries are involved.
The tempting design is the obvious one: the customer pays, Stripe redirects to /success, you mark the order paid.
It's wrong, and the reasons are mundane rather than exotic:
/success?order=123 by hand — or reloads it four times.So the rule is: the redirect updates the UI, the webhook updates the database. The success page says "thanks, we're confirming your payment" and polls or subscribes for the real state. Everything that moves money or stock happens in the webhook handler.
Your webhook endpoint is a public URL that changes order state. Without a signature check, anyone who finds it can mark any order paid.
import express from 'express';
import Stripe from 'stripe';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const app = express();
// The webhook route MUST be mounted before express.json(), with the raw body.
app.post(
'/webhooks/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Buffer, not an object
req.headers['stripe-signature'] as string,
process.env.STRIPE_WEBHOOK_SECRET!,
);
} catch (err) {
// Bad signature: 400, and do not process anything.
return res.status(400).send(`invalid signature: ${(err as Error).message}`);
}
await handle(event);
res.json({ received: true });
},
);
app.use(express.json()); // everything else, afterThe Go equivalent, same idea:
func (h *WebhookHandler) Handle(w http.ResponseWriter, r *http.Request) {
payload, err := io.ReadAll(io.LimitReader(r.Body, 65536))
if err != nil {
http.Error(w, "read error", http.StatusServiceUnavailable)
return
}
event, err := webhook.ConstructEvent(
payload,
r.Header.Get("Stripe-Signature"),
h.webhookSecret,
)
if err != nil {
http.Error(w, "invalid signature", http.StatusBadRequest)
return
}
if err := h.process(r.Context(), event); err != nil {
// 500 so Stripe retries.
http.Error(w, "processing failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}Note the LimitReader. It's a public endpoint; don't let it read an unbounded body.
This is not an edge case. Stripe's delivery guarantee is at least once. You will receive duplicates because:
Every event carries a stable event.id (evt_...). Record it, and let the database enforce uniqueness:
CREATE TABLE webhook_events (
id TEXT PRIMARY KEY, -- Stripe's evt_...
type TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);async function handle(event: Stripe.Event) {
await db.transaction(async (tx) => {
// The insert is the lock. A concurrent duplicate loses here, not later.
const inserted = await tx
.insertInto('webhook_events')
.values({ id: event.id, type: event.type })
.onConflict((oc) => oc.column('id').doNothing())
.executeTakeFirst();
if (Number(inserted.numInsertedOrUpdatedRows ?? 0) === 0) {
return; // already processed — nothing to do, and no error
}
switch (event.type) {
case 'checkout.session.completed':
await fulfil(tx, event.data.object as Stripe.Checkout.Session);
break;
case 'payment_intent.payment_failed':
await markFailed(tx, event.data.object as Stripe.PaymentIntent);
break;
case 'charge.refunded':
await recordRefund(tx, event.data.object as Stripe.Charge);
break;
default:
break; // acknowledged and recorded, deliberately ignored
}
});
}Two properties make this work.
The insert and the business change share one transaction. Record the event and then crash before fulfilling, and the retry would be skipped as a duplicate — order paid, nothing shipped. One transaction means either both happen or neither does.
The unique constraint is the concurrency control. Two simultaneous deliveries of the same event race into the same INSERT; one wins, the other gets zero rows and returns. No advisory lock, no queue, no SELECT then INSERT gap for a second worker to slip through.
Events do not arrive in the order they were created. payment_intent.succeeded can land before checkout.session.completed, and a charge.refunded can arrive before you've processed the payment it refunds.
So handlers must be order-independent, which in practice means driving a state machine rather than assuming a starting point:
const ALLOWED: Record<OrderStatus, OrderStatus[]> = {
pending: ['paid', 'failed', 'cancelled'],
paid: ['shipped', 'refunded'],
shipped: ['delivered', 'refunded'],
delivered: ['refunded'],
failed: ['pending'],
cancelled: [],
refunded: [],
};
async function transition(tx: Tx, orderId: string, to: OrderStatus) {
const order = await tx.orders.byIdForUpdate(orderId);
if (!ALLOWED[order.status].includes(to)) {
// Not an error — a late or out-of-order event. Log and move on.
log.info('ignored transition', { orderId, from: order.status, to });
return;
}
await tx.orders.setStatus(orderId, to);
}An illegal transition is information, not a failure. Returning 500 because a refund arrived early would make Stripe retry forever.
Stripe waits about 20 seconds and retries with exponential backoff for up to three days on any non-2xx. That gives you two rules:
Return 2xx as soon as the event is durably recorded. If fulfilment means generating a PDF, calling a shipping API and sending mail, don't do it inside the request. Record the event, enqueue the work, respond.
Return 5xx when you genuinely couldn't process it — the database was down — so Stripe retries. Return 2xx for events you don't care about; a 404 on an unhandled type earns you three days of retries for nothing.
And Stripe disables endpoints that fail continuously, so a handler that throws on unknown event types will eventually turn your webhook off in production.
Subscribe to what you handle, nothing else — every extra event type is noise you have to acknowledge.
| Event | What it means |
|---|---|
checkout.session.completed | Session finished. Check payment_status — for async methods it can still be unpaid. |
checkout.session.async_payment_succeeded | The delayed method (SEPA, bank transfer) finally settled. |
checkout.session.async_payment_failed | It didn't. Release the reserved stock. |
payment_intent.payment_failed | Card declined. |
charge.refunded | Full or partial refund — read amount_refunded. |
charge.dispute.created | A chargeback. Usually a human needs to know. |
That payment_status check on checkout.session.completed is the one that bites: treat "session completed" as "paid" and you'll ship goods for a SEPA payment that fails two days later.
The webhook payload is signed, so the amount inside it is trustworthy. The amount your frontend sent is not.
async function fulfil(tx: Tx, session: Stripe.Checkout.Session) {
const order = await tx.orders.byId(session.metadata!.order_id);
// Recompute server-side and compare against what Stripe actually captured.
if (session.amount_total !== order.totalCents) {
log.error('amount mismatch', {
orderId: order.id,
expected: order.totalCents,
charged: session.amount_total,
});
return; // do not fulfil — a human looks at this
}
await transition(tx, order.id, 'paid');
await queue.enqueue('order.fulfil', { orderId: order.id });
}Put your own order_id in metadata when you create the session, so the webhook can find its way back without trusting anything in the request.
express.raw() before express.json(), or the signature never verifies.event.id with a unique constraint, in the same transaction as the business change.payment_status, not just "session completed".Checkout Kit ships this handler — signature verification, an idempotency table on event.id, the order state machine, and the Stripe flow mirrored in both the Go and Node editions.