Let's be precise about what the PayPal sandbox is: the real integration environment, and the only place to verify your actual PayPal integration before go-live. Nothing on this page replaces it.
But the everyday dev loop โ building order screens, decline UX, retry logic, webhook handlers โ pays a lot of sandbox tax that has nothing to do with what you're testing:
POST /v2/checkout/orders returns 401 AUTHENTICATION_FAILURE. Before your first mock payment you need a PayPal account, the developer dashboard, a REST app, and a client ID + secret.CREATED means a sandbox buyer account clicking through a real web approval flow โ automating that in CI means scripting a login UI you don't control.PayPal-Mock-Response header with a mock_application_codes value from their documented error list โ after you've done all the auth setup above. You can't script an arbitrary status or an exact fail-fail-succeed sequence.This guide builds the mock version in one paste: a hosted, stateful, PayPal-shaped API on a public HTTPS URL. No account, no keys, no token dance โ and it fails exactly when you tell it to.
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 PayPal-shaped responses specifically.BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
-d '{"name":"paypal-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":"orders",
"fields":[
{"name":"intent","type":"oneOf","values":["CAPTURE","AUTHORIZE"]},
{"name":"status","type":"oneOf","values":["CREATED","APPROVED","PAYER_ACTION_REQUIRED","COMPLETED","VOIDED"]},
{"name":"amountValue","type":"number"},
{"name":"currencyCode","type":"oneOf","values":["USD","EUR","GBP"]},
{"name":"payerEmail","type":"email"},
{"name":"createTime","type":"pastDate"}
],
"seed":5
}'
You now have GET/POST/PATCH/DELETE /m/<PID>/orders with 5 seeded, PayPal-shaped records:
curl "https://mockbird.mockbird.workers.dev/m/<PID>/orders?limit=1"
[
{
"id": 1,
"intent": "AUTHORIZE",
"status": "PAYER_ACTION_REQUIRED",
"amountValue": 103,
"currencyCode": "GBP",
"payerEmail": "omar.marin4@example.com",
"createTime": "2026-04-07T00:12:30.590Z"
}
]
The statuses are the real Orders API vocabulary, so your status-badge component and state machine exercise real branches. Filters work immediately: ?status=COMPLETED, ?amountValue_gte=40, ?currencyCode=EUR.
In the sandbox, an order sits in CREATED until a sandbox buyer logs in and approves it. Here, your test driver plays the buyer โ no web flow, no second account:
# your checkout creates an order
curl -s -X POST $BASE/m/$PID/orders -H 'content-type: application/json' \
-d '{"intent":"CAPTURE","status":"CREATED","amountValue":49.99,"currencyCode":"USD","payerEmail":"jo@example.com"}'
# โ {"intent":"CAPTURE","status":"CREATED","amountValue":49.99,โฆ,"id":6}
# your UI polls โ the record is really there (stateful, not a fixture)
curl -s $BASE/m/$PID/orders/6 # status: "CREATED"
# the test driver "approves" and settles it
curl -s -X PATCH $BASE/m/$PID/orders/6 -H 'content-type: application/json' \
-d '{"status":"COMPLETED"}'
Every write is visible to every later read, from any client โ browser, CI job, teammate's curl. Walk an order through CREATED โ APPROVED โ COMPLETED and watch your UI track it.
/orders/:id/capture endpointIf your client calls a capture-style path, don't change the client โ add a custom route with a templated PayPal-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":"/orders/:id/capture","status":201,
"body":"{\"id\":\"{{params.id}}\",\"status\":\"COMPLETED\",\"purchase_units\":[{\"payments\":{\"captures\":[{\"id\":\"{{uuid}}\",\"status\":\"COMPLETED\"}]}}],\"update_time\":\"{{now}}\"}"
}'
curl -s -X POST $BASE/m/$PID/orders/6/capture
{
"id": "6",
"status": "COMPLETED",
"purchase_units": [
{ "payments": { "captures": [ { "id": "a62791a0-ce78-4187-acfe-cddf17c4c9ae", "status": "COMPLETED" } ] } }
],
"update_time": "2026-09-15T13:42:26.103Z"
}
Custom routes take precedence over the generated CRUD routes, so this coexists with the orders resource above โ {{params.id}}, {{uuid}}, and {{now}} are filled per request.
PayPal's Orders API signals business failures like INSTRUMENT_DECLINED as HTTP 422. Get one whenever you want it:
curl -si -X POST "$BASE/m/$PID/orders?mock_status=422" \
-H 'content-type: application/json' \
-d '{"intent":"CAPTURE","status":"CREATED","amountValue":10,"currencyCode":"USD"}'
# HTTP/2 422
# {"error": "simulated 422 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. No beta program, no auth, no enumerated code list โ full recipes in testing loading & error states.
# first attempt 422, second 422, third succeeds โ exactly
curl -s -o /dev/null -w '%{http_code}\n' -X POST \
"$BASE/m/$PID/orders?mock_seq=422,422,201&mock_seq_key=pp-retry-1" \
-H 'content-type: application/json' \
-d '{"intent":"CAPTURE","status":"CREATED","amountValue":20,"currencyCode":"EUR"}'
# run it 3x: 422, 422, 201
Each mock_seq_key tracks its own position, so parallel test workers don't trample each other's sequences.
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/paypal"}'
# โ {"ok":true,"webhook":{"secret":"whsec_bebb10c56a11โฆ","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 โ including the PATCH-to-COMPLETED from section 2, which is exactly the "payment finished" event your handler cares about. Verify snippet and delivery log in sending test webhooks.
| You wanted | PayPal sandbox | Mockbird |
|---|---|---|
| First request without an account | โ 401 โ dev account + REST app + client ID/secret first | โ one anonymous curl |
| No OAuth token plumbing in tests | โ Bearer token on every call, expires | โ no auth (opt-in mock JWT if you want it) |
| Advance an order without a buyer login | โ sandbox buyer approves via web flow | โ PATCH the status |
| Arbitrary error statuses | โณ negative testing (beta), documented codes via header | โ ?mock_status= any 400โ599 |
| Scripted outcome sequences | โ | โ ?mock_seq=422,422,201 |
| Deterministic latency | โ real shared environment | โ ?mock_delay=, jitter, chaos |
| Reset to a known dataset per test | โ | โ snapshots |
| Real PayPal integration truth | โ that's its job | โ your own shapes |
The clean split: Mockbird while you build โ UI states, declines, retries, webhook handler logic, CI. PayPal sandbox before you ship โ integration truth. They compose.
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.