raceproof
Race testing for 3-D Secure payment flows

Make the payment race happen. Then prove your fix holds.

raceproof drives your gateway against a fake PSP and lands the shopper's result, your own timeout and the PSP's webhook at exactly the wrong moment, hundreds of times in a row.

It runs on your machine against your test stack. PSP test data only, no real card data, no telemetry.

The C3 race on a timeline The shopper's challenge result and the gateway's challenge timeout both reach the PSP inside the same race window. The PSP accepts the first and answers the second with error 704. race window shopper's CRes your timeout PSP Y, key …:challenge U, same key 1st: Authorised 2nd: 409 / 704 0 s challenge shown timeout fires
Scenario C3. Both paths submit the challenge result with the same idempotency key. With the fix off, the gateway reads the loser's 704 as a failure and tells the merchant twice.
The problem

What the sandbox never does

It answers once, quickly, in order

In production, callbacks arrive twice, late, out of order, or in the same millisecond as your own timeout. A sandbox lets you click through a flow; it can't place two messages 5 ms apart.

The bug looks like bad luck

A payment refused although the shopper was charged. A merchant told FAILED, then COMPLETED. An authorisation left open at the PSP. Rare per payment, expensive at volume, and nearly impossible to reproduce from a log.

Mocks don't have timing

A unit test can mock the PSP's answer. It can't mock the moment the answer arrives, or what the PSP does with a second call that carries the same idempotency key.

How it works

Your gateway, a fake PSP, and a runner that controls the clock

  1. Point your gateway at the fake PSP

    Change the PSP base URL and shorten your timeouts in a test profile. Your gateway code stays as it is.

  2. Describe your system once

    lab.yaml holds your URLs, how to read a payment's status from your database, your status values and your timeouts. Scenarios refer to them by name, so the same scenario runs against any gateway.

  3. Run each scenario a hundred times

    The runner scripts the fake, fires the shopper's and the issuer's messages at chosen moments with jitter, and checks your database and the PSP's call log after every run.

# C3: the CRes arrives as the watchdog fires
repeat: 300
jitter_ms: [0, 700]
steps:
  - http: {url: "${gateway.start_payment_url}",
           json_file: "${payloads.start_payment}"}
  - wait_status: {is: pending_challenge}
  - sleep_ms: "${timing.near_watchdog_ms}"
  - http: {name: cres, url: "${fake}/_control/acs/challenge",
           json: {outcome: "Y", url: "${gateway.challenge_url}"}}
assert:
  - tx_status: {is: completed}
  - refusal_marker: {count: 0}
  - sql_equals: {named: merchant_final_count, value: 1}
Method

A green result has to mean something

A race test that passes because the race never happened is worse than no test. raceproof reports what it actually exercised.

Inconclusive is its own result

If your gateway never reached the state under test (it stalled, the fake was slow), the run counts as inconclusive, not passed. Too many inconclusive runs and the whole result is marked untrusted.

It shows whether the race was hit

The report counts how many runs landed inside the race window and in which order the two calls reached the PSP. Green with zero runs in the window is reported as proving nothing.

It finds the timing for you

--sweep timing.near_watchdog_ms=3000:4800:200 runs every value, interleaved in random order so that drift in your environment hits all values alike.

The control run comes first

Run a scenario against the unfixed code before you trust it on the fixed code. A test that can't fail on the bug it is about proves nothing; the demo shop and paygate-demo have switches for exactly this.

Proof you can rerun

Measured on paygate-demo, an open-source gateway

paygate-demo is a small Spring Boot gateway for Adyen and Stripe with two of these races behind switches you flip at runtime. The same scenario, the same timing, the fix off and then on:

ScenarioRaceFix offFix on
C3Adyen: the shopper's challenge result arrives as the gateway's challenge timeout fires 82 of 100 runs fail0 of 100
S1Stripe: the browser's return (reloaded) and the webhook report the outcome together 100 of 100 runs fail0 of 100

Measured 2026-11-14 on a laptop, with paygate-demo's timeouts shortened to seconds. Your numbers will differ with your machine and timing; the fix-on column should not.

Six minutes, with voice-over

Watch two races fail, then pass

The scenario menu, C3 and M1 each with the fix off and then on, and the C3 race in a shop's checkout page. Recorded against the reference gateway that paygate-demo is cut from.

  • 0:04
  • 0:17
  • 1:39
  • 3:01
  • 4:01
  • 5:04
PSP packs

Fakes built from sandbox measurements

A race only shows up if the fake answers the way the real PSP does: its idempotency replays, its "already in progress" errors, its webhook delays and retries. Each pack's fake is built from measurements in the PSP's test environment and lists what was measured and what is assumed.

PackWhat the fake coversScenariosStatus
AdyenNative 3DS2: /payments, /payments/details, the issuer's method and challenge pages, signed webhooksM1, C1–C5, W1–W4available
StripePaymentIntents with Stripe's own 3DS page, manual capture, signed webhooks with retriesS1–S9available
Your PSPTell us which PSP and flow you integrate–on request

Payment providers: a pack for your API lets the merchants who integrate you find these races in their own code before they turn into support tickets about payments that were charged but refused. Talk to us about coverage for your flows.

Measured, for example

Adyen, 2026-11-14: a second challenge submission with the same idempotency key while the first is in progress gets 409 with error code 704; after the first has answered, the same key gets that answer replayed.

Stripe, 2026-11-14: webhook retries after an error answer come about 1 h and 3 h later, with the same event id, in a different order.

Assumed, and listed as such

Adyen: whether a retry after a failed (5xx or timed-out) first call also gets 704.

Stripe: the exact set of statuses from which a PaymentIntent can be cancelled.

Free and open source

Try it in two minutes

The core (runner, menu, report and the pack contract) is open source under Apache-2.0. It comes with a demo PSP and a demo shop with one race, R1, of the same shape as C3: the shopper's result and the shop's own timeout. No PSP account needed.

Run R1 with the shop's fix on: every run passes. Switch the fix off and run it again: some runs fail, and the report shows how many hit the race window.

raceproof-core on GitHub

pip install raceproof
raceproof demo                              # terminal 1
raceproof menu --config raceproof-demo.yaml # terminal 2: pick R1

# the shop's fix off, then run R1 again
curl -X PUT -H 'Content-Type: application/json' \
     -d '{"race": false}' http://127.0.0.1:9301/_demo/fixes

Find the payment race before your shoppers do

The Adyen and Stripe packs are sold as a perpetual licence per company, with a year of updates: buy once, keep using it. Ask for a 30-day trial: run the scenarios against paygate-demo first, then against your own gateway. Tell us which PSP you use and which edge case worries you.

Write to
dev@raceproof.dev Open in your mail app