Give every payment failure a seed
Uncontrolled clocks, random choices and event ordering let a repaired test pass for the wrong reason, while the production race remains intact.
7 min read
Attach a replay seed to every payment failure. Not a log search term. Not a test card number. A seed that reconstructs the payment’s starting state, generates the same downstream decisions, advances the same virtual clock, and delivers the same callbacks in the same order. One command should be enough: payments-sim replay payfail:v3:7f4c9a….
That seed turns “it failed once in staging” into a durable engineering object. The simulator can explore thousands of combinations, but the first failing run is no longer random: its seed, generator version and configuration digest become a permanent regression case. Deterministic simulators already use this pattern to reproduce failures by reusing a seed; FoundationDB’s simulator controls network, disk, time and pseudorandomness, while TigerBeetle describes sharing the seed that led to a failure.12 The payment-specific move is to apply the same discipline to the entire transaction path.
A failure is a path, not a code
An insufficient_funds fixture is useful. It proves that one branch renders the right message. It does not prove that an authorization timeout followed by a retry, a late success and a duplicate webhook leaves the order paid exactly once.
Real payment interfaces expose intermediate and changing states. Adyen documents Received and Pending results for payments whose final status is not yet available, and warns that the status can change after the synchronous result is returned.3 PayPal’s idempotency guide gives the sharper case: a capture request can time out even though the server has captured the payment.4 The caller does not know whether “no response” means no money moved.
Authentication adds another state machine. EMVCo’s 3-D Secure documentation defines an architecture of protocol messages, data and channels rather than a single pass/fail call.5 A simulator therefore needs to generate journeys: frictionless authentication, a challenge, abandonment, an issuer decline after successful authentication, or an authorization attempted without completing the required action. Stripe’s public test values include both “authentication required and succeeds” and “authentication required, then the charge is declined,” illustrating why the authentication result and payment result must remain separate.6
Callbacks widen the state space again. Stripe states that webhook endpoints can receive duplicate events and that event delivery order is not guaranteed.7 A useful failure generator must be able to delay, duplicate, drop and reorder notifications independently of the synchronous API response.
Define the seed contract
A bare integer is not a replay contract. Change a generator’s branching logic and the same number may produce a different case. Keep the human-facing seed short, but resolve it to an immutable replay capsule:
seed: 7f4c9a2e6d18b031
simulator: payments-sim/3.4.1
scenarioSchema: 5
configDigest: sha256:…
virtualEpoch: 2026-01-15T10:00:00Z
entrypoint: authorize-and-captureThe capsule should reference sanitized starting data: amount class, currency, payment-method capability, authentication mode, retry policy and order state. It should also record the code revision under test and the invariant that failed. The seed drives every generated choice after that point—processor outcome, latency, message ordering, callback duplication and customer action.
Do not overload existing identifiers. An idempotency key identifies one logical mutation so a retry does not create the operation twice; PayPal, for example, uses PayPal-Request-Id for supported POST calls and returns the latest status associated with a reused value.4 A trace ID correlates requests across services; W3C Trace Context standardizes traceparent and tracestate for that purpose.8 A replay seed reconstructs a world. Link all three in telemetry, but give them separate types and lifecycles.
The seed must also be safe to paste into a bug report. It should contain no PAN, cardholder identity, authentication value or raw request body. PCI SSC distinguishes masking displayed PAN from rendering stored PAN unreadable, and notes that a masked display can still correspond to a fully stored PAN.9 Treat the seed as an opaque lookup key or deterministic input to synthetic data, never as an encoding of production payment credentials.
Put time and chance behind one interface
Replay fails the moment one component reads the wall clock, calls an unseeded random generator or lets the operating system choose a race winner. FoundationDB’s documentation is explicit: its seeded replay works only while the workload avoids nondeterministic behavior.10
Route those choices through the simulator. now() returns virtual time. random() comes from one seeded stream, split into named substreams so adding a fraud-model choice does not silently perturb webhook timing. Network clients talk to scripted rail adapters. Queues run on a deterministic scheduler. Generated IDs come from the seed and a monotonic counter.
This does not require rewriting the payment application as a single-threaded program. It requires deterministic boundaries. In-process domain logic can receive a clock and an ID source. HTTP clients can point to a stateful simulator. Workers can be driven by a test scheduler. Database behavior can remain real where transaction semantics matter, while external events arrive according to the seed.
Record the decision tape during each run: not merely “random choice 17,” but issuer=unavailable, auth=challenge, callback=duplicate, delay=31s. On replay, compare the new tape with the saved tape and stop at the first divergence. That converts “the seed no longer reproduces” from a mystery into a versioning error with a location.
Generate payment-shaped disorder
Start with the public negative-testing catalogues. PayPal supports sandbox headers that force named application errors.11 Adyen exposes test values for results including issuer unavailable, authentication required, pending, refused and acquirer error.12 These are valuable atoms. The seeded simulator should compose them into sequences that hosted sandboxes rarely express in one fixture.
Weight generation toward boundaries, not uniform randomness. Choose an amount at a currency precision edge. Schedule the client timeout one tick before the simulated authorization response. Deliver payment_succeeded before the API caller processes its timeout. Retry with the same idempotency key, then with a different one. Expire an authentication session between challenge completion and authorization. Duplicate a callback while the first handler is holding a database lock.
Use two generation modes. Exploration mode varies many dimensions and searches for unknown interactions. Mutation mode begins with a known incident capsule and changes one choice at a time: the callback arrives earlier, the timeout moves later, the retry changes identity. Exploration finds new paths; mutation maps the neighbourhood around a failure and shows which conditions are actually necessary.
Each run needs assertions stronger than an expected HTTP status. The order is never fulfilled twice. A successful capture is not overwritten by a stale failure. Retrying one logical operation cannot create a second capture. Every terminal payment maps to a compatible order state. Money values conserve across authorization, capture, void and refund. Intermediate states may persist, but only along an allowed transition.
This is property-based testing applied to a stateful protocol. QuickCheck established the practical pattern of stating program properties and checking them against automatically generated inputs.13 In payments, the generated input is not just a card number or request body. It is the event schedule.
Keep the failures that matter
When a seed breaks an invariant, save it before shrinking anything. Then minimize the scenario while preserving the failure: remove an unrelated callback, shorten the delay, reduce the retry count, simplify the authentication branch. The final artefact should retain both the original discovery seed and a small canonical replay.
Promote that replay into the ordinary regression suite. Run the compact case on every relevant change; keep the wider seeded search in continuous testing. Track generator coverage by semantic dimensions—authentication path, terminal outcome, timeout position, retry class and callback disorder—so a million easy approvals cannot masquerade as exploration.
There is one hard boundary. A seed cannot make an external issuer, network or hosted sandbox repeat its hidden state. For production incidents, build a sanitized capsule from the observable transcript and replay it against controlled doubles. The result reproduces the system’s response to that evidence, not the external party’s private decision. Keep that limitation visible.
The engineering action remains simple: whenever a payment scenario fails, emit a seed; whenever a bug is filed, require its replay command; whenever the simulator changes, preserve the version that gives the seed meaning. Randomness finds the path. The seed makes it accountable.
Sources
- FoundationDB: A Distributed Unbundled Transactional Key Value Store (opens in a new tab) — FoundationDB / SIGMOD
- Architecture: Determinism and Simulation Testing (opens in a new tab) — TigerBeetle
- Result codes (opens in a new tab) — Adyen Docs
- Idempotency (opens in a new tab) — PayPal Developer
- 3-D Secure Documentation (opens in a new tab) — EMVCo
- Test card numbers (opens in a new tab) — Stripe Documentation
- Receive Stripe events in your webhook endpoint (opens in a new tab) — Stripe Documentation
- Trace Context (opens in a new tab) — World Wide Web Consortium
- What is the difference between masking and truncation? (opens in a new tab) — PCI Security Standards Council
- Client Testing: Parallelism and Determinism (opens in a new tab) — FoundationDB
- Simulate negative responses with request headers (opens in a new tab) — PayPal Developer
- Testing result codes and refusal reasons (opens in a new tab) — Adyen Docs
- QuickCheck: A lightweight tool for random testing of Haskell programs (opens in a new tab) — Chalmers University of Technology