Mock a payment API โ€” test checkout flows without gateway keys

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.

To be clear about what this is: it is not a Stripe emulator. It won't reproduce Stripe's exact response shapes, error catalogue, or test-card numbers โ€” for deep integration tests against the real SDK, Stripe's own test mode and the open-source 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.

1. Create the mock (one paste)

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.

2. The happy path โ€” create, poll, settle

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".

3. Declines โ€” exactly when you want them

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.

4. Retry choreography you can assert on

"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:

5. Slow gateways and 3-D Secure limbo

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.

6. Signed payment webhooks โ€” the part everyone tests last

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.

Honest comparison

Stripe test modestripe-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 setupneeds 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.

Notes & limits

Create yours

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.