payments · tutorial · webhooks

Test Checkout Locally: Never Use a Real Card Again

Stripe CLI plus test clocks exercise every money path on localhost: grants, retries, cancels, refunds. No card, no deploy. Drill inside.

Crash test dummies standing and seated around a test sled in a laboratory
Crash test dummies standing and seated around a test sled in a laboratory · Photo: NIST, public domain, via Wikimedia Commons

The first real customer is your QA department

Launch day. A stranger pays $199 — and the webhook 500s on a header bug your devpasstesting never triggered, because dev testing meant clicking through with your own card in live mode and refunding yourself.

Or the cancel flow you never exercised strands a test subscription in active forever. Or the retry storm you never simulated triple-grants the second customer whose payment was merely slow.

Test checkout local discipline means every money path runs green on localhost before any stranger's card is involved. Billing untested is billing broken on someone else's card.

Live-testing vs local-drilling

Path Tested live (own card) Drilled local (CLI)
Happy-path grant Once, manually Scripted, repeatable
Webhook retry storm Never (hope) Replayed 5x in seconds
Cancel + revoke Never (real sub!) Test clock in minutes
Refund + dispute Refund yourself ($$$ fees) Trigger commands, $0
Expired card dunning Impossible safely 4000000000000341, instant
Regression after edits Manual re-click One script, every deploy

Three details make live-testing worse than incomplete:

  1. Live mode hides test-only bugs. Test clocks, trigger commands, and unsigned-payload drills exercise paths live mode cannot reach without real consequences. The bugs live exactly in untested paths — retries, disputes, expiries.
  2. Refunding yourself costs real fees. Every live test donates processing margin to the rail and litters the books with test rows that pollute year-one analytics. Local drills cost $0 and write to dev SQLite you reset freely.
  3. Manual clicks don't regress. The checkout you clicked in March breaks silently in June when a sibling post edits the paywall — and nobody re-clicks. A drill script runs per deploy in seconds; memory runs never.

The Stripe trap post built the handler. This post proves it before strangers arrive.

Test checkout local drill kit

Three tools, all free: Stripe CLI, test card numbers, test clocks — aimed at the same thin routes prod uses.

Step 1 — Forward real-shaped events to localhost.

stripe login
stripe listen --forward-to localhost:3000/api/webhooks/stripe
# terminal 2: fire every scenario without a browser:
stripe events trigger checkout.session.completed
stripe events trigger customer.subscription.deleted
stripe events trigger charge.refunded

The CLI signs forwarded events with your test webhook secret, so signature verification runs for real — unsigned-payload bugs surface here, not on launch day. Trigger commands cover the lifecycle verbs; manual replays (same event twice, five times) cover the dedupe the idempotency post demands.

Step 2 — Walk the card-number matrix for declines and edges.

4242 4242 4242 4242  # success, always
4000 0000 0000 0341  # attached but charge declines (dunning path)
4000 0000 0000 0002  # generic decline at checkout
4000 0027 6000 3184  # 3D Secure redirect flow

Each number exercises a distinct branch: success grants, decline triggers dunning email, 3DS tests the redirect return nobody codes carefully. Run all four through checkout monthly — card branches rot like all untested code, and decline paths rot fastest because success is the only path anyone clicks voluntarily.

Step 3 — Time-travel subscriptions with test clocks.

stripe test_clocks create --frozen-time=$(date +%s)
# attach customer → trial → advance a month → advance a year:
# trialing → active → past_due → canceled, each webhook verified

Test clocks compress the subscription lifecycle into minutes: trial expiry grants correctly, failed renewals dun, cancels revoke at period end, sweeps reconcile. The portal post's transitions get proven end to end before any human subscribes. Delete the clock after; the order rows in dev SQLite tell the story for review.

That's the architecture talking — money paths drilled on a laptop, trusted worldwide.

The pre-launch money matrix

Scenario Drill command Pass looks like
Grant once trigger completed 1 order, 1 receipt
Retry storm replay ×5 deduped ×4, silence
Decline 0341 card dunning email, no access
3DS flow 3184 card redirect → grant
Full lifecycle test clock, 1 year trial→active→canceled, books balance
Refund trigger refunded access revoked, row marked

Green across the matrix is the launch gate for billing: no ship until every row passes on localhost. Re-run per deploy touching payments, paywall, or webhooks — thirty seconds that guard the only code paths handling other people's money.

The real cost, stated: drills test the machinery, not the humans — a drilled path can still confuse real buyers (checkout copy, pricing clarity, trust signals need live eyes). Pair the matrix with one live test purchase (your card, immediately refunded) before launch: drills prove the pipes, the live dollar proves the human path.

FAQ

Can I test checkout local for Dodo and LemonSqueezy too?

Yes with their equivalents: test/sandbox modes plus dashboard-sent test events forwarded to localhost (tunneling via the same Cloudflare Tunnel you serve through, or plain localhost listeners). The matrix is rail-agnostic — grant, retry, cancel, refund, decline — only the trigger mechanism changes. Drill every rail you operate; copy the matrix, swap the commands.

How do I keep test data out of production analytics?

Physically separate: dev drills write to local SQLite (reset freely), staging uses test-mode keys (real-shaped, unreal money), prod analytics query only live-mode rows. Never share a database across modes — the one time test rows pollute revenue charts, every later number carries doubt. Separation is a topology decision made once, honored forever.

Should the drill script run in CI on every push?

The fast subset (signature rejection, dedupe replay against ephemeral DB, paywall matrix) runs per push in seconds with Stripe CLI in CI. Full lifecycle clocks run nightly — minutes, not seconds. Split by speed: gate the merge on the fast half, trust the nightly for the slow half. Billing CI is the rare suite worth its runtime twice over.

What test card covers 3D Secure + saved-card + off-session?

Combine: 3184 for the 3DS redirect, attach to customer, then trigger off-session invoice payment for the saved-card path. Three scenarios, one card family, all local. Off-session failures (expired mandate, declined renewal) are the dunning system's bread and butter — drill the exact sequence your recovery emails claim to handle, or the emails lie.

How do I drill the nightly sweep without waiting overnight?

Run your reconcile job directly against dev with staged mismatches, plus unit-test the reconcile logic with fixture rows. The sweep's value is coverage of webhook misses — simulate misses by deleting a test grant's webhook trace, then watch the sweep restore truth and scream. Backstops get tested by staging the failure they backstop.

When is manual live testing still necessary?

Once per rail before launch (your card, immediate refund: proves the human path end to end) and once per checkout redesign (copy, layout, trust signals need eyes, not scripts). Everything between is drills. Live tests are ceremonies with fees attached — rare, deliberate, and never a substitute for the matrix.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading