The checkout looks correct. The customer gets created. The PaymentIntent gets created. The confirm endpoint returns 200. The dashboard shows the order paid.
Then a few hours later, finance asks why the bank settlement is short by every authorization that wasn't actually captured.
The bug is the same one Cursor, Claude, and every "your first Stripe integration" snippet keeps writing: treating the 2xx from POST /v1/payment_intents/{id}/confirm as proof the money moved. It isn't.
What the response actually says
When you confirm a PaymentIntent that was created without capture_method=automatic, Stripe returns 200 — and the response body tells you exactly what happened:
{
"id": "pi_8IVW9CKUU9ZOX5IQJDQLNRDT",
"status": "requires_capture",
"amount": 2000,
"amount_capturable": 2000,
"amount_received": 0
}
status: requires_capture means the customer's card was authorized — Stripe placed a hold on the funds — but the money hasn't actually moved. amount_received is still 0. You have to call POST /v1/payment_intents/{id}/capture separately for the charge to settle.
If your app marks the order paid at confirm-time, you've now told finance, fulfillment, and the customer that the transaction is done. It isn't.
The code that ships this bug
This pattern is everywhere in agent-generated and tutorial code:
const intent = await stripe.paymentIntents.confirm(piId, {
payment_method: pmId,
});
if (intent) {
await markOrderPaid(orderId);
return res.status(200).json({ ok: true });
}
The if (intent) branch is true for both requires_capture and succeeded. The app commits to "paid" on the wrong state.
The fix
Read the status field. Branch on it:
const intent = await stripe.paymentIntents.confirm(piId, {
payment_method: pmId,
});
switch (intent.status) {
case "succeeded":
// automatic capture mode — money moved, safe to mark paid
await markOrderPaid(orderId);
break;
case "requires_capture":
// manual capture mode — authorize succeeded, still need to capture
await markOrderAuthorized(orderId);
// either capture immediately:
await stripe.paymentIntents.capture(intent.id);
// ...or queue capture for after fulfillment confirmation
break;
case "requires_action":
// 3DS / additional auth needed — return next_action to the client
return res.json({ requires_action: intent.next_action });
default:
throw new Error(`unexpected PI status ${intent.status}`);
}
The requires_capture branch is the one that matters. Pick a moment that's right for your business — capture immediately if you ship inventory same-day, queue capture for after fulfillment if you don't. But until capture returns succeeded, the order isn't paid.
Receipts
Real Stripe accept-payment workflow run against the FetchSandbox sandbox, captured 2026-06-07:
POST /v1/customers → 200 cus_EWSGOLGWNZ1H823A
POST /v1/payment_intents → 200 pi_8IVW9CKUU9ZOX5IQJDQLNRDT status: requires_payment_method
POST /v1/payment_intents/.../confirm → 200 pi_8IVW9CKUU9ZOX5IQJDQLNRDT status: requires_capture ← not paid yet
POST /v1/payment_intents/.../capture → 200 pi_8IVW9CKUU9ZOX5IQJDQLNRDT status: succeeded ← now paid
GET /v1/payment_intents/pi_8IVW9CKUU9ZOX5IQJDQLNRDT → 200 status: succeeded
webhook events fired:
payment_intent.created
payment_intent.succeeded ← fires AFTER capture, not after confirm
Full timeline: fetchsandbox.com/runs/ace33a7f01?flow=run_cf9ad1d9-be0d-4db0-990e-e3c7f0d50be2
Notice the gap between step 3 and step 4. The confirm returns 200 with status: requires_capture. The capture is what flips it to succeeded. The payment_intent.succeeded webhook fires after capture, not after confirm. Any code that treats step 3 as the finish line is shipping authorizations as if they were captures.
Why we wrote this
If your code path looks like confirm → mark paid, you have a quiet bug today and a settlement discrepancy tomorrow. The Stripe state machine is requires_payment_method → requires_capture → succeeded for manual-capture flows, and requires_payment_method → succeeded for automatic-capture flows — and the status field on every response tells you which arm you're on.
We use FetchSandbox to run the full create-confirm-capture workflow before writing the handler, so the code branches on the real state values instead of an "if response is truthy" check that compiles fine and breaks finance.