Skip to main content

Stripe

A Stripe PaymentIntent at `requires_capture` is not paid yet — but most generated code thinks it is

Coding agents and quickstart snippets routinely treat any 2xx from /payment_intents/confirm as 'paid'. Stripe says otherwise: the response status is requires_capture, not succeeded, and a separate capture step has to fire.

FetchSandbox EngineeringFetchSandbox Engineering

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.