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:
- Confirm →
requires_action - Assert you surface
next_actionto the client (not fulfill) - Simulate completed auth → confirm again →
succeeded - 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.