A PayPal sandbox alternative for your dev loop โ€” no account, no OAuth, fails on demand

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:

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.

Testing your checkout flow, decline UX, retry logic, or webhook handler? That's this page. Verifying the real integration โ€” approval flow, capture truth, webhook signatures? Use the sandbox; see the honest comparison below.

1. A PayPal-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 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.

2. The approval flow you don't have to automate

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.

3. A literal /orders/:id/capture endpoint

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

4. Declines on demand โ€” any status, no enablement, no header vocabulary

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.

5. Exact retry choreography: declined, declined, captured

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

6. Signed webhooks โ€” no webhook simulator, 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/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.

PayPal-sandbox concern โ†’ Mockbird

You wantedPayPal sandboxMockbird
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

Honest comparison โ€” where the sandbox wins

The clean split: Mockbird while you build โ€” UI states, declines, retries, webhook handler logic, CI. PayPal sandbox 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.