Your checkout UI, your order service, your retry logic โ none of them should need a payment-provider account to be testable. But that's how most teams end up: the frontend dev wants to build the "card declined" screen and first has to get Stripe test keys, the CI pipeline needs secrets injected, and nobody has ever actually watched the app survive a gateway timeout, because you can't ask a real payment API to time out on cue.
This guide builds a hosted fake payment gateway in about 60 seconds: persisted payment records with real CRUD, declines exactly when you want them, retry choreography you can assert on, slow-gateway drills, and HMAC-signed payment webhooks โ the part of payment integration everyone tests last. No signup, no keys. Every command below was run against production before publishing.
stripe-mock are the right tools (more in the honest comparison). This is for everything before that: checkout state machines, decline screens, spinners, retries, webhook handlers โ with zero accounts and failure injection real gateways can't do.BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"payments-mock","blank":true}')
PID=$(echo $P | sed 's/.*"id": *"\([^"]*\)".*/\1/')
KEY=$(echo $P | sed 's/.*"adminKey": *"\([^"]*\)".*/\1/')
curl -s -X POST $BASE/api/projects/$PID/resources -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"name":"payments",
"fields":[
{"name":"amount","type":"price"},
{"name":"currency","type":"oneOf","values":["usd","eur","gbp"]},
{"name":"status","type":"oneOf","values":["requires_payment_method","processing","succeeded","failed"]},
{"name":"customerEmail","type":"email"},
{"name":"created","type":"pastDate"}],
"seed":10}'
echo "payments API: $BASE/m/$PID/payments"
That's a live endpoint seeded with 10 realistic payments in mixed states:
curl "https://mockbird.mockbird.workers.dev/m/<PID>/payments?limit=1"
[
{
"id": 1,
"amount": 995.02,
"currency": "usd",
"status": "failed",
"customerEmail": "levi.marin38@example.com",
"created": "2026-06-18T04:12:09.975Z"
}
]
Filters work out of the box for building list screens: ?status=succeeded, ?amount_gte=500, ?currency=eur&_sort=created&_order=desc, pagination via _page/_limit with an X-Total-Count header. See query params.
Payments are asynchronous state machines: your app creates one, shows "processing", polls (or waits for a webhook), and eventually renders success. Drive the whole loop:
# checkout submits โ create a payment in "processing"
curl -s -X POST $BASE/m/$PID/payments -H 'content-type: application/json' \
-d '{"amount":49.99,"currency":"usd","status":"processing","customerEmail":"jo@example.com"}'
# โ {"amount":49.99,"currency":"usd","status":"processing","customerEmail":"jo@example.com","id":11}
# your UI polls
curl -s $BASE/m/$PID/payments/11 # status: "processing"
# the test driver plays the gateway: settle it
curl -s -X PATCH $BASE/m/$PID/payments/11 -H 'content-type: application/json' \
-d '{"status":"succeeded"}'
# next poll flips the UI to the success screen
curl -s $BASE/m/$PID/payments/11 # status: "succeeded"
Writes persist โ a POST from your checkout is visible to every later GET, from any client, which is exactly what an order-history page or a second service needs. In a Playwright test, the PATCH is one request.patch() call between "assert spinner" and "assert success screen".
Append ?mock_status=402 to any request and it fails with that status. Crucially, the write is not applied โ a declined POST creates nothing, just like a real decline:
curl -si -X POST "$BASE/m/$PID/payments?mock_status=402" \
-H 'content-type: application/json' -d '{"amount":10,"currency":"usd","status":"processing"}'
# HTTP/2 402
# {"error": "simulated 402 error (mock_status)"}
# X-Total-Count on the next list GET: unchanged โ no phantom payment
Build the whole decline matrix this way: 402 card declined, 400 invalid request, 401 bad credentials, 429 rate-limited, 500/503 gateway down. Your QA team can reproduce any of them by editing a URL โ no test-card cheat sheet. Recipes in testing loading & error states.
"Charge the card, and if the gateway blips, retry โ but never double-charge." That's the scariest code path in the codebase, and it usually ships untested. mock_seq scripts the exact sequence:
# first attempt 503, second 503, third succeeds โ exactly
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"$BASE/m/$PID/payments?mock_seq=503,503,201&mock_seq_key=retry-test-1" \
-H 'content-type: application/json' \
-d '{"amount":20,"currency":"eur","status":"processing"}'
# run it 3x: 503, 503, 201
Two properties make this a real idempotency test, verified against production:
mock_chaos (great for soak tests), mock_seq fails exactly twice โ so you can assert "my client made exactly 3 attempts". The x-mockbird-seq response header tells you the position. Parallel test workers isolate counters with their own mock_seq_key; reset in beforeEach with mock_seq_reset=1.Payment requests are the slowest calls your app makes โ redirects, bank auth, 10-second settles. Test what your UI does in the meantime:
# 3 seconds of spinner, then success
curl -s -o /dev/null -w '%{time_total}\n' "$BASE/m/$PID/payments/1?mock_delay=3000"
# 3.16
# slow AND failing โ does your timeout fire before your error handler?
curl -si "$BASE/m/$PID/payments/1?mock_delay=4000&mock_status=503" | head -1
mock_delay goes up to 5000 ms and composes with everything above. Add mock_jitter for realistic variance, or mock_chaos=0.2 to make one checkout in five fail during a manual QA session.
Real gateways confirm payments by POSTing signed webhooks to your backend, and verifying that signature is the step most integrations get wrong (or skip). Mockbird fires real, HMAC-SHA256-signed webhooks on every record change:
curl -s -X PUT $BASE/api/projects/$PID/webhook -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{"url":"https://your-app.example.com/hooks/payments"}'
# โ {"ok":true,"webhook":{"secret":"whsec_71bfdae5107b4cf3โฆ","events":["created","updated","deleted"]}}
Now every payment your checkout creates delivers a payments.created event to your handler โ from the public internet, like the real thing:
POST /hooks/payments
X-Mockbird-Event: payments.created
X-Mockbird-Delivery: evt_6da43afc221ae8b9
X-Mockbird-Signature: sha256=a32ceed53302898eโฆ
{"id":"evt_โฆ","event":"payments.created","project":"โฆ","resource":"payments",
"action":"created","record":{"amount":120.5,"currency":"gbp","status":"processing",โฆ},"ts":โฆ}
Verify it in your handler exactly like you would a gateway's (Node):
import crypto from "node:crypto";
function verify(rawBody, sigHeader, secret) {
const expected = "sha256=" +
crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected));
}
The signature above was computed and verified against a live delivery before this guide was published. Deliveries are logged (GET /api/projects/<PID>/webhook/deliveries โ status, latency, errors), and there's a test button in the dashboard. Full recipe incl. local-tunnel setup in sending test webhooks.
| Stripe test mode | stripe-mock (OSS) | Mockbird | |
|---|---|---|---|
| Account / API keys needed | โ account + keys | โ (local binary) | โ nothing |
| Exact Stripe response shapes & test cards | โ the real thing | โ spec-generated | โ your own shapes |
| Stateful (POST visible to later GETs) | โ | โ static responses | โ real persistence |
| Reachable by CI, teammates, deployed previews | โ | your network only | โ public HTTPS |
| Fail on demand (declines, 503s, timeouts, sequences) | test cards only | โ | query params |
| Signed webhooks without tunnel/CLI setup | needs stripe listen | โ | โ hosted โ any URL |
Straight answer on when to use which: if you're integrating the actual Stripe SDK and need its exact objects and error codes, use Stripe test mode (and stripe-mock in unit tests) โ that's what they're for, and they're excellent. Use Mockbird when the thing under test is your flow โ checkout states, decline UX, retry/idempotency logic, webhook verification, order screens โ and you don't want a payment-provider account standing between a frontend dev (or a CI job, or a workshop room) and a working backend. The two compose: many teams mock "our payments service" (this guide) while the payments service itself is tested against Stripe test mode.
cardLast4, method (oneOf), refunds as a nested resource, whatever your app expects. refId fields link payments to orders/customers with _expand./v1/payment_intents? Custom routes serve any path with templated bodies โ same trick as our OpenAI mock guide. For replaying a recorded real API, see mocking third-party APIs.The paste block in section 1 is the whole setup โ or open the dashboard and click it together. The same project also serves GraphQL, exports OpenAPI + TypeScript types, and snapshots its state for deterministic test fixtures.