PayHook

Stripe webhook works in test mode but not in live: the causes

A Stripe webhook that passes every test and fails in live mode: its own endpoint and secret, live keys and prices, HTTPS, real 3D Secure, a newer API version.

Test mode, which Stripe now calls a sandbox, shares nothing your webhook depends on with live mode. Each has its own webhook endpoints, signing secrets, API keys, products and prices. Live mode adds what a sandbox does not have: HTTPS with a valid certificate, banks that ask for authentication, payments that take days. And the live endpoint, created later, may send events in a newer API version than the one your code was written against.

What changes when you go live

In live modeWhat breaksWhat to do
Endpoints are separateNo event reaches your app: test endpoints never receive live eventsCreate the endpoint again in live mode, with the same events selected
Each endpoint has its own secretNo signatures found matching the expected signature for payload on every eventGive production the live endpoint's whsec_
Keys are sk_live_ and rk_live_API calls from your handler fail: the objects of a live event do not exist for a test keyUse the live secret key on the server
Products and prices are separateA checkout or a plan lookup by a test price id finds nothingCreate them in live mode and read their ids from configuration
HTTPS is requiredEvery delivery fails with a connection or TLS errorServe the endpoint over HTTPS with a valid certificate and TLS 1.2 or newer
Banks ask for authenticationA subscription starts incomplete, and a handler that expects active at once never grants accessGrant on invoice.paid, which comes after authentication
Some payments are not instantThe checkout completes, the money arrives days laterAlso handle checkout.session.async_payment_succeeded
The endpoint has its own API versionFields your code reads come back emptyPin the endpoint's API version, or read both shapes

The endpoint and its secret

Stripe's go-live checklist says it plainly: an account has both test and live endpoints, so define live endpoints, and make sure each behaves like the test one. In the Dashboard, switch to live mode first, then open Workbench and Webhooks: the live endpoints and their event deliveries are listed there, apart from the sandbox's.

A new endpoint gets a new signing secret, and the most common live failure is a production server still reading the test one, or the one stripe listen printed. The CLI forwards sandbox events unless you pass --live, and its secret belongs to it alone. Every Stripe signature message, with its causes, is on our page about Stripe's signature errors.

Check also that the live endpoint listens to the same events. An endpoint that misses invoice.paid delivers everything else and still leaves renewals unpaid in your app.

Keys, products and prices

Objects created in a sandbox, such as products, prices and coupons, are not usable in live mode, so a price id copied from test mode means nothing there. Read price ids from configuration per environment rather than from the code, and use the live secret key on the server: a test key cannot read the customer or the subscription of a live event. Every event carries a livemode field; log it next to the event id, and you see at once which mode an event came from.

HTTPS

Stripe requires an HTTPS endpoint in live mode and checks the connection before it sends anything: the certificate chain must be valid, and TLS must be version 1.2 or newer. A test endpoint on plain HTTP, or with an incomplete certificate chain, can work in a sandbox and fail every live delivery with a TLS error or an unable-to-connect error.

Real cards: authentication and delayed payments

The test card 4242 4242 4242 4242 never asks for authentication. A real card may, and in a checkout built on the Subscriptions API the subscription is then created incomplete, invoice.payment_action_required arrives, and only after the customer authenticates do invoice.paid and the move to active follow. A handler that grants access on customer.subscription.created only when it says active leaves those customers locked out. Stripe's test card 4000 0025 0000 3155 asks for authentication, so you can replay this in a sandbox.

Bank debits and other methods that are not instant complete the checkout before the money arrives. Stripe's fulfillment guide checks payment_status on checkout.session.completed and fulfills the rest on checkout.session.async_payment_succeeded. Which event to trust, provider by provider: payment succeeded, subscription not active.

A newer API version

An event is rendered in the API version of its endpoint, or of your account if the endpoint has none, and an endpoint created months after the test one may use a newer version. Some versions move fields. Since 2025-03-31.basil, an invoice no longer has subscription: the id is in parent.subscription_details.subscription. And a subscription no longer has current_period_end: each item has its own. Code written against the older shape reads undefined, finds no subscription and unlocks nobody, without a single error. Pin the endpoint's API version when you create it, or read both shapes:

// invoice.paid: the subscription id, in API versions before and after 2025-03-31.basil
const subscriptionId = invoice.parent?.subscription_details?.subscription ?? invoice.subscription ?? null;

// customer.subscription.*: the end of the paid period, on the item since 2025-03-31.basil
const periodEnd = subscription.items?.data?.[0]?.current_period_end ?? subscription.current_period_end ?? null;

Stripe's Go and .NET libraries go further and refuse an event from another release of the API than their own, with received event with API version …, even when the signature is fine.

When it is fixed

Live mode retries a failed delivery for up to three days with exponential backoff, so once the endpoint answers again, most of what it missed arrives on its own. For the rest, Resend in the Dashboard works for 15 days after the event and stripe events resend for 30. Skip event ids you have already handled: retries and resends deliver the same event again.

How PayHook reports it

PayHook, the webhook inspector we are building, shows whether each Stripe event came from test or live mode next to its signature verdict, and when a signature does not match, its reason reminds you that stripe listen, each Dashboard endpoint and test and live mode have their own secrets. PayHook is in closed beta; the home page has the details.

Sources

Back to the blog