Guide
How to test a Stripe Checkout flow without paying
Test cards are declined on live keys, and a real charge costs money and fees. Here is what a checkout test actually has to prove, and four ways to prove it on production without spending a cent.
Updated · 8 min read
What a checkout test has to prove
“Checkout works” is four separate claims, and each one breaks on its own. Your server has to create a Checkout Session with the right price, mode and return URLs. Stripe has to render that session for the visitor. After payment, Stripe has to call your webhook and your webhook has to grant access. And before payment, the paid area has to stay closed. Most outages that reach a support inbox are in the first and the last of these: an environment variable missing from production so the session is created with a test price id, or a gate that opens for anyone who is signed in.
A useful test therefore starts from a brand-new account, not your own admin user, because a new account is the one that exercises signup, the welcome email and the empty state of your billing page. It also runs against production, because staging has a different Stripe account, different webhooks endpoints, different tax settings and, usually, a different price id.
Why test mode is not enough
Stripe gives every account two sets of keys. With the test keys (sk_test_…, pk_test_…) you can pay with 4242 4242 4242 4242, any future expiry and any CVC, and nothing moves. With the live keys the same card is declined, on purpose. That leaves a gap: everything you verified in test mode was verified against a copy of your configuration, and the copy is what you deploy least carefully. Prices are created twice, once per mode, so the live price id is a different string. Webhook endpoints are registered per mode. Promotion codes, tax registrations and the customer portal configuration are all per mode. A test-mode green light says the code is right; it says nothing about the production wiring.
Option 1: test mode with a test card
Still the right first step, locally and on staging, and the only option that exercises the webhook end to end for free. Point your app at the test keys, create a Checkout Session, pay with the test card, and forward the webhook to your machine with the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhook/stripe
# in another terminal, or just complete the test checkout in the browser
stripe trigger checkout.session.completedUse 4000 0000 0000 3220 when you want the 3D Secure challenge in the loop, and 4000 0000 0000 0002 for a decline, so the failure path renders something sensible. The limitation is the one above: it proves the code, not production.
Option 2: reach live Checkout and stop
This is the check you can run against production every day, with live keys, at no cost. Sign up with a fresh email, press your upgrade button, and let the browser land on checkout.stripe.com. Then read the page instead of paying:
- The URL starts with
https://checkout.stripe.com/c/pay/cs_live_…. Acs_test_session on production means the server is holding test keys. - The product summary on the left names the plan and the total, for example “Pro · $79.00 per month”. Compare it with the price you meant to sell. A price id swapped in a deploy shows up here as the wrong name or the wrong amount.
- The email field is pre-filled when your server passed
customer_emailor created the customer first. An empty field is often a sign the session was created without the signed-in user.
Then close the tab. An abandoned session expires on its own after 24 hours, nothing is charged, and no customer sees anything. What this proves is the hand-off: your app can create a live session for a new account with the right plan. What it cannot prove is the webhook, because no payment happens. Pair it with the paywall check below and you have covered the two failures that are actually common.
Option 3: a 100%-off promotion code
When you need the webhook path proven on live keys, a full discount turns a real checkout into a free one. In the Stripe Dashboard, create a coupon with 100% off and a duration of “forever” (or as many months as you want the test subscription to last), then attach a promotion code to it, say QA100, limited to a handful of redemptions. Two settings on the session make it usable:
const session = await stripe.checkout.sessions.create({
mode: "subscription",
line_items: [{ price: process.env.STRIPE_PRICE_PRO, quantity: 1 }],
allow_promotion_codes: true,
// Skip the card form when the total is zero, so nobody has to type one.
payment_method_collection: "if_required",
customer_email: user.email,
success_url: `${origin}/dashboard/settings/billing?paid=1`,
cancel_url: `${origin}/pricing`,
});Redeem the code on the live Checkout page, finish, and watch checkout.session.completed arrive at the production webhook, followed by invoice.paid for a $0 invoice. The paid area should open for that account within seconds. This is the closest thing to a real purchase that costs nothing.
The costs are administrative. Each test creates a real customer and a real subscription on the live account, so name the test accounts recognisably and cancel the subscriptions afterwards, or your MRR dashboard and your customer count drift. Also decide whether allow_promotion_codes should stay on for everyone; most subscription products leave it on, but it does add a “Add promotion code” link every visitor can see.
Option 4: a real card, then a refund
The last resort, and sometimes the right one before a launch: pay with a company card, confirm the paid area opens, then refund the charge from the Dashboard and cancel the subscription. It proves everything, including the customer's receipt email and the statement descriptor. Stripe does not return its processing fee on refunds in most regions, so each rehearsal costs a small amount, and the refund itself becomes a line in your books. Do it once per release that touches billing, not on a schedule.
The check most teams forget: the paywall before payment
Every option above starts from a new account, and that is the moment to test the gate. Before you press Pay, open the paid page with the unpaid account. It must not render the paid content. The common bug is granting access when the Checkout Session is created, or when the user returns to the success URL, instead of when the webhook confirms payment. The success URL is public: anyone can type it. Only the webhook knows money moved.
Two ways to make the gate checkable by a script as well as by a person: render an element such as <section data-plan="pro"> only for paying accounts, or expose a small JSON endpoint that returns { "hasAccess": true } only after the webhook ran. Either one gives you a yes-or-no answer without reading the page.
Doing this after every deploy
Once by hand is a launch checklist. The problem is the deploy three weeks later that drops an environment variable, renames a button or changes the plan dialog. A scripted browser can repeat option 2 and the paywall check every time you ship, from a fresh account, and keep the screenshot and network capture as proof. That is what Mystra does: it signs up on your app with its own throwaway inbox, follows the welcome email, reaches live Stripe Checkout, records what it shows, stops without paying, and checks the paid area stayed closed. When a step breaks with proof, a 5xx, a timeout, a missing email or an open paywall, it alerts you with the evidence. Uncertain steps never page anyone.
Checklist
- Test mode with a test card, locally, webhook forwarded.
- On production, from a new account: reach live Checkout, confirm
cs_live_, the plan name and the amount, then stop. - With the same unpaid account, open the paid page. It must be closed.
- Before a billing-related release: one 100%-off purchase or one refunded real charge, then cancel the subscription.
- Repeat steps 2 and 3 after every deploy, automatically.