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:
/link/token/create β answers "the following required fields are missing: client_id, secret" until you've created a dashboard account and copied API keys. (Verified September 2026 with a plain curl to sandbox.plaid.com.)link_token server-side β run the Link flow (or call /sandbox/public_token/create) β exchange the public_token for an access_token β then ask for transactions. Three credentialed API calls before your first fake record.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.
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"
}
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"
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.)
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}'
ITEM_LOGIN_REQUIREDThe 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.
SYNC_UPDATES_AVAILABLE stand-inPlaid 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.
| You wanted | Plaid Sandbox | Mockbird |
|---|---|---|
| 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 |
personal_finance_category, counterparties, enrichment) β a home-rolled mock can't tell you your Plaid integration works. Before go-live you must pass through the sandbox anyway./sandbox/item/fire_webhook, /sandbox/item/reset_login, custom user JSON) that are genuinely well designed.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.
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.