How a scenario is built

Underneath a Finxture scenario there is no webhook. There is an event in the real world — a payment was authorized, then captured — and two separate things stacked on it: an adapter that renders that event in one provider's vocabulary, and a decision about how the delivery gets broken.

The three layers of a scenarioThree stacked layers. Layer one, at the bottom: the event — the authored canonical flow [email protected], whose transitions are authorize-payment → capture-payment. An arrow labelled "rendered by" points up from it to layer two: the provider rendering, Stripe adapter 2.0.0, 4 canonical events → 2 provider events. A second arrow points up from there to signed HTTP POSTs to your endpoint. Layer three, the delivery perturbation, is attached by a dashed line to that second arrow rather than to either box: it changes the arrow, not the boxes.1 · The event[email protected]authorize-payment → capture-paymentrendered by2 · The provider renderingStripe adapter 2.0.04 canonical events → 2 provider events3 · The delivery perturbationchanges this arrow, not the boxesSigned HTTP POSTs to your endpointthe only part of this your code sees
The outcome is the invariant. The rendering is one variable, the perturbation a second and independent one.

That event is authored on its own, in a file with no provider word and no calendar date in it. The absence is what makes it a layer rather than a Stripe fixture with the names filed off.

What the rendering layer does

4 canonical events rendered as 2 Stripe events Two panels. The upper panel lists the 4 canonical events of flow [email protected], grouped by transition: authorize-payment at +1000 ms produces authorization.succeeded, which the adapter renders as payment_intent.amount_capturable_updated; payment.authorized, which the adapter renders as no provider event. capture-payment at +2000 ms produces capture.succeeded, which the adapter renders as no provider event; payment.succeeded, which the adapter renders as payment_intent.succeeded. An arrow labelled Stripe adapter 2.0.0 points down to the lower panel, which lists the 2 provider events: payment_intent.amount_capturable_updated and payment_intent.succeeded. 2 of the 4 canonical events have no provider event of their own.authorize-payment · +1000 ms1authorization.succeededpayment.authorizedcapture-payment · +2000 mscapture.succeeded2payment.succeededStripe adapter 2.0.01payment_intent.amount_capturable_updated2payment_intent.succeeded
2 of the 4 canonical events have no Stripe event of their own. A provider groups the moments of a payment the way its own object model groups them; the adapter knows that so the layer below does not have to.

Every payload, and the run-relative time each one is delivered at, is generated from that adapter and served as a file: JSON or YAML. Open one — none of it is hand-written. The same scenario produces the same events on every run, and a change to any payload fails the build.

What breaks, and why it stays portable

Deliver the capture notification twice. Reverse the two events. Drop either one. The flow is untouched and the rendering is untouched; what changes is one array in one file.

One shared flow, two delivery plansTwo delivery plans over the same provider events, numbered as in the previous figure. [email protected] plans 2 HTTP deliveries, while [email protected] plans 3 HTTP deliveries. The extra delivery is payment_intent.succeeded sent 2 times, with the same bytes and a fresh signature each time.[email protected]2 deliveries1POST2POST[email protected]3 deliveries1POST2POST2POST againpayment_intent.succeeded ×2: same bytes, new signature
Same events, same bodies, same signatures — one extra POST. The difference between the two scenarios is the delivery plan, and nothing else.

That array targets payment-succeeded, a canonical event id — not the Stripe name it is rendered as. Which is why the same perturbation survives a change of provider: an adapter that rendered that one event as three would deliver three, twice, with no edit to the scenario.

Where all of it should land

In every variant a correct integration ends in the same place: the payment is captured and your side agrees. Order changed, count changed, timing changed; the outcome did not. That is the whole reason to keep the layers apart.

What none of this does yet

The convergence just above is the target the scenarios are designed around. It is not a thing the product measures, and there are three gaps worth knowing before you decide the design is worth anything.

There is no reconciliation API. Finxture delivers the events and watches what your endpoint answers. It does not read your database, and there is no interface through which it could. What it verifies automatically is narrow: that each delivery was acknowledged with a 2xx. Whether your payment row ended up in the right state, exactly once, is a manual checkbox on the run report — your judgment, not a verdict the tool can issue.

Only duplicate delivery actually runs. Of the four perturbations above, one is executable today: a zero-interval duplicate. The others fail explicitly rather than being quietly approximated — the delivery planner rejects a perturbation it cannot honour instead of running something adjacent and calling it the scenario. Out-of-order and missing deliveries are explained on this site already, at Out-of-order and late webhook delivery, but neither is a runnable scenario.

One provider, so the canonical layer is unproven as canonical. Everything above rests on the claim that the middle layer is a rendering of a provider-neutral event, and a model with exactly one adapter has never had anything push back on it. The Stripe projection is also narrower than Stripe: it emits PaymentIntent objects, and Charge and PaymentMethod appear only as correlated identifiers rather than as projected objects. The second adapter is the test of the design, and it has not been run.

Test Stripe webhooks on localhost without ngrok runs this same scenario end to end against a local endpoint.