First, credit where due: Adyen's test environment is the real integration surface โ the genuine /payments API, Drop-in and Components tokenization, 3DS2 challenge flows, redirect simulators for local payment methods โ and the only place to verify your actual Adyen integration before go-live. Nothing on this page replaces it.
But the everyday dev loop โ checkout screens, decline UX, retry logic, webhook handlers โ pays a test-environment tax that has nothing to do with what you're testing:
POST to checkout-test.adyen.com/v71/payments answers {"status":401,"errorCode":"000","message":"HTTP Status Response - Unauthorized"}.paymentMethod.holderName to a magic string: a shopper literally named DECLINED, or CARD_EXPIRED, NOT_ENOUGH_BALANCE, FRAUD, CVC_DECLINED, BLOCK_CARDโฆ Your fixtures ship a cardholder named DECLINED and hope nobody ever renders that on screen.additionalData.RequestedTestAcquirerResponseCode: 2 means refused, 6 expired card, 12 not enough balance, 20 fraud โ a ~40-row lookup table your test suite memorizes./payments call. Building a checkout screen's loading/error states means standing up that loop first.This guide builds the mock version in one paste: a hosted, stateful, Adyen-shaped API on a public HTTPS URL. No account, no API key, no holderName trivia โ declines are a parameter, not a cardholder name.
curl -X POST https://mockbird.mockbird.workers.dev/api/projects -H 'content-type: application/json' -d '{"preset":"payments"}') creates a ready payments sandbox: charges/refunds/subscriptions/customers CRUD plus a Stripe-shaped POST /v1/payment_intents → /v1/payment_intents/:id/confirm flow and /v1/balance. The paste below builds a custom-shaped mock instead โ use it when you want Adyen-shaped responses specifically.BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"adyen-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":"resultCode","type":"oneOf","values":["Authorised","Refused","Pending","Cancelled","Error","Received"]},
{"name":"refusalReason","type":"oneOf","values":["Not applicable","Refused","Expired Card","Not enough balance","CVC Declined","Blocked Card"]},
{"name":"pspReference","type":"uuid"},
{"name":"amountValue","type":"number"},
{"name":"currency","type":"oneOf","values":["EUR","USD","GBP"]},
{"name":"merchantReference","type":"slug"},
{"name":"createdAt","type":"pastDate"}
],
"seed":5
}'
You now have GET/POST/PATCH/DELETE /m/<PID>/payments with 5 seeded, Adyen-shaped records:
curl "https://mockbird.mockbird.workers.dev/m/<PID>/payments?limit=1"
[
{
"id": 1,
"resultCode": "Cancelled",
"refusalReason": "Refused",
"pspReference": "1c44f575-df51-4ca5-aa93-24d11ccfe219",
"amountValue": 669,
"currency": "EUR",
"merchantReference": "book-business-bridge-519",
"createdAt": "2026-01-10T07:33:20.571Z"
}
]
The result codes are the /payments response's real vocabulary (Authorised, Refused, Pending, Cancelled, Error, Received) and the refusal reasons are genuine refusalReason strings, so your status badges and state machine exercise real branches. Filters work immediately: ?resultCode=Refused, ?amountValue_gte=500, ?currency=EUR. (Amounts here are plain numbers โ treat them as minor units like Adyen's amount.value if you like; it's your schema. In real responses refusalReason only appears on refusals โ drop the field or ignore it on happy paths.)
In the test env, a payment is Drop-in โ client key โ test card โ payment details โ /payments. Here it's a POST, and the record is really there afterwards (stateful, not a fixture):
# your checkout "authorises" a payment
curl -s -X POST $BASE/m/$PID/payments -H 'content-type: application/json' \
-d '{"resultCode":"Authorised","amountValue":4999,"currency":"EUR","merchantReference":"order-1042","refusalReason":"Not applicable"}'
# โ {"resultCode":"Authorised","amountValue":4999,โฆ,"id":6}
# your UI polls โ the write persisted
curl -s $BASE/m/$PID/payments/6 # resultCode: "Authorised"
Every write is visible to every later read, from any client โ browser, CI job, teammate's curl. A PATCH {"resultCode":"Cancelled"} is your mock of a technical cancel (/payments/{pspReference}/cancels), and your UI can watch the record change.
/payments/:id/captures endpointAdyen captures an authorised payment with POST /v71/payments/{pspReference}/captures, which answers {"status":"received"}. If your client calls an action path like that, don't change the client โ add a custom route with a templated, Adyen-shaped body:
curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
-H 'content-type: application/json' -d '{
"method":"POST","path":"/payments/:id/captures","status":201,
"body":"{\"status\":\"received\",\"paymentPspReference\":\"{{params.id}}\",\"pspReference\":\"{{uuid}}\",\"receivedAt\":\"{{now}}\"}"
}'
curl -s -X POST "$BASE/m/$PID/payments/6/captures"
{
"status": "received",
"paymentPspReference": "6",
"pspReference": "55721900-92d8-4be0-abec-b71de5632f1f",
"receivedAt": "2026-09-15T18:42:14.390Z"
}
Custom routes take precedence over the generated CRUD routes, so this coexists with the payments resource above โ {{params.id}}, {{uuid}}, and {{now}} are filled per request. Same trick covers /payments/:id/cancels or /payments/:id/refunds.
No holderName trivia, no acquirer-response-code lookup table โ ask for the failure you want, when you want it:
curl -si -X POST "$BASE/m/$PID/payments?mock_status=402" \
-H 'content-type: application/json' \
-d '{"resultCode":"Authorised","amountValue":1000,"currency":"EUR"}'
# HTTP/2 402
# {"error": "simulated 402 error (mock_status)"}
# and no phantom record is created โ the write is not applied
Any status 400โ599 works: 429 for rate-limit UX, 500/503 for gateway-down banners, and mock_delay=4000 composes for slow-failure paths. Your fixtures can name their cardholders like humans. Full recipes in testing loading & error states.
# first attempt 402, second 402, third succeeds โ exactly
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"$BASE/m/$PID/payments?mock_seq=402,402,201&mock_seq_key=ady-retry-1" \
-H 'content-type: application/json' \
-d '{"resultCode":"Authorised","amountValue":2000,"currency":"EUR"}'
# run it 3x: 402, 402, 201
Each mock_seq_key tracks its own position, so parallel test workers don't trample each other's sequences. In the test env the closest equivalent is renaming your cardholder between runs โ DECLINED, DECLINED, then back to a real name.
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/adyen"}'
# โ {"ok":true,"webhook":{"secret":"whsec_bd2cba99c0f15e5bโฆ","events":["created","updated","deleted"]}}
Every create/update/delete fires a signed POST (X-Mockbird-Signature: sha256=โฆ, HMAC-SHA256 of the raw body) so your verification code path actually runs โ Adyen's standard webhooks are HMAC-signed too, so the discipline transfers. The PATCH-to-Cancelled from section 2 is exactly the notification moment your handler cares about. Verify snippet and delivery log in sending test webhooks.
| You wanted | Adyen test env | Mockbird |
|---|---|---|
| First request without an account | โ Customer Area + merchant account + API key | โ one anonymous curl |
| Trigger a decline explicitly | โ cardholder named DECLINED | โ ?mock_status=402, any payload |
| Trigger a specific refusal reason | โณ ~40 magic holderNames / acquirer codes | โ the status and body you asked for |
| Build UI before the token loop exists | โ Drop-in โ client key โ test card โ /payments | โ plain HTTP from the first minute |
| Scripted outcome sequences | โ | โ ?mock_seq=402,402,201 |
| Deterministic latency | โ real shared environment | โ ?mock_delay=, jitter, chaos |
| Reset to a known dataset per test | โ | โ snapshots |
| Real gateway integration truth | โ that's its job | โ your own shapes |
/payments API with the full result-code state machine (RedirectShopper, IdentifyShopper, ChallengeShopper for 3DS2), Drop-in/Components tokenization, redirect simulators for local payment methods where you pick the outcome, real HMAC-signed standard webhooks from the real notification system. A mock โ theirs or ours โ can differ from the live gateway in nuanced ways.refusalReason strings and raw acquirer response codes end-to-end, the test env returns the genuine article; Mockbird returns the status you asked for with a generic body (or a custom-route body you define).The clean split: Mockbird while you build โ UI states, declines, retries, webhook handler logic, CI. Adyen test env before you ship โ integration truth. They compose.
amount objects, additionalData)? Record a real test-env session once and replay it โ 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 ejects to db.json anytime โ no lock-in.