An Adyen test environment alternative for your dev loop โ€” no API key, no cardholder named DECLINED

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:

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.

Testing your checkout flow, decline UX, retry logic, or webhook handler? That's this page. Verifying the real integration โ€” tokenization, 3DS2 challenges, local payment method redirects, genuine refusal reasons? Use Adyen's test env; see the honest comparison below.

1. An Adyen-shaped mock in one paste

New โ€” skip the paste: one click (or 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.)

2. Authorise, without the token dance

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.

3. A literal /payments/:id/captures endpoint

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

4. Declines are a parameter, not a cardholder name

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.

5. Exact retry choreography: refused, refused, authorised

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

6. Signed webhooks โ€” no Customer Area configuration, no tunnel

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.

Adyen-test concern โ†’ Mockbird

You wantedAdyen test envMockbird
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

Honest comparison โ€” where the test environment wins

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.

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 ejects to db.json anytime โ€” no lock-in.