- The money already moved. Your app did not notice.
- Test mode and live mode are two different Stripe accounts in practice
- The three secrets that get mixed up
- Checkout succeeding is not the same as access being granted
- The live-only failures test cards hide
- What to check in the Stripe Dashboard before you hire anyone
- When a vibe-coded Stripe integration is actually done
- What I would do if this were my SaaS
- When to hire someone instead of prompting the builder
- Frequently asked questions
- FAQs
For SaaS founders, AI SaaS founders, Nontechnical founders, Founders using Lovable or Cursor · Intermediate · Commercial · Solves: Stripe works in test mode but not live, Customer paid but has no access, Webhook signature verification failed, Subscriptions do not activate in production
Key takeaways
- Sandbox objects and secrets do not work in live mode.
- A paid invoice is not the same as your app granting a plan.
- CLI, test, and live endpoints each have their own whsec_.
- Fix Dashboard evidence before rewriting the app.
If checkout works with a test card and dies the week you flip the live switch, the product did not suddenly forget how to charge a card. You left the sandbox.
Stripe treats sandbox keys and live keys as separate worlds. Objects do not cross. A sandbox Customer is not a live Customer. A sandbox webhook secret is not the live one. Stripe says this directly in its API keys documentation: objects in one mode are not accessible to the other.
That is why founders get a receipt-shaped UI, a happy test card, and then one of these: the live customer is charged and never gets access, the live customer is not charged at all, or the Dashboard shows a paid invoice while the app still treats them as a free user.
The money already moved. Your app did not notice.
Most SaaS billing is asynchronous. The browser finishes Checkout. The bank or card network may still need authentication. Stripe then tells your server what happened. Your server is supposed to mark the user paid, start the subscription state, and unlock the product.
Stripe's subscription webhook guide is blunt about this: a subscription integration needs a destination that handles events, because most subscription activity happens asynchronously. invoice.paid is the event they point to when you should provision access and the subscription is active. invoice.payment_failed and invoice.payment_action_required are how you find out a live card needs a retry, a new method, or a 3D Secure challenge.
AI builders are good at the visible half. They draw a pricing page. They drop in Checkout. They store a stripe_customer_id if you ask. They are weak at the invisible half: a public HTTPS endpoint, the correct live signing secret, raw-body signature checks, and idempotent handling when Stripe delivers the same event twice.
This is the same production gap as a Lovable app that works in preview and breaks with real users. Preview traffic never exercises live webhooks. Real customers do.
Test mode and live mode are two different Stripe accounts in practice
Sandbox keys start with pk_test_ and sk_test_. Live keys start with pk_live_ and sk_live_. Stripe documents the split on the API keys page. Webhook signing secrets are not API keys at all. They live on each endpoint, start with whsec_, and change per endpoint and per mode.
When founders say test works and live fails, they usually mixed one of these:
- The site still uses pk_test_ in production, so live cards never hit the live account.
- The site uses pk_live_ but the server still uses sk_test_, so session creation fails or writes to the wrong place.
- Checkout is live, but the webhook endpoint is still the Stripe CLI secret from stripe listen.
- The webhook URL still points at localhost, a preview URL, or an old Vercel deployment.
- The endpoint exists, but it is not subscribed to the events that unlock the product.
Stripe CLI, Dashboard test endpoints, and Dashboard live endpoints each have their own whsec_. Using the wrong one produces the same signature error as a mangled request body.
That last point is official. Stripe's signature troubleshooting page says the most common error is the wrong endpoint secret, and that the CLI secret is not the Dashboard endpoint secret.
The three secrets that get mixed up
1. The publishable key in the browser
Safe to expose. It identifies the account and starts Checkout or Elements. If production still has pk_test_, real cards are talking to a sandbox. Nothing you do in the live Dashboard will match.
2. The secret key on the server
Not safe to expose. It creates Checkout Sessions and reads customers. If this leaks into a Vite or Lovable client bundle, you have a finance incident, not a styling bug. Restricted keys are what Stripe now recommends for new work. Rotate anything that has been in a chat log, a committed .env, or a screenshot.
3. The webhook signing secret
This is how you prove the POST came from Stripe. You pass the raw body, the Stripe-Signature header, and the whsec_ for that exact endpoint into constructEvent. Stripe's webhook guide is explicit: do not manipulate the raw body. Next.js, Express json middleware, and pretty-printed logging all break this in the same way. The error looks like a bad secret even when the secret is right.
Checkout succeeding is not the same as access being granted
Here is the sequence a founder feels as one button click, and a production system treats as four different jobs:
- Create a Checkout Session with live keys and the right price IDs.
- Collect payment, including any authentication the issuer requires.
- Receive invoice.paid or checkout.session.completed on a public HTTPS URL.
- Write plan, period end, and seat limits into your own database, then show the paid UI.
AI-generated apps often stop after step 1. They poll the client after redirect and hope the session looks paid. That works in sandbox when everything is instant and nobody challenges the card. It fails when the customer completes 3D Secure on another screen, when the webhook is 400ing in silence, or when two events arrive and the handler creates duplicate users.
Stripe also tells you not to assume event order. Distinct events can share a timestamp. Track event.id so retries do not double-provision. Live mode retries failed deliveries for up to three days with exponential backoff. That is in the webhook delivery docs. A handler that is slow, or that returns 500 while it emails invoices, will look haunted for days.
The live-only failures test cards hide
Sandbox cards are obedient. Live cards are not. The product can be fine in test and still fail for reasons that are not a missing env var.
- Strong Customer Authentication. invoice.payment_action_required means the customer has to authenticate. If you never send them back to Stripe, you keep an incomplete subscription and a confused buyer.
- Radar and issuer declines. Live fraud rules and banks decline cards that test numbers never simulate.
- Wrong price or product IDs. Live products are a different set of objects. A hardcoded sandbox price ID fails or charges the wrong thing.
- HTTPS and TLS. Live destinations must be public HTTPS. Stripe validates TLS 1.2 or 1.3 before it will send. A bad certificate looks like Stripe never called you.
- Preview URLs. A webhook registered to a Vercel preview host dies the next deploy. Register the production host.
None of this is exotic. It is why 'we will add billing later' turns into refund tickets the first weekend you run ads.
What to check in the Stripe Dashboard before you hire anyone
You can diagnose a lot without reading the repo. Do this in live mode, not sandbox.
- Payments or Invoices: did the money actually move? If no, the checkout or PaymentIntent is the bug. If yes, the webhook or entitlement write is the bug.
- Workbench, Event destinations: is there a live HTTPS endpoint on your real domain? Open a recent event. Was the response 2xx or 400?
- Reveal the signing secret on that same endpoint. Confirm the production host has that value, not the CLI value.
- Enabled events: for subscriptions you want at least invoice.paid, invoice.payment_failed, invoice.payment_action_required, and customer.subscription.updated / deleted. checkout.session.completed if you fulfill one-time Checkout.
- API keys: production browser traffic should show pk_live_. Server logs should never print a secret key.
If events are 400 with a signature error, you have a secret or raw-body problem. If events never appear, the URL is wrong, the host is down, or TLS is failing. If events are 200 and the user is still locked out, your handler acknowledges Stripe and then does not write the plan. That last one is an application bug. Stripe will not fix it for you.
When a vibe-coded Stripe integration is actually done
Treat billing as done only when all of this is true on a live test purchase you are willing to refund:
- A real card (or your own card) completes Checkout, including any authentication prompt.
- The live event destination shows 2xx for that payment.
- The user can sign out, sign back in, and still see the paid plan.
- A second user cannot see the first user's invoices or customer portal session.
- Cancel and failed-payment paths lock the product or send the customer a recovery email.
- The service role or secret key is not in the browser bundle.
If you have not tested a second user, you also have not tested Supabase RLS. Paid users leaking into each other's billing records is worse than a broken webhook.
What I would do if this were my SaaS
I would not start with a rewrite. I would start with the Dashboard. Money moved or it did not. Events are 2xx or they are not. The env on the production host matches the live endpoint or it does not.
Then I would make the webhook boring. One route. Raw body. constructEvent. Persist event.id. Grant access from invoice.paid when the subscription is active. Return 2xx fast. Put slow email and CRM work after the ack, not before it.
I would not ask an AI builder to 'just make Stripe work' again without pasting the live event ID, the HTTP status Stripe recorded, and the current endpoint URL. The model cannot see Workbench. You can.
If the app was assembled in Lovable, Bolt, or v0, I would also assume the fulfillment write is coupled to the UI. Move it server-side. Keep the publishable key in the client. Keep the secret key and webhook secret out of it.
When to hire someone instead of prompting the builder
Hire help when any of these are true:
- Real customers have been charged and cannot get in. That is a support fire, not a learning exercise.
- You cannot tell whether the failure is Checkout, webhooks, or your own database.
- The secret key or webhook secret has been in a frontend bundle, a public repo, or a shared chat.
- You need subscriptions, trials, seat changes, and failed-payment recovery, not a single test Checkout.
A useful first engagement is a production billing review, not a greenfield rebuild. Scope it like a real product SOW: live keys, event list, entitlement model, and who owns the Stripe account after the work.
If you only have sandbox traffic and no paying users yet, stay in test mode and finish the webhook path there with stripe listen, then register a live HTTPS destination before the first real charge. Do not learn this on a customer's card.
Frequently asked questions
Did my customer actually get charged?
Open live mode Payments or Invoices. If there is no paid object, Checkout or the PaymentIntent failed. If there is a paid object and they have no access, the webhook or your database write failed.
Why does Stripe say webhook signature verification failed?
Wrong whsec_ for that endpoint, or the framework parsed the body before constructEvent. Stripe documents both. The CLI secret is not the Dashboard live secret.
Which Stripe events should a SaaS subscription handle?
At minimum invoice.paid for access, invoice.payment_failed and invoice.payment_action_required for recovery, plus customer.subscription.updated and deleted. Stripe lists these in the subscription webhook guide.
Should I rebuild the app to fix live Stripe?
Usually no. Match live keys and the live destination, keep the raw body, persist event.id, and grant access from invoice.paid. Rebuild only if the secret key is in the client or billing is glued to the UI with no server path.
Can I stay in Stripe test mode until I have paying users?
Yes, and you should finish the webhook path there first. Register a live HTTPS destination before the first real charge. Do not learn billing on a customer's card.
Implementation table
| Fix | Problem | What to change | Metric | Tool |
|---|---|---|---|---|
| Swap to pk_live_ / sk_live_ and live price IDs | Test checkout works, live does not start | Replace sandbox keys and objects on the production host | Live Checkout Session creates without auth errors | Stripe API keys |
| Repair the live webhook and entitlement write | Customer paid, no access | Register HTTPS destination, verify 2xx, grant on invoice.paid | Live event shows 2xx and user sees the plan after refresh | Stripe Workbench |
| Match the endpoint secret and raw body | Signature verification failed | Reveal live whsec_, stop JSON-parsing before constructEvent | Same event ID verifies and stores once | Stripe signature docs |
| Handle invoice.payment_action_required | Live card needs authentication | Send the customer back to Stripe to complete SCA | Incomplete subscriptions drop after the customer returns | Subscription webhooks |
Sources & references
- Receive Stripe events in your webhook endpointStripe
Primary: HTTPS destinations, raw body, 2xx, live retries up to three days, TLS 1.2+.
- Resolve webhook signature verification errorsStripe
Primary: wrong whsec_, CLI vs Dashboard secrets, frameworks that mutate the body.
- Using webhooks with subscriptionsStripe
Primary: invoice.paid provisioning, payment_failed, payment_action_required.
- API keysStripe
Primary: sandbox vs live keys and objects, webhook secrets are not API keys.










