Skip to content
FlowPatrol

Guide

Why did my Stripe checkout stop working after an AI edit?

Checkout worked, an AI edit shipped, and the pay button now does nothing, Stripe shows an error, or the card goes through and the app still says unpaid. The card number is usually fine. The edit broke the handoff between the button, the route that creates the Checkout Session, and Stripe. Read the failing request before you rotate keys or recreate products.

This is not a launch checklist. For a first pass of signup, login, and checkout on a published Lovable URL, use how to test a Lovable app before launch. If the form never signs the user in, use why Supabase login stopped working after an AI edit. Preview keys that were never copied to production are the short list in checkout works in preview but fails live.

Read the failure before you change Stripe

Use the published URL in a private window. The editor preview often still has the old environment values, and an admin session can skip the paywall. Open the browser tools, click Pay, and watch Network and Console.

  1. No request leaves the page: the handler or the redirect was removed. Use the pay button section.
  2. The console says the request was blocked by CORS, or the failed call is OPTIONS to a Supabase function: use the CORS section.
  3. Your route returns an error. Copy the body. “Neither apiKey nor config.authenticator provided” means the secret key variable is empty, so Stripe was never called. “Invalid API Key provided” means Stripe rejected the value. “No such price” means that price ID is not in this account and mode. “You must provide at least one recurring price in subscription mode when using prices.” means the price is one-time and the session mode is subscription.
  4. The browser reaches checkout.stripe.com and Stripe’s page errors: open that Checkout Session in the Dashboard. A message that the session belongs to the other mode means the publishable key and the secret key disagree.
  5. Stripe takes the card and your app stays unpaid: open Workbench, or Developers then Webhooks, in the same mode as the secret key. No delivery, or a delivery that is not 2xx, is the webhook section. A 2xx delivery with no database row is the row level security section.
  6. Switch the Dashboard’s test/live toggle until the failed request shows in the logs. A live-key request stays hidden while the Dashboard is on test mode.

The publishable key and the secret key were swapped or emptied

The browser may hold the publishable key, pk_test_ or pk_live_. The server alone holds the secret key, sk_test_ or sk_live_, or a restricted key, rk_test_ or rk_live_. Stripe.js rejects a secret key in the page. The secret API rejects a publishable key.

  1. Search for pk_, sk_, rk_, and the names the checkout route reads. A string literal is a key the edit pasted into source. Move it to the host’s environment settings, roll it in the Dashboard, and do not paste the new secret into the chat.
  2. Next.js inlines only names that start with NEXT_PUBLIC_. Vite inlines only names that start with VITE_. Renaming NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY to STRIPE_PUBLISHABLE_KEY empties the browser value and leaves the server secret in place. Those public values are copied at build time, so publish again after you correct them.
  3. Compare names on the published deployment. Code that reads STRIPE_SECRET_KEY still fails when the host has STRIPE_API_KEY or STRIPE_SK. An empty secret throws before Stripe sees a request. On Lovable, Bolt, v0, Replit, and similar hosts the values live in the deployment settings. A new name does not copy the old value. The homepage can still load.
  4. A restricted key needs permission to write Checkout Sessions. If the log says that key lacks permission, add it or use the secret key on the server. Do not move the secret key into the browser.

Test mode and live mode no longer match

Test mode and live mode are separate catalogs. A price, customer, webhook endpoint, and signing secret from one mode do not exist in the other. sk_test_ and pk_test_ see test objects. sk_live_ and pk_live_ see live objects.

  1. When the price error adds “a similar object exists in live mode, but a test mode key was used to make this request,” the ID is real and the key is the other mode. The reverse sentence means a test price was sent with a live key.
  2. Without that sentence, the price is not in this Stripe account: another account, an old sandbox, or an ID the editor made up. Open the product in the same mode as the secret key and compare the price ID.
  3. The publishable key has to match the secret that created the session. A live session opened with pk_test_ fails in the browser. Preview and production are separate settings. Publish again after you change either one.
  4. 4242 4242 4242 4242, any future expiry, any three-digit CVC, and any postal code is Stripe’s test card. It works with test keys. A decline that treats it as a real card means the session is live. Do not use that card with a live key.
  5. Copy to live mode on the product page creates new live IDs. The test price ID does not start working with a live key. Put the live price ID in production only.

The webhook URL or the signing secret no longer matches

Hosted Checkout can charge the card when your app never hears about it. Stripe posts checkout.session.completed to the endpoint you registered. That route has to verify the signature and write the paid state. A renamed file, or a new redirect, drops the call while the Stripe payment still succeeds.

  1. Open Workbench, or Developers then Webhooks, in the same mode as the secret key. Test and live each have their own endpoint and their own whsec_ signing secret. The secret stripe listen prints is a third value, only for events the CLI forwards. Using it on the published app fails verification.
  2. The endpoint URL has to be the public route, including https and the path. /api/stripe/webhook does not match /api/webhooks/stripe, a new trailing slash, or a www host the site now redirects to. Stripe treats a redirect as a failed delivery, so a 301 or 308 is broken even when the new route works in the browser.
  3. No delivery attempts means the URL is wrong or the event is not selected. Select checkout.session.completed. If you take delayed methods such as some bank debits, also select checkout.session.async_payment_succeeded. A handler that still switches on payment_intent.succeeded ignores the new name and can still return 2xx.
  4. A 400 that says “No signatures found matching the expected signature for payload.” means constructEvent got the wrong secret, the wrong Stripe-Signature header, or a body that is not the raw bytes. Parsing JSON and stringifying it again changes those bytes. In the Next.js App Router, pass request.text(). On a Supabase or Firebase function, pass the platform’s raw body. The third argument is the whsec_ secret, not the secret key.
  5. A 500 after a valid signature means the write failed. A row-level security line belongs in that section. Any other line, fix the one the function log names. Then send a test event, or pay once in test mode, and confirm a 2xx plus an order row.

The success or cancel URL now points at the wrong place

success_url and cancel_url have to be absolute. Stripe sends the browser there after the hosted page. A relative path, or http://localhost:3000 left in by the edit, is where the customer lands after the charge.

  1. Open the Checkout Session and read success_url and cancel_url. Localhost, a preview host, or a path that 404s on the published site is the bug. Change the values passed to checkout.sessions.create, then publish.
  2. Hosted Checkout uses success_url and cancel_url. Embedded Checkout uses return_url. Switching styles without the matching field fails session creation, and the error names the missing URL.
  3. Stripe replaces the {CHECKOUT_SESSION_ID} token in the success URL. Encoded or dropped braces leave the success page unable to load the session. Retrieve it on the server with the secret key and require payment_status paid before you unlock anything. Anyone can open the success route.

The price ID was hard-coded, swapped, or replaced with a product ID

A line item takes a price ID, price_. A product ID, prod_, is not a substitute. Each mode has its own IDs. An ID from a tutorial, the other mode, or the other plan fails, or it charges the wrong amount.

  1. In the same mode as the secret key, copy the ID on the price row. The product ID at the top of the page is the wrong value. Passing prod_ where a price belongs comes back as no such price.
  2. Search for price_ and prod_. A hard-coded ID ignores the environment variable you just changed. Swapped plan IDs still return a session, and the Checkout page shows the other amount. Compare that amount with the plan the button named.
  3. mode payment takes a one-time price. mode subscription needs a recurring price. A one-time price as the only subscription line is the error in the first section. A recurring price sent as payment is the same mismatch.
  4. Store the ID in that deployment’s environment, or set the same lookup key on the test price and the live price and list prices by that key. Do not ask the editor to invent an ID.

CORS or the edge function stopped the session from being created

The browser should not call api.stripe.com with the secret. That API does not allow your origin, and the secret would be in the page. The button calls your server or a Supabase Edge Function, which creates the session and returns its URL.

  1. A CORS error, often on OPTIONS, means the function used to answer the preflight and the edit removed it. Send Access-Control-Allow-Origin for your site, or *, and Access-Control-Allow-Headers covering authorization, apikey, and content-type. Supabase functions also expect the anon key in apikey. A 401 after a successful preflight is a missing header, not a price error.
  2. A 404 on the function URL means it was renamed or deleted. Point the button at the path that still creates the session.
  3. A Content-Security-Policy that blocks js.stripe.com matters for Stripe.js or the Payment Element. The console says the script was refused. Hosted Checkout that only redirects to the session URL does not load that script.
  4. If the route moved to the Edge runtime and the log says the Stripe library failed to start, put it back on the Node runtime, or create the session with a server-side request that keeps the secret off the page.

Row level security blocked the order write

Sign-in can succeed, and Stripe can show the payment succeeded, while the app stays unpaid. The webhook has no user, so auth.uid() is null. A policy written for the signed-in customer does not allow that insert.

  1. “new row violates row-level security policy” means the writer is not allowed. The webhook should use the Supabase service role key, which bypasses row level security. The anon key does not. Keep the service role key in the function secrets, not in the browser. Reusing the browser client in the webhook swaps in the anon key.
  2. The SQL editor bypasses policies. A missing row means the insert failed. A row that exists while the success page is empty means the select policy is hiding it. The customer needs a select policy for their own rows. The service-role webhook does not.
  3. Do not turn row level security off. That lets one customer read another customer’s orders. Change the writer and the select policy for that table only. A missing profile after signup is a different break, covered in the Supabase login guide.

The pay button lost its handler or its redirect

  1. Search for the click handler, checkout.sessions, and session.url. A button that is only a link back to pricing, or a submit button whose function was deleted, sends no request. Restore the call to the route that creates the session.
  2. Read the field the function returns. If the edit renamed url to sessionUrl or checkoutUrl and the button still reads url, the redirect target is empty and the page stays where it is.
  3. Leave the site with window.location.assign(session.url), or with an ordinary link. The Next.js client router, and other in-app navigators, do not open https://checkout.stripe.com. The session can be valid and the customer never sees it.
  4. Write down the visible words on the control, such as “Pay”, “Subscribe”, or “Checkout”. A later check that looks for the old words fails when the label changes, even if the handler is intact.

A prompt you can paste

Paste this into Lovable, Bolt, v0, Cursor, or the editor you used, after you have the error from the browser or the Dashboard. Replace the brackets. It keeps the edit on checkout.

Stripe checkout broke on my published app after the last edit. Do not change unrelated pages, auth, or layout.

What I see: [button does nothing / error text / Stripe page / paid in Stripe but unpaid in the app]
Page: [URL]
Request that failed: [path and status, or "no request fired"]
Stripe Dashboard error, if any: [paste the message, including the request id]
Mode of the secret key on the server: [test or live]

Fix checkout only. Check these and change only what is wrong:
1. The server uses the secret key (sk_test_, sk_live_, rk_test_, or rk_live_). The browser uses the matching publishable key (pk_test_ or pk_live_). Do not put the secret key in client code or in a variable whose name starts with NEXT_PUBLIC_ or VITE_.
2. The publishable key, the secret key, the price IDs, and the webhook signing secret are all from the same Stripe mode.
3. success_url and cancel_url are absolute https URLs on the production origin, and those routes exist. Hosted Checkout redirects with window.location to the session URL. Do not send https://checkout.stripe.com through the client router.
4. line_items uses a price ID (price_), not a product ID (prod_). mode is subscription only when that price is recurring, and payment when it is one-time.
5. The webhook route matches the endpoint URL in the Stripe Dashboard for this mode. It verifies Stripe-Signature with that endpoint's whsec_ secret against the raw body. On checkout.session.completed it records the paid order with the service role key so row level security does not block the write.
6. The pay button still calls that server route.

Do not invent price IDs or keys. Leave a comment where I must paste the values from the Stripe Dashboard and the host's environment settings.

How to keep the next edit from doing this

  1. After any prompt that touches payments, environment names, routes, or SQL, use a private window on the published URL and click Pay. On test keys, finish one payment with the test card, confirm the paid sentence, and reload. On live keys, confirm Checkout opens for the amount you meant to sell, unless you intend a small live charge you will refund.
  2. In the diff, look for STRIPE_, pk_, sk_, price_, prod_, success_url, cancel_url, return_url, constructEvent, checkout.sessions, window.location, router.push, Access-Control-Allow-Origin, and the service role key.
  3. In the same prompt as a visual change, tell the editor not to rename payment variables, price IDs, or the webhook path. Keep a checkout rewrite out of an unrelated feature so the diff stays small enough to check.
  4. Write down the pay button text, the success path, and the sentence that means paid. Compare them after the next edit. The homepage can stay green while this path is broken.

The free scan only loads the page. It does not click Pay, and it does not type a card on checkout.stripe.com. To rerun the steps that stay on your site, create an account and describe the flow, such as “Open pricing, click Pay, then confirm the success page.” The watch stays on your hostname. Finish one test payment yourself when the edit touches Stripe. A failure email names the step and includes a prompt you can paste back into the editor.

Starter is $19/mo. Pro is $49/mo. What each plan includes, and how a watched flow compares with testing by hand, is on the facts page. FlowPatrol is not affiliated with Stripe.