Payments

A successful payment sends two webhooks, not one

A card payment that works is two operations, not one — an authorization that reserves the money and a capture that moves it — so the webhook stream describing it carries two events about the same payment object, one step apart. Running the successful-payment scenario proves almost nothing on its own; what it gives you is a baseline you have read, and the moment to check that your own code recorded two facts instead of one.

Add to Chrome Run it locally ↓

Both events are the same payment, one step apart

Underneath, this flow makes four canonical transitions: the attempt’s authorization succeeds, the payment becomes authorized, the capture succeeds, the payment succeeds. Stripe reports all four as two deliveries. How a provider groups transitions into events is a provider fact, not a canonical one, and it is the first thing worth noticing here — the number of webhooks you receive is not the number of things that happened.

Both deliveries carry a PaymentIntent created with capture_method: manual, and both carry the same id. Three fields separate them:

Read only the id out of data.object and the two events are indistinguishable, which means the first one looks exactly like a completed payment.

The gap between them is not network latency. It is a decision the merchant made on purpose, and it can last days — authorization and capture covers why it exists, where the money is at each step, and what the customer sees.

This is the shape everything else breaks

Every other scenario in the catalog is this run with something done to it. Duplicate delivery sends one of the two events twice. Out-of-order delivery swaps them. A decline stops after the first. The authored definition makes that literal: a scenario is a flow plus a list of perturbations, and this one’s list is empty. How a scenario is built shows the same flow with one perturbation added.

So run this one before you run any of the others, and keep what it sends you. The two payloads are under the timeline below. A duplicate-delivery test that fails against a baseline you have read tells you which field moved; the same test failing against nothing tells you only that something is wrong.

Check that your code sees two facts, not one

The run itself proves very little. Two POSTs go out, your endpoint answers with a success status twice, and the report says so. A handler that discards the body entirely passes.

What the run gives you is the moment to check the thing no verdict here can. Look at your own data afterwards and count the facts you stored:

If your schema holds one paid boolean and one paid_at, there is nowhere to put the first fact, so the handler wrote it as the second. That is the mistake this run exists to surface. It costs nothing while every authorization is captured seconds later, and it costs an order shipped against a hold the first time one is not.

The run report says this out loud rather than scoring it. Two of its checkboxes — update application state to reflect the successful payment exactly once, and verify each webhook signature before trusting its payload — are your judgment, not the tool’s.

What the run does not prove

Nothing about your database. Finxture reads the status code your endpoint returns and nothing else. Both automatic assertions in this scenario are about acknowledgment: a 2xx for the authorization event, a 2xx for the payment event. There is no interface through which the tool could check the row.

Not that the order is guaranteed. This scenario delivers the authorization event first and the capture event a second later, every time, because that is what a deterministic sandbox is for. Stripe does not guarantee that events arrive in the order they were generated. Surviving that is a separate problem, and it is not a runnable scenario yet.

Not that your Stripe account produces these payloads. They are generated from a reviewed adapter against one pinned API version. Confirming that a real account agrees needs a real event, which is a different tool’s job.

Nothing about a capture that comes later. Here the capture follows one second after the authorization. Every interesting failure in a two-stage payment happens when it follows days later, or never: a partial capture, a second capture against the same authorization, a hold that expires uncaptured. None of those are runnable today.

What goes wrong

A handler with a single "payment succeeded" branch marks the order paid when the authorization event arrives, so goods ship against money that was only reserved — and if the capture never happens, the hold expires, the funds return to the customer, and nothing in the integration ever notices the difference.

Concepts used here

Authorization and capture
Authorization is the operation that reserves funds on a payment method without moving them, and capture is the separate, later operation that actually moves the reserved funds.

Provider notes

stripe

  • Stripe describes payment_intent.amount_capturable_updated as occurring when a PaymentIntent has funds to be captured, and payment_intent.succeeded as occurring when a PaymentIntent has successfully completed payment. The authorization and the capture of one payment are therefore reported as two separate events about the same PaymentIntent, rather than as one event per operation on different objects. — https://docs.stripe.com/api/events/types
  • Stripe documents how long an uncaptured authorization survives: for card-not-present transactions it is seven days on Visa, Mastercard, American Express, and Discover, and card-present windows are shorter. If the authorization expires before the funds are captured, the funds are released and the payment status changes to canceled. — https://docs.stripe.com/payments/place-a-hold-on-a-payment-method

See it running

Deliver the successful-payment canonical events once in their default order. Generated from the same scenario definition the extension runs — one panel per supported provider.

One event here is one signed HTTP POST to the localhost endpoint you configure in the extension, and its body is a single JSON object — the one your handler parses. The timeline below lists those requests in delivery order. Where a row shows more than one delivery, the identical body is posted that many times.

What this proves

Event timeline

Time from startEvent typeDeliveriesWhat your handler must do
+1000mspayment_intent.amount_capturable_updated1 HTTP POSTVerify the signature, then acknowledge with a 2xx response.
+2000mspayment_intent.succeeded1 HTTP POSTVerify the signature, then acknowledge with a 2xx response.

Keep the payloads as fixtures

These two files hold the exact request bodies the timeline above delivers. Run this scenario once, confirm the payloads are what your integration expects, then keep the file. Every broken-delivery scenario you run afterwards — a duplicate, a retry, a delivery arriving out of order — is a diff against that baseline rather than a payload you have to read from scratch.

Download JSON · Download YAML

Verify the signature

const event = stripe.webhooks.constructEvent(
  requestBody, // the raw, unparsed request body
  request.headers['stripe-signature'],
  endpointSecret,
);
// Verified against the Stripe API version this scenario targets: 2026-04-22.dahlia.

Handle the webhook

switch (event.type) {
  case 'payment_intent.amount_capturable_updated':
    // Handle payment_intent.amount_capturable_updated.
    break;
  case 'payment_intent.succeeded':
    // Handle payment_intent.succeeded.
    break;
  default:
    // Unhandled event type: acknowledge it anyway to stop redelivery.
    break;
}
response.sendStatus(200);

Updated 2026-08-03