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 see | Usual 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 unlocks | No live webhook endpoint, or the test whsec_ is used in production |
Invalid API Key provided | Keys from different modes mixed, or a stale build |
| Customer portal error about a missing configuration | Portal settings were never saved in live mode |
| Payments declined or account errors | Stripe 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 restrictedrk_live_...), server-onlyNEXT_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.
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.