Stripe webhooks look simple. Stripe sends a POST, you update a row, you return 200. Then you go live and the support tickets start: a customer got two welcome emails, someone who cancelled still has Pro, a Payment Link buyer paid and got nothing.
None of these are exotic. They come from four facts about how Stripe delivers events, plus a few API details that are easy to misread. This post walks through seven failure modes, what each looks like in production, and the code that fixes it. The examples use a Next.js App Router route handler, but the patterns apply to Express, FastAPI or anything else that speaks HTTP.
The four facts
- Delivery is at least once. If your endpoint times out, returns a non-2xx status, or the connection drops after you processed the event but before Stripe saw your 200, Stripe sends it again. In live mode it keeps retrying with backoff for up to three days. You can also resend any event by hand from the Dashboard.
-
Order is not guaranteed. Stripe says so in its docs.
customer.subscription.deletedcan arrive before an oldercustomer.subscription.updated. -
The signature covers the raw bytes.
Stripe-Signatureis an HMAC-SHA256 oftimestamp + "." + raw body. Parse and re-serialise the JSON and the signature no longer matches. - Your response code is the retry signal. A 2xx means "done, stop sending". Anything else means "try again later".
Every bug below breaks one of these.
1. Verifying a parsed body instead of the raw body
The most common first bug: the framework parses JSON before your code runs, so you verify JSON.stringify(req.body). Key order and whitespace change, and every signature fails. The usual "fix" is to skip verification, which leaves your endpoint open to anyone who can send a POST.
In the App Router, read the body as text:
// app/api/webhooks/stripe/route.ts
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const raw = await req.text(); // raw bytes, untouched
const sig = req.headers.get("stripe-signature");
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(raw, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
} catch {
return new Response("bad signature", { status: 400 });
}
return handle(event);
}
In Express, mount express.raw({ type: "application/json" }) on the webhook route only. In FastAPI, use await request.body(). In Django, use request.body.
Keep the default 300-second timestamp tolerance. It's what stops someone who captured one real request from replaying it next week. Real Stripe retries are signed again with a fresh timestamp, so the tolerance never blocks them.
2. Running side effects twice
Your handler sends a welcome email and creates a licence key. The response is slow, Stripe times out and sends the event again, and the customer gets two emails and two keys.
The fix is to record each event.id with a unique constraint and skip repeats. The order matters: claim the id first, do the work, and release the claim if the work fails.
async function handle(event: Stripe.Event) {
// INSERT ... with a UNIQUE index on event_id; returns false if it already exists
const claimed = await db.claimEvent(event.id);
if (!claimed) return new Response("duplicate", { status: 200 });
try {
await dispatch(event);
return new Response("ok", { status: 200 });
} catch (err) {
await db.releaseEvent(event.id); // let the retry run it
console.error("webhook failed", event.type, event.id, err);
return new Response("retry", { status: 500 });
}
}
Why claim first? If you check seen(event.id) and only write the id at the end, two deliveries arriving at the same moment both pass the check. The unique index makes the database do the locking for you.
3. Returning 200 when you failed
// Don't do this
try { await dispatch(event); } catch (e) { console.error(e); }
return new Response("ok");
This tells Stripe that a failed event succeeded. Stripe stops retrying, and that customer's plan never updates. Return a 5xx on any failure you can recover from. Return 200 only when the work is committed, or when you are deliberately ignoring an event type.
The flip side is slow work. If provisioning calls three APIs and takes 20 seconds, Stripe may time out and retry while the first attempt is still running. Keep the handler short: write what you need to the database, put the slow parts on a queue keyed by event.id, and return.
4. Trusting the order events arrive in
Here's the sequence that revives cancelled customers:
- The customer cancels. Stripe emits
customer.subscription.deleted(created at 12:00:05). - An older
customer.subscription.updatedwithstatus: "active"(created at 12:00:01) was delayed by a retry. - Deleted arrives first, and you set the plan to free. Then updated arrives, and you set it back to Pro.
Two fixes. Use one or both:
case "customer.subscription.updated":
case "customer.subscription.deleted": {
const sub = event.data.object as Stripe.Subscription;
const stored = await db.getSubscription(sub.id);
// Option A: ignore events older than what we already applied
if (stored && stored.lastEventCreated > event.created) break;
// Option B (safer): don't trust the payload, ask Stripe for the current state
const fresh = await stripe.subscriptions.retrieve(sub.id);
await db.saveSubscription(fresh, event.created);
break;
}
event.created has one-second resolution, so two events in the same second can tie. Re-fetching costs one API call and is always correct, which is why it's the default I'd pick.
5. Looking up the buyer by customer_email
checkout.session.completed has two email fields, and they mean different things:
-
customer_emailis the email you passed in when you created the session. For Payment Links, pricing tables and most guest checkouts it'snull. -
customer_details.emailis the email the buyer actually typed on the Checkout page.
Code that reads only session.customer_email works in every test where you created the session yourself, and then silently drops every Payment Link order in production. Identify the buyer by your own id first, then fall back:
case "checkout.session.completed": {
const s = event.data.object as Stripe.Checkout.Session;
if (s.payment_status !== "paid" && s.payment_status !== "no_payment_required") break;
const userId = s.client_reference_id ?? s.metadata?.userId;
const email = s.customer_details?.email ?? s.customer_email;
await provision({ userId, email, customerId: s.customer as string | null });
break;
}
Also check payment_status. With delayed payment methods (bank debits, some wallets) the session completes before the money arrives, and checkout.session.async_payment_succeeded follows later.
6. Granting access on the success page
The success page is a URL. Anyone can open it, and anyone can share it. Treat it as a status screen. It can say "Thanks, we're confirming your payment" and poll your own API. Access should be granted only by the webhook-synced row in your database.
The success page does need to know which session it's showing, and that leads to a subtle bug:
// Bug: inside a template literal, ${CHECKOUT_SESSION_ID} is a JS variable lookup
success_url: `${base}/thanks?session_id=${CHECKOUT_SESSION_ID}`,
// Correct: Stripe replaces the literal text {CHECKOUT_SESSION_ID}
success_url: `${base}/thanks?session_id={CHECKOUT_SESSION_ID}`,
The first version throws a ReferenceError, or produces session_id=undefined if a variable with that name happens to exist. Building the URL with new URL() / searchParams.set() causes a third version of the bug, because it encodes the braces to %7BCHECKOUT_SESSION_ID%7D and Stripe no longer recognises the placeholder. A grep for success_url across your codebase takes a minute.
7. Cutting access on the first failed renewal
invoice.payment_failed fires when a renewal charge is declined. It's tempting to downgrade right away. Don't. Stripe retries the card on its own schedule (Smart Retries), and many failures recover by themselves. Treat the event as a trigger for dunning: email a link to update the card. Let access follow the subscription's status as it moves through past_due, then unpaid or canceled, depending on your settings.
Testing all of this without a Stripe account
You can test every case above offline, because the Stripe library can sign payloads for you:
import Stripe from "stripe";
const stripe = new Stripe("sk_test_dummy");
const secret = "whsec_test_secret";
const payload = JSON.stringify(event); // a fixture shaped like a real Stripe event
const header = stripe.webhooks.generateTestHeaderString({ payload, secret });
// POST payload with header "stripe-signature" to your handler, twice, out of order, tampered...
Then write a test for each case:
- Duplicate. Send the same signed event twice. Expect two 200s and one side effect.
-
Out of order. Send
deleted, then the olderupdated(active). Expect the subscription to staycanceled. -
Replay. Sign with a timestamp an hour old (
timestamp: now - 3600). Expect 400. - Tampered. Change one byte of the body after signing. Expect 400.
-
Wrong secret. Sign with a different
whsec_. Expect 400. - Failure. Make the database throw. Expect 500, and expect the event id not to be marked processed.
-
Guest checkout. Send
client_reference_id: null, customer_email: null, customer_details.email: "a@b.com". Expect the buyer to be provisioned.
Before going live, also run stripe listen --forward-to localhost:3000/api/webhooks/stripe, trigger each event type, and resend one old event from the Dashboard to confirm nothing fires twice.
The short version
- Verify the raw body. Keep the tolerance.
- Claim
event.idunder a unique index. Release the claim on failure. - Return 500 when you fail. Keep the handler fast.
- Don't trust order. Re-fetch the subscription.
- Find buyers by your id, then
customer_details.email. - Grant access from the webhook, never from the success page. Keep
{CHECKOUT_SESSION_ID}literal. - A failed renewal starts dunning, not a downgrade.
If you want these cases ready to run, I packaged them as the Stripe Webhook Survival Pack ($9): signed, Stripe-shaped fixtures, TypeScript and Python test harnesses, and a 31-point go-live checklist. If you'd rather start from a Next.js billing module that already does all of the above, that's RetrySafe Billing for Next.js ($39).
Top comments (0)