If you've ever added a 100%-off coupon to a Stripe Checkout flow
and then watched your webhook handler silently do nothing, you've
run into a gotcha that isn't well documented anywhere obvious.
The setup
I was building a small test program: give a few people free access
to a paid product via a Stripe Promotion Code, so they could go
through the real checkout flow (price, decision, everything) instead
of skipping straight to the result.
The webhook listens for checkout.session.completed, like every
Stripe tutorial tells you to. Worked perfectly for paid orders.
Free orders went through Checkout fine on the frontend... and then
nothing happened on the backend.
The actual cause
Two details that aren't obvious until you hit them:
-
checkout.session.completedstill fires for €0 sessions — that part is fine, no surprises there. -
But the session looks different. A free Checkout Session has
no associated
PaymentIntent(there's nothing to charge, so Stripe never creates one), andsession.payment_statusis"no_payment_required"instead of"paid".
If your webhook handler has any logic like:
if (session.payment_status === "paid") {
// fulfill order
}
...free orders get silently ignored. No error, no log, nothing —
the condition is just false and the code below it never runs.
The fix
Don't gate fulfillment on payment_status === "paid". Treat
"paid" and "no_payment_required" as equally valid completion
states:
const validStatuses = ["paid", "no_payment_required"];
if (validStatuses.includes(session.payment_status)) {
// fulfill order
}
And if you need the PaymentIntent ID for anything downstream
(refund logic, in my case), just make sure your code tolerates
it being null or undefined rather than assuming it always exists:
const paymentIntentId = session.payment_intent ?? null;
// later, somewhere that might issue a refund:
if (paymentIntentId) {
await stripe.refunds.create({ payment_intent: paymentIntentId });
}
// if it's null, there's nothing to refund anyway — skip silently
Why this matters beyond coupons
This isn't just a coupon edge case. Anything that can legitimately
bring a Checkout Session total to zero — full discounts, free trial
codes, internal test accounts — hits this same gap. If your
fulfillment logic only recognizes "paid", every one of those
paths fails silently, and you won't notice until someone reports
"I redeemed the code but never got anything."
Costly to debug after the fact, free to avoid if you know about
it upfront.
If you're testing your own coupon flow: don't trust that webhook
silence means "nothing happened." Check payment_status directly
on a real free-session payload before assuming your handler covers it.
Top comments (0)