HeHeedza

Why does my Stripe checkout work in test mode but not in live mode?

Last reviewed

Short answer

Test (sandbox) and live are separate worlds in Stripe. Keys, products, prices, customers and webhook endpoints don't carry over. Going live means live keys on the server, live price IDs, a live webhook endpoint with its own whsec_ secret, and a rebuild so the new values ship.

Why this happens

Stripe's docs: "Each mode has its own set of API keys, and objects in one mode aren't accessible to the other." So a setup that's perfect in test fails in live in very predictable ways:

What you seeUsual cause
No such price: 'price_...' (often with "a similar object exists in test mode, but a live mode key was used")Live key, but test price ID
Checkout works, but access never unlocksNo live webhook endpoint, or the test whsec_ is used in production
Invalid API Key providedKeys from different modes mixed, or a stale build
Customer portal error about a missing configurationPortal settings were never saved in live mode
Payments declined or account errorsStripe account not fully activated for live payments

How to fix it, step by step

1. Switch the keys and rebuild. On your host's Production environment:

  • STRIPE_SECRET_KEY=sk_live_... (or a restricted rk_live_...), server-only
  • NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY / VITE_STRIPE_PUBLISHABLE_KEY=pk_live_..., if your front end uses Stripe.js

Then redeploy. Public env vars are baked in at build time.

2. Recreate products and prices in live mode and use the live IDs. Don't hardcode price_... IDs. Give each price a lookup key in both modes and resolve it at runtime:

const { data } = await stripe.prices.list({ lookup_keys: ['pro_monthly'], active: true });
const priceId = data[0]?.id;
const session = await stripe.checkout.sessions.create({
  mode: 'subscription',
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: `${process.env.APP_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
  cancel_url: `${process.env.APP_URL}/pricing`,
});

The same code then works in both modes. Heedza's own billing uses lookup keys for exactly this reason.

3. Create a live webhook endpoint. In live mode, add an endpoint pointing at your production URL, select the same events as in test, and copy its whsec_... into production as STRIPE_WEBHOOK_SECRET. Even with an identical URL, the test and live secrets differ.

4. Finish account activation. Make sure Stripe shows the account can accept live payments, with business details and payouts done.

5. Save live-mode settings. The customer portal configuration, payment methods and tax settings are configured per mode. Save the portal configuration once in live mode.

6. Check success_url. Build it from a production APP_URL, not localhost.

7. Test once for real. Buy your cheapest plan with a real card, confirm access unlocks and the webhook shows 200, then refund yourself.

Fix prompt for your AI builder

Paste this into Lovable, Bolt, Cursor, Claude, ChatGPT, Replit, v0 or whichever tool you build with. Replace the bracketed parts first.

Fix prompt
Our Stripe checkout works in test mode but fails in live mode with: [paste error].
Please, without changing the UI:
1. Make sure no Stripe price or product IDs are hardcoded. Resolve prices by lookup_key with stripe.prices.list({ lookup_keys: [...] }).
2. Make sure the secret key and webhook secret are read only from server env vars (STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET), and only the publishable key reaches the browser.
3. Build success_url and cancel_url from an APP_URL env var.
4. Give me a go-live checklist: which env vars to set in production, the live webhook endpoint URL and events, and the settings to save in live mode.
Don't create charges while testing.

How to catch this next time

The test-to-live switch is a one-time event, but the same breakage comes back when keys are rotated, prices are archived, or an AI edit re-hardcodes a test price ID.

Heedza checks your live checkout page on a schedule, stopping before payment and never entering a card. If the payment form or pay button stops showing, it emails a screenshot and a fix prompt for your AI tool. Its passive scan also flags a live Stripe secret or restricted key (sk_live_, rk_live_) showing up in your public JavaScript. Run the free scan on your live URL.

Questions

Can I copy test products to live?

Stripe offers ways to copy some objects to live mode, but the copies get new IDs. Use lookup keys, or update your env vars to the live IDs.

Do I need a separate webhook endpoint for live?

Yes. Live events only go to live endpoints, and each endpoint has its own signing secret.

Is it safe to put pk_live_ in the browser?

Yes. Publishable keys are meant for the browser. Secret (sk_live_) and restricted (rk_live_) keys must stay on the server.

Sources