How to test a Stripe checkout end to end
A Stripe integration is only as reliable as the failure paths you have tested. This guide walks through a complete test plan for a Stripe checkout: the environment and keys, the documented test cards for success and every common decline, 3D Secure, Radar, webhooks and the checks to run before you switch to live keys.
Key takeaways
- Test in a Stripe sandbox with
pk_test_/sk_test_keys. Stripe’s services agreement prohibits testing in live mode with real card details. - Use the documented test cards in the browser and test PaymentMethod IDs such as
pm_card_visain server-side code and automated tests. - Fulfill orders from webhooks, not from the redirect. Test your handler locally with
stripe listen --forward-toandstripe trigger. - 3D Secure only appears with the 3DS test cards and only in your own integration — payments created in the Dashboard never redirect to 3DS.
- Before launch, swap and rotate keys, register live webhook endpoints, and make sure your handler copes with duplicate and out-of-order events.
On this page
Most checkout bugs don’t show up on the happy path. They appear when a card is declined, when the bank asks for 3D Secure, when a webhook arrives twice, or when the customer closes the tab before the redirect. Stripe gives you a full set of tools to reproduce each of those situations safely. This guide puts them together into one test plan you can run before every release.
Set up a safe test environment
Stripe separates test activity from live activity. You test in a sandbox — an isolated test environment you select from the account picker in the Dashboard — and every API call uses that environment’s test keys. Test transactions never move money, and objects you create there (products, prices, coupons, customers) are not available in live mode.
| Key | Prefix | Where it lives |
|---|---|---|
| Publishable key | pk_test_ | Browser or mobile app (Stripe.js, Elements, mobile SDKs) |
| Secret key | sk_test_ | Your server only, loaded from an environment variable or secret manager |
| Webhook signing secret | whsec_ | Your server, one per endpoint (and a separate one per environment) |
Two rules matter more than any other. First, never use real card details: Stripe’s testing documentation says so explicitly, and its services agreement prohibits testing in live mode with real payment method details. Second, keep keys out of your code. Load them from configuration so that the switch from test to live is a configuration change, not a code change — and so that a test key can never reach production by accident. Stripe also notes that you can’t process live payments while an integration still uses test keys.
Why not use generated numbers here?
A random Luhn-valid number from a card generator is fine for testing your own form validation, but Stripe’s test environment simulates outcomes only for its documented test cards. Use generated numbers before the payment request and Stripe’s numbers from the moment you call the API.
Test the happy path with test cards
Start with a successful payment for every card brand you accept. Each test card works with any future expiry date, such as 12/34, and any three-digit CVC (four digits for American Express). Other fields, such as the name or postal code, can be any value.
| Brand | Test number | Why test it |
|---|---|---|
| Visa | 4242 4242 4242 4242 | Baseline success |
| Visa (debit) | 4000 0566 5566 5556 | Debit funding type in your reporting |
| Mastercard | 5555 5555 5555 4444 | Classic 51–55 range |
| Mastercard (2-series) | 2223 0031 2200 3222 | Catches brand detection that only knows 51–55 |
| American Express | 3782 822463 10005 | 15 digits, 4-digit CID, 4-6-5 grouping |
| Discover | 6011 1111 1111 1117 | 6011 prefix |
| Diners Club (14-digit) | 3622 720627 1667 | Shortest common length |
| JCB | 3566 0020 2036 0505 | 3528–3589 range |
| UnionPay (19-digit) | 6205 5000 0000 0000 004 | Longest length; input masks often cut it off |
For each card, confirm the whole chain rather than just the success screen: the PaymentIntent reaches succeeded, your webhook handler receives payment_intent.succeeded, the order is marked paid exactly once, and the confirmation email or receipt goes out. The full list, including co-branded cards, is on our Stripe test cards page.
Raw numbers in the browser, PaymentMethod IDs on the server
Type the raw numbers into Elements, Checkout or Payment Links, the same way a customer would. In server-side code and automated tests, Stripe recommends test PaymentMethods such as pm_card_visa instead of card numbers, warning that code which sends raw card numbers to the API might not be PCI-compliant when it goes live.
const Stripe = require('stripe');
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // sk_test_... from config
test('declined card leaves the order unpaid', async () => {
const order = await createOrder({ amount: 2500, currency: 'usd' });
await expect(
stripe.paymentIntents.create({
amount: order.amount,
currency: order.currency,
payment_method: 'pm_card_visa_chargeDeclined',
confirm: true,
automatic_payment_methods: { enabled: true, allow_redirects: 'never' },
})
).rejects.toMatchObject({ type: 'StripeCardError', code: 'card_declined' });
expect((await getOrder(order.id)).status).toBe('pending');
});Simulate declines and card errors
Declines are where real customers get stuck, so test each one your UI distinguishes. Stripe documents a test card for each common error and decline code:
| Scenario | Card | Result | PaymentMethod |
|---|---|---|---|
| Generic decline | 4000 0000 0000 0002 | card_declined / generic_decline | pm_card_visa_chargeDeclined |
| Insufficient funds | 4000 0000 0000 9995 | card_declined / insufficient_funds | pm_card_visa_chargeDeclinedInsufficientFunds |
| Expired card | 4000 0000 0000 0069 | expired_card | pm_card_chargeDeclinedExpiredCard |
| Incorrect CVC | 4000 0000 0000 0127 | incorrect_cvc | pm_card_chargeDeclinedIncorrectCvc |
| Processing error | 4000 0000 0000 0119 | processing_error | — |
| Lost / stolen card | 4000 0000 0000 9987 | card_declined / lost_card | — |
| Attaches, then fails when charged | 4000 0000 0000 0341 | Attach succeeds, charge fails | pm_card_chargeCustomerFail |
Three details trip up many test plans:
- Cards that simulate issuer declines can’t be attached to a Customer. If you save cards first and charge later (subscriptions, one-click checkout), use
4000 0000 0000 0341: attaching succeeds and the later charge fails, which is exactly the renewal-failure path you need to test. - Client-side validation errors are separate. An invalid month such as 13 returns
invalid_expiry_month, and a two-digit CVC such as 99 returnsinvalid_cvc. A 4242 number with a wrong last digit fails the Luhn check before any decline logic runs. - Map codes to helpful messages. “Your card has insufficient funds” and “Your card’s security code is incorrect” lead to different customer actions. Don’t show raw decline codes such as
lost_cardto the customer; a neutral “Your card was declined” is the safer message.
Test 3D Secure and requires_action
When a bank requires authentication, the PaymentIntent moves to requires_action and your front end must display the 3D Secure step. Stripe.js shows the authentication UI in a modal automatically when you confirm with its methods; if you control the flow yourself, you read next_action and redirect or open an iframe. In a sandbox Stripe shows a mock authentication page where you can complete or fail the challenge.
| Card | Behavior in Stripe’s docs |
|---|---|
| 4000 0027 6000 3184 | Requires authentication on all transactions, regardless of how the card is set up |
| 4000 0025 0000 3155 | Requires authentication for off-session payments unless the card was set up for future use; on-session payments always require it |
| 4000 0084 0000 1629 | 3DS required, then declined with card_declined after authentication |
| 4000 0000 0000 3055 | 3DS supported but not required |
| 3782 822463 10005 | 3DS not supported; the payment proceeds without authentication |
Run these through your real checkout, not the Dashboard: Stripe states that 3D Secure redirects won’t occur for payments created directly in the Dashboard. Only the cards in Stripe’s 3D Secure section reliably exercise authentication — other test cards may still trigger 3DS but return attempt_acknowledged to skip the extra steps. For mobile apps, Stripe also documents cards that force each challenge UI (out-of-band, one-time passcode, single select and multi select). Our 3-D Secure testing guide covers the outcomes in more depth and compares gateways.
Test Radar and fraud rules
If you rely on Radar, test what your checkout does when a payment is blocked or flagged. Blocked payments return a card error with the code fraud, and your UI should handle it like a decline without revealing why.
| Card | Radar behavior |
|---|---|
| 4100 0000 0000 0019 | Risk level “highest”; Radar always blocks it (pm_card_radarBlock) |
| 4000 0000 0000 4954 | Risk level “highest”; may be blocked depending on your settings |
| 4000 0000 0000 9235 | Risk level “elevated”; may be queued for review |
| 4000 0000 0000 0101 | CVC check fails if you provide a CVC |
| 4000 0000 0000 0036 | Postal code check fails if you provide a postal code |
Remember that Stripe skips CVC and postal code checks when the field is empty, so those checks can’t fail unless you send a value. You can also write custom Radar rules in your test environment to trigger 3D Secure on specific test cards. For a merchant’s view of automated fraud, see our guide to stopping card-testing attacks.
Test webhooks locally with the Stripe CLI
The redirect after payment is not a reliable signal: customers close tabs, networks drop, and some payment methods confirm minutes or days later. Stripe recommends driving fulfillment from webhooks. To receive events on your laptop without a public URL, use the Stripe CLI:
stripe login
stripe listen --forward-to localhost:4242/webhook
# Ready! Your webhook signing secret is 'whsec_...' (^C to quit)
# in a second terminal
stripe trigger payment_intent.succeeded
stripe trigger payment_intent.payment_failedPut the printed whsec_ secret in your local configuration and verify every request with Stripe’s library, using the raw request body and the Stripe-Signature header. Frameworks that parse JSON before your handler runs break the signature check, which is one of the most common local-testing failures.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = stripe.webhooks.constructEvent(req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send('Invalid signature');
}
if (await alreadyProcessed(event.id)) return res.sendStatus(200); // duplicate delivery
await queue.add(event); // do the slow work later
res.sendStatus(200);
});What to verify in your handler
- Fast 2xx responses. Return a success status before slow work such as emails or accounting updates; Stripe treats timeouts and redirects as failed deliveries.
- Duplicates. The same event can arrive more than once. Log processed event IDs and skip repeats.
- Ordering. Stripe doesn’t guarantee delivery order, so don’t assume
invoice.createdarrives beforeinvoice.paid. Fetch the object from the API if you need its latest state. - Retries. In live mode Stripe retries failed deliveries for up to three days; sandbox events are retried three times over a few hours. You can resend an event with
stripe events resend.
Make retries safe with idempotency keys
A network timeout after you send a payment request leaves you not knowing whether the payment was created. Stripe’s API solves this with the Idempotency-Key header: send the same key when you retry and Stripe returns the saved result of the first request instead of creating a second payment. All v1 POST requests accept keys of up to 255 characters; Stripe suggests V4 UUIDs and says keys can be removed once they are at least 24 hours old. If a retry reuses a key with different parameters, Stripe returns an error.
Test this deliberately: send the same create request twice with one key and assert that only one PaymentIntent exists. Then double-click your own “Pay” button and confirm the order is charged once.
Go-live checklist
Stripe designed its sandbox and live environments to behave as similarly as possible, so going live is mostly a key swap. Most launch-day problems come from the items around it:
- Rotate and secure keys. Rotate keys that may have been saved outside your codebase during development, and make sure no key is committed to the repository.
- Register live webhook endpoints. Test and live endpoints are separate and have different signing secrets. Confirm the live endpoint behaves exactly like the test one.
- Recreate test objects. Products, prices and coupons from the sandbox don’t exist in live mode. Recreate them with the IDs your code expects.
- Pin the API version. Set the API version in your library so behavior doesn’t change underneath you.
- Review error handling and logs. Distinguish card errors (show the customer a message) from your own request errors (alert your team), and check that your logs never store card numbers.
- Re-run the edge cases. Incomplete data, invalid data and duplicate requests, ideally tested by someone who didn’t write the code.
Finally, keep real card data out of every non-production system — logs, fixtures, screenshots and staging databases. Our guide to test data and PCI DSS explains why and how.
Frequently asked questions
What is the basic Stripe test card number?
4242 4242 4242 4242 (Visa) with any future expiry date, such as 12/34, and any three-digit CVC. It succeeds in a sandbox and is the usual starting point for the happy path.
Can I test Stripe with a real card?
No. Stripe’s testing documentation says not to use real card details, and its services agreement prohibits testing in live mode with real payment method details. Use test keys and the documented test cards.
Why does my CVC or postal code check never fail in test mode?
Stripe skips those checks when the field is empty. Send a CVC with 4000 0000 0000 0101 or a postal code with 4000 0000 0000 0036 and the check fails.
How do I test Stripe webhooks on localhost?
Install the Stripe CLI, run stripe login, then stripe listen --forward-to localhost:4242/webhook. The command prints a signing secret for your handler. Trigger events with stripe trigger payment_intent.succeeded or by running real test payments.
Why do I get HTTP 429 errors in my Stripe tests?
The rate limiter is stricter in test environments than in live mode. Reduce parallelism in your test suite and don’t use a sandbox for load testing.
Sources
Tools used in this guide
Related guides
Written for developers, QA teams and merchants. Generated card numbers are test data: they are not real cards and cannot pay for anything.