Skip to main content

Stripe

Test Stripe requires_action (3DS) without real cards

Your checkout treats PaymentIntent succeeded as the finish line. 3DS returns requires_action — and most suites never force that branch. Here's how to prove the client handles next_action before production cards do.

FetchSandbox EngineeringFetchSandbox Engineering

A lot of Stripe integrations ship with one mental model: create a PaymentIntent, confirm it, get succeeded, fulfill.

That model fails the first time the card needs 3D Secure. Stripe returns requires_action with a next_action payload. Your frontend is supposed to hand that to Stripe.js, wait for the customer, then confirm again. If you only ever tested succeeded in fixtures, production is the first time that path runs — usually on a real card during a launch.

This is different from requires_capture (auth hold vs capture). requires_action is customer authentication mid-confirm. Same status-code confusion family; different fix.

Why CI lies

Fixtures and mocks love status: "succeeded". Test cards in docs that skip 3DS keep local demos green. Nobody writes:

  1. Confirm → requires_action
  2. Assert you surface next_action to the client (not fulfill)
  3. Simulate completed auth → confirm again → succeeded
  4. Assert you fulfill once, keyed on PaymentIntent id

Agents copy the quickstart. Reviewers approve the happy path. The 3DS branch stays theoretical.

Force the branch on purpose

You need a sandbox that can return requires_action on demand — without collecting real PANs or burning Stripe test-dashboard clicks every time.

With the FetchSandbox MCP server and a Stripe sandbox:

./fetchsandbox run my PaymentIntent confirm flow.
On first confirm, return requires_action with a next_action redirect shape.
Assert I do not fulfill. After simulated auth completion, confirm again to
succeeded and assert a single fulfillment keyed on pi_xxx.

The receipt shows whether you short-circuited to “paid” on the first response. Reading the TypeScript cannot.

The rule

If your fulfill trigger is “any confirm response that isn’t an error,” you will grant on requires_action. Gate fulfillment on terminal success (succeeded / your product’s captured state), and test the requires_action loop once on purpose before a real card hits it.