A Plaid sandbox alternative for frontend & demo work β€” fake bank data without the Link ceremony

Credit first: the Plaid Sandbox is genuinely good β€” free, fully featured, unlimited test Items, every institution answering to user_good / pass_good, and dedicated /sandbox/* endpoints for simulating webhooks and Item states. If you are integrating Plaid itself, use it; nothing on this page replaces it.

But a lot of work that looks like "I need Plaid" is really "I need plausible bank data over HTTP" β€” a budgeting-app prototype, a portfolio project, a dashboard demo, a frontend course exercise, CI tests for transaction-list UI. For that job the sandbox charges a toll that has nothing to do with what you're building:

This guide builds the mock version in one paste: a hosted, stateful, bank-shaped API β€” accounts and transactions with Plaid's vocabulary β€” on a public CORS-enabled HTTPS URL you can hit straight from a browser, a test runner, or a teammate's curl. No account, no keys, no exchange.

Building your transaction-list UI, category charts, pending-badge logic, relink-error banner, or demo dataset? That's this page. Verifying a real Plaid integration β€” Link, OAuth institutions, real product payloads? Use the sandbox; see the honest comparison below.

1. A bank-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 bank/Plaid-shaped responses specifically.
BASE=https://mockbird.mockbird.workers.dev
P=$(curl -s -X POST $BASE/api/projects -H 'content-type: application/json' \
     -d '{"name":"fake-bank","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":"accounts",
  "fields":[
    {"name":"name","type":"oneOf","values":["Everyday Checking","High-Yield Savings","Joint Checking","Money Market","Rainy-Day Savings"]},
    {"name":"type","type":"oneOf","values":["depository"]},
    {"name":"subtype","type":"oneOf","values":["checking","savings"]},
    {"name":"mask","type":"oneOf","values":["0000","4321","8710","1533","9606"]},
    {"name":"current_balance","type":"price"},
    {"name":"available_balance","type":"price"},
    {"name":"iso_currency_code","type":"oneOf","values":["USD"]}
  ],
  "seed":5
}'

curl -s -X POST $BASE/api/projects/$PID/resources -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "name":"transactions",
  "fields":[
    {"name":"account_id","type":"oneOf","values":[1,2,3,4,5]},
    {"name":"merchant_name","type":"company"},
    {"name":"amount","type":"price"},
    {"name":"iso_currency_code","type":"oneOf","values":["USD"]},
    {"name":"date","type":"pastDate"},
    {"name":"pending","type":"boolean"},
    {"name":"payment_channel","type":"oneOf","values":["online","in store","other"]},
    {"name":"category","type":"oneOf","values":["Food and Drink","Travel","Shops","Transfer","Recreation","Payment"]}
  ],
  "seed":30
}'

Two tricks worth stealing: account_id is a numeric oneOf [1,2,3,4,5], so every seeded transaction points at an account that actually exists β€” no dangling joins β€” and the field names are Plaid's own (merchant_name, pending, payment_channel, iso_currency_code), so a later swap to real Plaid data touches less code.

curl "https://mockbird.mockbird.workers.dev/m/<PID>/accounts/1"
{
  "id": 1,
  "name": "High-Yield Savings",
  "type": "depository",
  "subtype": "savings",
  "mask": "0000",
  "current_balance": 21.67,
  "available_balance": 333.24,
  "iso_currency_code": "USD"
}

2. Query it like a budgeting app

Everything a transaction-list screen needs is a query parameter away β€” and it's all plain CORS GETs, callable directly from browser code with no backend in between:

# one account's transactions (nested route)
curl "$BASE/m/$PID/accounts/2/transactions"

# join the account onto each transaction
curl "$BASE/m/$PID/transactions?_expand=account&limit=1"
# β†’ each record gains "account": {"name":"Everyday Checking", …}

# pending badge logic
curl "$BASE/m/$PID/transactions?pending=true"

# big purchases since a date (ISO dates compare correctly)
curl "$BASE/m/$PID/transactions?amount_gte=500&date_gte=2026-08-01"

# pagination for infinite scroll (X-Total-Count header included)
curl "$BASE/m/$PID/transactions?page=2&limit=10&sortBy=date&order=desc"

3. A Plaid-shaped response envelope

Plaid's /transactions/get wraps results as {"transactions": […], "total_transactions": N, "request_id": …}. If your client already destructures that shape, don't change the client β€” reshape the response with mock_envelope:

curl "$BASE/m/$PID/transactions?limit=2&mock_envelope=%7B%22transactions%22%3A%22%24data%22%2C%22total_transactions%22%3A%22%24total%22%2C%22request_id%22%3A%22mockbird%22%7D"
{
  "transactions": [ …2 records… ],
  "total_transactions": 31,
  "request_id": "mockbird"
}

(That's the URL-encoded template {"transactions":"$data","total_transactions":"$total","request_id":"mockbird"} β€” $total is the full count, $data the current page. Set it once as the project default via PUT /api/projects/$PID/settings and every list response comes back Plaid-shaped.)

4. It's stateful β€” link a "new bank account" mid-demo

Writes persist and are visible to every later read, which is what makes demos and tests feel real:

# the user "links" a credit card
curl -s -X POST $BASE/m/$PID/accounts -H 'content-type: application/json' \
  -d '{"name":"Travel Rewards Card","type":"credit","subtype":"credit card","mask":"4444","current_balance":412.50,"available_balance":4587.50,"iso_currency_code":"USD"}'
# β†’ …"id": 6

# a pending charge appears on it
curl -s -X POST $BASE/m/$PID/transactions -H 'content-type: application/json' \
  -d '{"account_id":6,"merchant_name":"United Airlines","amount":347.20,"iso_currency_code":"USD","date":"2026-09-15","pending":true,"payment_channel":"online","category":"Travel"}'

# the nested route sees it immediately
curl "$BASE/m/$PID/accounts/6/transactions"        # β†’ 1 record

# …and later it "posts" (pending β†’ false), like a real bank feed
curl -s -X PATCH $BASE/m/$PID/transactions/31 -H 'content-type: application/json' \
  -d '{"pending":false}'

5. Relink errors on demand β€” ITEM_LOGIN_REQUIRED

The error UX Plaid apps most need to get right is the relink banner. Make it a custom route that returns Plaid's exact error envelope (this shape matches what sandbox.plaid.com really returns β€” same keys, same casing):

curl -s -X POST $BASE/api/projects/$PID/routes -H "x-admin-key: $KEY" \
  -H 'content-type: application/json' -d '{
  "method":"POST","path":"/item/error","status":400,
  "body":"{\"display_message\":null,\"error_type\":\"ITEM_ERROR\",\"error_code\":\"ITEM_LOGIN_REQUIRED\",\"error_message\":\"the login details of this item have changed and a user login is required to update this information\",\"request_id\":\"{{uuid}}\",\"suggested_action\":null}"
}'

curl -si -X POST $BASE/m/$PID/item/error     # β†’ HTTP 400 + that body, fresh request_id each call

For transient failures on the real endpoints, no route needed: ?mock_status=429 for rate-limit UX, ?mock_status=500&mock_delay=3000 for slow-failure spinners, and ?mock_seq=500,500,200 to choreograph an exact fail-fail-recover sequence for retry logic. Recipes in testing loading & error states.

6. Webhooks β€” your SYNC_UPDATES_AVAILABLE stand-in

Plaid tells your server about new transactions by webhook; so does Mockbird. Register a URL and every create/update/delete fires a signed POST (X-Mockbird-Signature: sha256=…, HMAC of the raw body) β€” so the handler code path that reacts to "new bank data available" actually runs in dev and CI:

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/bank"}'

Verification snippet and delivery log in sending test webhooks.

Plaid-sandbox concern β†’ Mockbird

You wantedPlaid SandboxMockbird
First request without an accountβœ— dashboard signup + client_id + secretβœ” one anonymous curl
Call it straight from a browserβœ— secrets in request body β†’ backend requiredβœ” CORS-open GETs
Fake data before token plumbingβœ— link_token β†’ public_token β†’ access_token firstβœ” data on the first request
Bend the dataset to your UI's edge casesβ–³ custom Sandbox users via JSON configβœ” it's your data β€” POST/PATCH anything
Trigger ITEM_LOGIN_REQUIRED / errors at willβ–³ via /sandbox/item/reset_login + keysβœ” custom route or ?mock_status=
Deterministic latency & failure sequencesβœ—βœ” mock_delay, mock_seq, chaos
Reset to a known dataset per testβ–³ create a fresh Itemβœ” snapshots
Real Link UI, OAuth banks, real payloadsβœ” that's its jobβœ— your own shapes

Honest comparison β€” where the Plaid Sandbox wins

The clean split: Mockbird for frontend prototypes, demos, portfolio projects, CI, and any consumer that just needs bank-shaped JSON today. Plaid Sandbox the moment your code touches real Plaid endpoints. They compose β€” same as with Stripe and the other providers below.

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.