Stripe test mode gives you a second set of books to break things in. By the end of this you will have run a card through checkout, watched a decline arrive with a named decline code, had a dispute land on your own webhook handler, advanced a fourteen-day trial to its conversion charge in about a minute, and fired a card-tester-shaped burst at your own fraud checks to see whether anything stops it.
What you need first:
- A Stripe account. You do not need to be activated for live payments to use test mode.
- Node 18 or newer with the
stripepackage, or curl. The examples are Node and curl. - The Stripe CLI (
npm i -g @stripe/cli), logged in withstripe login. - A local server with a POST route to point webhooks at. Port 4242 below.
- One recurring
price_...in test mode, for the trial step.
Budget an hour. Steps 1 through 5 are the billing lifecycle. Step 6 is the one almost nobody runs.
Test mode vs live mode: two ledgers
Test mode is a separate set of books. Stripe's wording is that each mode has its own set of API keys, and objects in one mode aren't accessible to the other. A test price_... cannot go on a live subscription. A live cus_... returns resource_missing under a test key. Almost every mistake below is a version of forgetting that.
Stripe has since made the separation literal. Sandboxes are isolated environments you reach from the Sandboxes entry in the Dashboard account picker, each with its own keys and its own data, and the API keys page now toggles between sandbox and live. If your account still shows a Test mode switch, everything here works the same way. The ledger was always separate; the old Dashboard just hid the seam.
1. Get test keys in place without leaking live ones
Test keys are pk_test_, sk_test_, rk_test_. Live keys are pk_live_, sk_live_, rk_live_. Publishable keys are safe in the browser and secret keys are not, and Stripe now says outright that because you cannot limit their permissions, it doesn't recommend using secret keys for new use cases and points you at restricted keys instead. For the server that runs checkout, an rk_ scoped to PaymentIntents and Customers is worth the ten minutes.
Use one variable name in every environment with a different value, and assert the prefix at boot:
const key = process.env.STRIPE_SECRET_KEY;
if (!key) throw new Error("STRIPE_SECRET_KEY missing");
if (process.env.NODE_ENV !== "production" && !/^(sk|rk)_test_/.test(key)) {
throw new Error("live Stripe key outside production");
}
export const stripe = new Stripe(key);
Four lines, and they catch the failure that ends with a real card charged from a staging box.
The Dashboard toggle changes what you are looking at, never what your server is holding. Copying a cus_ out of a sandbox and pasting it into a production script is the same error running the other direction.
2. Run a real checkout, then break it with test cards
4242 4242 4242 4242, any future expiry, any CVC. That one you know. The useful cards are the failures, and the whole set is published: 4000000000000002 for a generic decline, 4000000000009995 for insufficient_funds, 4000000000000127 for incorrect_cvc, 4000000000000119 for processing_error, 4000000000003220 for a payment that requires 3D Secure, and 4100000000000019 for a card Radar always blocks.
Do not invent numbers to test declines. A number you made up is not a test card, so it fails validation rather than exercising the decline path your users hit, and you end up handling an error that never occurs in production.
Server side you can skip Elements entirely, because Stripe ships PaymentMethod IDs for the same scenarios:
const pi = await stripe.paymentIntents.create(
{
amount: 2900,
currency: "usd",
payment_method: "pm_card_visa_chargeDeclined",
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: "never" },
},
{ idempotencyKey: crypto.randomUUID() }
);
That throws a card error. Read err.code (card_declined) and err.decline_code (generic_decline), not the message string. When it works you will see the request logged with a 402, the PaymentIntent back at requires_payment_method, and the charge's outcome.reason naming the decline. Confirm with stripe payment_intents list --limit 3.
The idempotency key is not decoration. Stripe saves the status code and body of the first request made under a key and returns the same result on retries, including 500s, pruning keys once they are at least 24 hours old. Test your retry path here, where a double charge costs nothing.
3. Land a dispute in test mode before a real one lands on you
Charge 4000000000000259, or pm_card_createDispute from the server. The charge succeeds first and is disputed afterwards, which is the production ordering and the reason your handler cannot assume a dispute belongs to a recent charge. Two more worth running: 4000000000005423 produces an early fraud warning with no dispute, and 4000000000001976 produces an inquiry.
Then respond. In test mode the evidence text decides the outcome:
curl https://api.stripe.com/v1/disputes/dp_... \
-u "$STRIPE_TEST_SECRET_KEY:" \
-d "evidence[uncategorized_text]=winning_evidence"
winning_evidence closes the dispute as won and credits back the amount and fees, losing_evidence closes it as lost, and escalate_inquiry_evidence turns an inquiry into a full chargeback.
Watch charge.dispute.created and charge.dispute.closed reach your handler and check that you persist both. What the money and the fees do after that is a longer story.
4. Move a trial forward with a test clock
The Dashboard calls these Simulations. The API object is still a test clock, and it exists only in a sandbox.
const clock = await stripe.testHelpers.testClocks.create({
frozen_time: Math.floor(Date.parse("2026-01-01T00:00:00Z") / 1000),
name: "trial conversion",
});
const customer = await stripe.customers.create({
email: "[email protected]",
test_clock: clock.id,
payment_method: "pm_card_chargeCustomerFail",
invoice_settings: { default_payment_method: "pm_card_chargeCustomerFail" },
});
await stripe.subscriptions.create({
customer: customer.id,
items: [{ price: process.env.PRICE_ID! }],
trial_period_days: 14,
});
pm_card_chargeCustomerFail attaches to a customer and then fails when charged, which is exactly the trial-conversion failure you want to see. Advance the clock to day 11:
await stripe.testHelpers.testClocks.advance(clock.id, {
frozen_time: Math.floor(Date.parse("2026-01-12T00:00:00Z") / 1000),
});
Day 11 gives you customer.subscription.trial_will_end, which Stripe sends three days before the trial ends. Advance past day 14 and the conversion charge fails, producing invoice.payment_failed. Advance another hour to watch the draft invoice finalise, because subscription invoices sit in draft for roughly an hour by default.
Constraints that will waste an afternoon if you meet them cold:
- Time only moves forward. Set
frozen_timein the past at creation if you need history behind you. - One call advances up to two billing intervals, or up to two years with no subscription attached.
- List endpoints hide clock objects.
GET /v1/customerswill not return your clock customer unless you passtest_clock. - Three customers per clock, three subscriptions per customer, and the clock deletes itself after 30 days, taking its customers and subscriptions with it.
- Repeated writes to a subscription without advancing the clock trigger rate limit errors, because every request lands on the same frozen second.
5. Forward webhooks to localhost with the Stripe CLI
stripe listen --forward-to localhost:4242/webhook
The CLI prints a whsec_.... It is not the secret on your Dashboard endpoint, and mixing the two is the most common reason a local handler answers 400 to everything. Both values start with whsec_, so nothing in the string tells you which one you are holding. The CLI secret does not rotate: the reference says it will not change between restarts, so write it into .env.local once. stripe listen --print-secret prints it without starting a listener.
Narrow the stream and replay shapes on demand:
stripe listen --events charge.dispute.created,invoice.payment_failed \
--forward-to localhost:4242/webhook
stripe trigger charge.dispute.created
Three things to build now rather than after the first outage. Verify against the raw body, since any framework that parses JSON before verification breaks the signature. Return 2xx before doing work, because Stripe retries live deliveries for up to three days but retries sandbox deliveries only three times over a few hours, which makes a slow handler look healthier in test than it is. Deduplicate on event id and never depend on ordering, which Stripe does not guarantee.
For anything you missed with the laptop shut, stripe events resend evt_... --webhook-endpoint=we_... works for 30 days.
6. Attack your own signup with a test key
A green checkout proves your happy path works. It says nothing about your fraud path, because nothing you have done so far was hostile. The first hostile traffic your checkout sees should not be real traffic.
The shape to imitate is card testing: one script, many cards, small amounts or zero-amount setups, from one machine. Stripe's description is that fraudulent actors use scripts to test a large amount of card information at once, then cash the valid ones with merchants or resell them. On the Hacker News thread about a 2023 wave of these attacks, samwillis described the motive: "it's a pretty standard MO of fraudsters to check if the cards they have purchased/stolen are going to work, i.e. have a value to resell."
So run one against your own endpoint, on test keys:
for pm in pm_card_visa pm_card_mastercard pm_card_amex pm_card_discover \
pm_card_visa_chargeDeclined pm_card_chargeDeclinedIncorrectCvc; do
for i in 1 2 3 4; do
curl -s https://api.stripe.com/v1/payment_intents \
-u "$STRIPE_TEST_SECRET_KEY:" \
-d amount=103 -d currency=usd -d confirm=true \
-d payment_method=$pm \
-d "automatic_payment_methods[enabled]=true" \
-d "automatic_payment_methods[allow_redirects]=never" > /dev/null
done
done
Twenty-four attempts, six distinct cards, one source. Now read your own logs. Did a velocity rule fire, and on which key: IP, device, card fingerprint, account? If the answer is IP, a residential proxy pool makes that count close to worthless, and Stripe agrees that filters based on a single heuristic such as IP addresses are usually not sufficient on their own. If nothing fired, you have found the gap at the cheapest possible price. The full shape of a Stripe card-testing run is worth reading before you decide what to build.
Portreeve, the abuse firewall I build, keeps the same split Stripe does: sk_test_ and pk_test_ keys, with test-mode events fully isolated from live, so a probe run screened under a test key never enters the live identity graph. A checkout_attempt verdict reads card probe patterns directly, meaning small-amount and $0 authorization ladders and distinct cards per device, and the two block-strength card rules read a device-keyed counter, so without the browser device token those shapes top out at review instead of block. Test mode and feedback covers pointing a probe run at test keys; device fingerprinting covers the token.
Keep the burst on test keys. People do run this against live and then get to explain the decline spike to their processor.
What changes for production
Swap keys by environment variable, never by editing code. Stripe shows a live secret key once, when it is created, while sandbox keys stay readable in the Dashboard forever, so the habit of copying a key out of the Dashboard breaks on the day you go live.
Each live webhook endpoint has its own signing secret. When you roll one, Stripe can keep both valid for up to 24 hours, so deploy inside that window.
Test clocks need no cleanup. They cannot touch live objects and they expire on their own.
Live disputes ignore the magic evidence strings. A real issuer reads what you submitted and takes 60 to 75 days to decide, which is why the handler you wrote in step 3 has to survive a redeploy or two.
The one habit worth dropping at the boundary is load testing against test keys. Sandbox rate limits are lower than live, 25 requests per second against 100, so a sandbox load test finds ceilings production does not have and misses the latency profile of a real card network.
Portreeve's free tier screens 1,000 events a month with no card, and every account gets test keys alongside live ones, so you can point step 6 at it before you point anything at live. Start here.