Payments PendingTesting Stripe Payment Failures and Decline Codes Systematically

Testing Stripe Payment Failures and Decline Codes Systematically

Stripe's test cards let you catch payment failures before customers hit them.

· 10 min read · Updated

A checkout that only ever sees a successful card looks finished long before it actually is. The form submits, the PaymentIntent succeeds, the CI pipeline goes green, and every signal available to the team says ship it. Declined cards, insufficient-funds responses, 3D Secure challenges, and fraud flags sit behind that green checkmark as untested code paths, waiting for a real customer to be the one who finds them. When that happens, the customer usually lands on a blank page or a frozen spinner, the order sits half-finished in a queue someone in support has to chase down by hand, and the business loses money in a way nobody notices until the pattern appears in a weekly report. A generic "something went wrong" screen on an insufficient-funds decline is a bug that a disciplined test suite can catch before a single real card is ever charged.

How Stripe's test mode is structured

Stripe's test mode is a complete parallel environment: its own API keys, its own dashboard view, its own set of objects, all built to simulate transactions without moving a dollar of real money or touching a real bank. Test keys carry the prefixes sk_test_ and pk_test_, and the boundary around them is strict by design: send a test card number under a live key and it fails, and send a real card number into test mode and it gets rejected too. That separation keeps test traffic from ever crossing into production by accident, and it matters because it means a team can run an entire failure catalogue safely, repeatedly, without a compliance officer losing sleep over it.

The card number itself is the only input Stripe actually checks. The outcome is determined entirely by which digits sit in the card number field. It's worth knowing, too, that card-level state can carry over between tests that reuse the same number, so a test suite that depends on starting clean needs a distinct test card for each independent scenario.

What test mode does not hand a team for free is a working browser automation strategy. Having the right card number gets a request through the API, but actually running a checkout form end to end, the way a customer would, means contending with Stripe.js and the behavioral fingerprinting it runs to separate real users from bots, which behaves differently depending on whether the browser driving it is headless or not. A test card is a credential. And test mode is not a load-testing tool: Stripe's documentation is explicit that running load tests against the sandbox can trip rate limits meant for normal test traffic.

The full decline-code matrix: every named failure mode and the card that triggers it

Stripe assigns a dedicated test card to every decline it can return, and that matters more than it might look at first. A team can force any named failure mode on command, as many times as needed. None of them should still be a surprise once real customers start paying.

The standard card declines cover the everyday reasons a charge fails. The number 4000 0000 0000 0002 returns a generic decline. The number 4000 0000 0000 9995 returns insufficient_funds. A lost card comes back from 4000 0000 0000 9987, and a stolen card from 4000 0000 0000 9979. An expired card is triggered by 4000 0000 0000 0069. The number 4000 0000 0000 0127 produces an incorrect CVC response, which Stripe returns as an error code. The number 4000 0000 0000 0119 produces a processing error, again surfaced as an error code with no decline code attached. And 4000 0000 0000 6975 simulates a card that's exceeded its velocity limit, returned as card_velocity_exceeded. Each of these deserves its own message on screen: what a customer sees after an insufficient-funds decline should read nothing like what they see after an expired card, and test mode is the only place to prove that distinction holds before a paying customer ever hits it.

3D Secure and Strong Customer Authentication make up a second group, and they matter more each year as European regulation pushes more issuers toward mandatory authentication. The number 4000 0025 0000 3155 requires authentication unless the card is already saved to the customer. The number 4000 0027 6000 3184 requires authentication on every single payment, saved or not. The number 4000 0000 0000 3220 requires 3DS and completes it successfully, while 4000 0000 0000 3055 supports 3DS without requiring it. The number 4000 0084 0000 1629 is the one to build a whole test case around: the customer passes the authentication challenge and the payment declines anyway. Skipping this part of the matrix doesn't just leave a gap in test coverage. It risks losing European customers to a checkout that dead-ends with no visible error.

Fraud and dispute handling round out the matrix. The number 4100 0000 0000 0019 is always blocked by Radar at the highest risk level, regardless of rules configuration, and it's the card to use to confirm an integration handles a fraud block without leaking internal error detail to the person on the other end. The number 4000 0000 0000 9235 simulates elevated risk that gets queued for manual review. These disputes don't arrive in the same request as the charge. They show up later as separate webhook events, sometimes instantly in test mode and sometimes days later in production, and a handler that silently drops one of those events means a bank pulls funds back with no warning to anyone on the team.

Every one of these cards works the same way at the input level: any future expiry date, any three-digit CVC (four for Amex), any postal code. Build the habit early, separate from the decline matrix itself: keep raw card numbers out of server-side code entirely and use tokens representing a stored payment method instead. It's a small discipline in test code, but it's also exactly the habit that keeps a codebase from accidentally handling raw card data in a way that becomes a compliance problem the moment it reaches production.

Subscription renewals and the silent revenue failure that the basic test matrix misses

A one-time charge that declines produces an error message on a screen, in front of a customer, at the moment it happens. The charge just fails quietly in the background, and unless the dunning logic and the notification system both work correctly, the business doesn't find out until a churn number moves weeks later. That makes the subscription failure path a fundamentally different problem from the one-time decline, not a smaller version of the same thing.

Testing it runs into a constraint that isn't obvious until a team hits it directly: the cards that simulate issuer declines can't be attached directly to a Customer object. The documented pattern is to create the subscription first against the standard success card, 4242 4242 4242 4242, confirm it works, and then swap the customer's stored payment method to 4000 0000 0000 0341, a card that attaches to the customer without issue but fails the moment the next renewal charge runs against it. From there, the test should advance the clock and watch what happens: does the retry logic fire, does the customer get a notification, does access get revoked at the right moment and not a day early or late. This is where recurring-revenue integrations are weakest in practice, precisely because the failure produces no on-screen symptom for a human to notice by accident.

The tool that makes this testable at all is Stripe's test clock, a mechanism built for exactly this purpose: it lets a subscription's billing cycle, including a renewal set for 30 days out, play out in seconds inside a test run instead of requiring an engineer to wait out the actual calendar.

Webhooks are a separate test surface from card charges, and most teams conflate them

Confirming that a charge succeeded and confirming that the application responded correctly to that charge are two separate tests, and conflating them is where a lot of integrations quietly lose orders. It says nothing at all about whether the order got marked as paid, whether a confirmation email went out, or whether a chargeback got routed to the right team.

When a payment succeeds, Stripe sends an event, either payment_intent.succeeded or checkout.session.completed, to whatever endpoint the application has registered to receive it. The Stripe CLI gives two commands built specifically to close this gap in testing: stripe listen --forward-to forwards live test-mode webhook events to a local endpoint during development, and stripe trigger fires a mock event on demand without needing a real charge to generate it. Confirming that both payment_intent.succeeded and payment_intent.payment_failed arrive and get handled correctly belongs in the list of things that block a go-live date, not in a backlog ticket marked "nice to have."

Stripe will retry delivery of an undelivered event for up to three days, but that safety net applies only when the event never reached the endpoint, leaving endpoint-side handling failures uncovered. Dispute events, the kind generated by cards like 4000 0000 0000 0259, arrive on their own asynchronous timeline separate from the original charge, so testing the dispute webhook path is a distinct requirement from testing the charge path, not a variation on the same test. And every incoming webhook needs its signature checked against the signing secret before the handler acts on it. That check belongs inside the test assertions themselves, not just somewhere in the production handler code where no test ever exercises it.

Why 3D Secure tests require a real browser

3D Secure authentication renders as an iframe loaded from a different origin than the checkout page, under timing constraints that depend on a real rendering engine. No HTTP client and no headless script that skips actual browser behavior can walk through that sequence. Covering Strong Customer Authentication requires real browser automation built into the architecture from the start, not an afterthought bolted onto an API test suite.

The complication starts even before the 3DS modal appears. Getting past that is a browser automation problem specifically, and Playwright's frame_locator is the mechanism built to navigate into a cross-origin iframe and interact with the elements inside it. That same mechanism also has to account for how Stripe Elements structures its card inputs: a default CardElement puts card number, expiry, and CVC into a single iframe, while a split configuration using separate CardNumberElement, CardExpiryElement, and CardCvcElement components places each field in its own frame, so a test suite has to target each one individually with frame_locator.

A real test has to wait for the 3DS modal to appear, interact with it the way a customer would, and then check the resulting state only after authentication completes, all under timing that's asynchronous by nature and that resolves before the corresponding webhook ever fires. Two specific paths need explicit coverage here, not just one: authentication that succeeds followed by a payment that succeeds, and authentication that succeeds followed by a payment that still declines, which is what the 4000 0084 0000 1629 card produces. Stripe.js fingerprinting on one side and asynchronous webhook delivery on the other both point at the same underlying need: a test environment that can hold state and react over time, not one that only answers a single request and forgets it happened.

Idempotency and retry logic as first-class decline-path test cases

Some of the most dangerous failures are not declines. A duplicate charge tells them nothing until it appears on a statement, and by then the evidence needed to figure out what happened has usually scattered across logs and support tickets that don't obviously connect to each other.

The fix is a unique idempotency key, typically a UUID, generated once per payment attempt and stored alongside the payment record, then included in every API call tied to that same attempt, including the retry. No test card number produces this scenario. It has to be built into the suite directly as its own case: it's invisible in a normal happy-path run and only shows up once real network conditions start causing retries in production, when it becomes far more expensive to fix.

Stateless mocks and live test mode coverage gaps

Decline codes, subscription renewals, webhook delivery, 3D Secure, and idempotency all depend on the same thing: the test environment needs to remember what happened a moment ago and react accordingly. A stateless mock that returns a canned response to a single request can simulate a generic decline card without much trouble, but it can't track that a subscription's card was swapped after creation, can't advance a billing cycle and then fire the correct renewal event days later, and can't hold a payment's state long enough to connect a charge to the dispute webhook that arrives for it asynchronously. Live Stripe test mode, meanwhile, covers that state correctly because it's the real system, but it depends on network calls to Stripe's own servers for every single test, carries the rate limits that come with any shared external service, and was never built to run at the speed a CI pipeline needs when a suite is exercising the full decline matrix on every commit.

A stateful simulator sits in the gap between those two options: fast and local like a mock, but able to hold a payment's state across a sequence of events the way live test mode does. Rystic, a locally runnable API simulator platform that runs continuously verified, stateful replicas of services like Stripe so teams can exercise the full decline matrix in CI without touching live credentials or rate limits, is one concrete way to close that gap. That's what it takes to run subscription lifecycles, webhook sequences, and multi-step 3D Secure flows as fast, repeatable tests instead of as cases a team quietly agrees to test manually, if at all, right before a release.

Sources

  1. Test card numbers
  2. Stripe decline codes
  3. Testing use cases

More in Payment Flow Testing