Stripe webhook not firing
- Stripe
A test payment can succeed in the Stripe Dashboard while your app never upgrades the user. The Checkout Session completed. Your handler was never called, or it returned an error Stripe will retry until it gives up.
Separate the two failures
Events are not arriving. The endpoint is wrong, the webhook is in test mode while the payment was live, or the signing secret in the server does not match the endpoint that sent the event.
Events are arriving and failing. The handler verifies the signature, then looks up a user through metadata that was never set, and returns 500. Stripe shows the failure. Your UI still says the customer is on the free plan.
SignupCanary's watchdog is the first case: a URL you add in Stripe, a signing secret, and an alert if events stop or a failure type shows up. It does not fulfill the subscription. Your handler still has to do that.
A handler that can actually keep the plan in sync
- Read the raw body. Parsed JSON breaks the signature.
- Verify with the endpoint's signing secret, not a secret from a different endpoint.
- Update the plan from
checkout.session.completedandcheckout.session.async_payment_succeededonly whenpayment_statusispaidorno_payment_required. - Also handle
customer.subscription.updatedandcustomer.subscription.deleted. Renewals and cancellations never visit your success page. - Store the Stripe customer id on your user when you create the customer, and look up by that id.
- Record the event id so a retry does not double-apply.
Ignore the success redirect as proof of payment. The browser can close before it loads.
Local checks
Use the Stripe CLI to forward events, or a tunnel to /api/stripe/webhook. Trigger checkout.session.completed with the CLI and confirm your user row changes. Then cancel the subscription and confirm the row returns to free.
If you sell to US or EU customers, plan for Stripe Tax separately. Turning on automatic tax without an active registration collects nothing and does not warn you.