DEV Community

KNALLHART.DEV
KNALLHART.DEV

Posted on

The Stripe Webhook Gotcha Nobody Warns You About: 100% Off Coupons and Missing PaymentIntents

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:

  1. checkout.session.completed still fires for €0 sessions — that part is fine, no surprises there.
  2. But the session looks different. A free Checkout Session has no associated PaymentIntent (there's nothing to charge, so Stripe never creates one), and session.payment_status is "no_payment_required" instead of "paid".

If your webhook handler has any logic like:

if (session.payment_status === "paid") {
  // fulfill order
}
Enter fullscreen mode Exit fullscreen mode

...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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)