> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Plaid

> Link, items and accounts, transactions, institutions, sandbox helpers, and webhooks.

Stateful Plaid Sandbox emulation: Link tokens and a hosted Link page, public token exchange, items, accounts and balances, Auth ACH numbers, transactions sync and get over deterministic sandbox data, institutions, the sandbox helpers, and webhooks signed with Plaid's ES256 `Plaid-Verification` JWT. No real bank is ever contacted. The tests drive it with the official [`plaid`](https://www.npmjs.com/package/plaid) Node SDK (48.0.0) and verify webhooks with [`jose`](https://www.npmjs.com/package/jose).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/plaid` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `plaid` |
| HTTP operations | 32, browsable at `http://localhost:3999/docs/plaid` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service plaid
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const plaid = await createEmulator({ service: "plaid" });
// plaid.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "plaid": {
    "client_id": "6650f1c0d1e2f3a4b5c6d7e8",
    "secret": "baselinesandboxsecret000000000",
    "items": [
      {
        "institution_id": "ins_109508",
        "access_token": "access-sandbox-baseline-local",
        "products": ["transactions", "auth"]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service plaid
```

Plaid starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`. A fresh emulator accepts the default credentials below and serves Plaid's five sandbox [institutions](#institutions); items come from Link, the [sandbox helpers](#sandbox-helpers), or the seed.

Point the SDK at the emulator with `basePath`:

```ts theme={null}
import { Configuration, PlaidApi, Products, CountryCode } from "plaid";

const client = new PlaidApi(
  new Configuration({
    basePath: process.env.PLAID_EMULATOR_URL,
    baseOptions: {
      headers: {
        "PLAID-CLIENT-ID": "6650f1c0d1e2f3a4b5c6d7e8",
        "PLAID-SECRET": "baselinesandboxsecret000000000",
      },
    },
  }),
);

const { data } = await client.linkTokenCreate({
  user: { client_user_id: "user-1" },
  client_name: "Acme",
  products: [Products.Transactions],
  country_codes: [CountryCode.Us],
  language: "en",
});
```

## URL mapping

| Real Plaid URL | Emulator URL |
| - | - |
| `https://sandbox.plaid.com/...` | `$PLAID_EMULATOR_URL/...` |

## Auth

Every endpoint is a `POST` with a JSON body. Credentials go in the body (`client_id`, `secret`) or in the `PLAID-CLIENT-ID` and `PLAID-SECRET` headers. A fresh emulator accepts client id `6650f1c0d1e2f3a4b5c6d7e8` and secret `baselinesandboxsecret000000000`; `client_id` and `secret` in the seed replace them. Missing credentials are 400 `MISSING_FIELDS`, wrong ones 400 `INVALID_API_KEYS`.

### Errors

Errors use Plaid's error object: `{ error_type, error_code, error_code_reason, error_message, display_message, request_id, causes, status, documentation_url, suggested_action }`. Unknown fields are `UNKNOWN_FIELDS`, missing required fields `MISSING_FIELDS`, and bad values `INVALID_FIELD`. An unknown access token is `INVALID_ACCESS_TOKEN`; an item that needs a new login is `ITEM_LOGIN_REQUIRED`; transactions that are not ready yet are `PRODUCT_NOT_READY`. Every response carries a `request_id`.

## Link

* `POST /link/token/create` — Create Link token: `user.client_user_id`, `client_name`, `products`, `country_codes`, `language`, `webhook`, `redirect_uri`, `account_filters`, `transactions.days_requested`, `hosted_link`, and `access_token` (update mode). It returns `link_token` (four hours), `expiration`, and `hosted_link_url`
* `POST /link/token/get` — Get Link token: the token's settings and its Link sessions, with each session's success or exit
* `POST /item/public_token/exchange` — Exchange public token: turns it into `access_token` (`access-sandbox-...`) and `item_id`

The hosted Link page at `hosted_link_url` (`/hl/<link_token>`) walks through what Plaid Link does:

1. Pick an institution.
2. Sign in with a sandbox login. The page lists the test logins.
3. Answer MFA, if the login asks for it.
4. Pick accounts. Auth only tokens offer checking and savings; `account_filters` apply.

On success the emulator makes an item and a public token (30 minutes, single use). With `hosted_link.completion_redirect_uri`, the page redirects there with `public_token`, `link_session_id`, and `status=success` added. Otherwise it shows the token and posts `{ source: "baseline-plaid-link", action: "success", public_token, metadata }` to `window.opener` or the parent frame. Exiting posts `action: "exit"` with the error and status, or redirects with `status=exited`. A Link token made with `access_token` repairs that item in update mode.

### Sandbox logins

| Username | Password | Result |
| - | - | - |
| `user_good` | `pass_good` | Success |
| `user_good` | `mfa_device` | One-time code, answer `1234` |
| `user_good` | `mfa_questions_<n>_<m>` | `n` rounds of `m` questions, answer `answer_<round>_<question>` |
| `user_good` | `mfa_selections` | Multiple choice, answer `Yes` |
| `user_good` | `error_<CODE>` | Fails with that item error, for example `error_ITEM_LOCKED` |
| `user_transactions_dynamic` | any | Success |

Logins seeded under `sandbox_users` are checked first.

## Items and accounts

* `POST /item/get` — Retrieve item: the item and its status
* `POST /item/remove` — Remove item: its token is refused afterwards
* `POST /item/webhook/update` — Update webhook: sets the webhook and sends `WEBHOOK_UPDATE_ACKNOWLEDGED`
* `POST /item/access_token/invalidate` — Rotate access token
* `POST /accounts/get` — Retrieve accounts: works while the item has an error
* `POST /accounts/balance/get` — Retrieve real time balances
* `POST /auth/get` — Retrieve auth data: ACH numbers for checking, savings, and cash management accounts: account `111122223333<mask>`, routing `011401533`, wire routing `021000021`
* `options.account_ids` filters accounts on every route that takes it

The sandbox accounts match Plaid's: checking `0000`, savings `1111`, CD `2222`, credit card `3333`, money market `4444`, IRA `5555`, 401k `6666`, student loan `7777`, and mortgage `8888`, with Plaid's documented balances.

## Transactions

Each item gets a deterministic history over `days_requested` (90 by default): payroll, rent, card payments, interest, savings transfers, and everyday merchants, with Plaid's personal finance categories. The same seed and item give the same transactions on every run. Card purchases from the last two days are pending.

* `POST /transactions/sync` — Sync transactions: `cursor`, `count`, `options`
* `POST /transactions/get` — Get transactions: `start_date`, `end_date`, `options`
* `POST /transactions/refresh` — Refresh transactions: accepted

Sync takes `cursor`, `count` (1 to 500, default 100), and `options` (`account_id`, `include_original_description`). It returns `added`, `modified`, `removed`, `next_cursor`, `has_more`, `accounts`, and `transactions_update_status`. Start with no cursor, page until `has_more` is false, and keep the last `next_cursor` for the next sync. Changes between pages fail the next page with `TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION`; restart from the cursor that began the loop. While transactions are not ready, sync returns an empty page with `next_cursor: ""` and `NOT_READY`.

Get takes `start_date`, `end_date`, and `options` (`count` up to 500, `offset`, `account_ids`). It returns `transactions` newest first, `total_transactions`, `accounts`, and `item`.

New transactions come from `POST /sandbox/transactions/create` (up to 10). Modify and remove them with the emulator controls:

```bash theme={null}
curl -X POST "$PLAID_EMULATOR_URL/_plaid/items/<item_id>/transactions/modify" \
  -H "Content-Type: application/json" -d '{"transaction_id":"<id>","amount":12.5}'
curl -X POST "$PLAID_EMULATOR_URL/_plaid/items/<item_id>/transactions/remove" \
  -H "Content-Type: application/json" -d '{"transaction_ids":["<id>"]}'
```

`modify` takes `amount`, `name`, `merchant_name`, `date`, or `pending: false`. Posting a pending transaction removes it and adds a posted one with `pending_transaction_id`, as Plaid does.

* `POST /_plaid/items/<item_id>/transactions/ready` — Make transactions ready: now, when `transactions_ready_delay_ms` or `transactions_ready: false` held them back
* `POST /_plaid/items/<item_id>/error` — Set item error: `{ "error_code": "ITEM_LOGIN_REQUIRED" }` puts an item into an error state and sends the webhook; `{ "error_code": null }` clears it and sends `LOGIN_REPAIRED`

## Institutions

* `POST /institutions/get_by_id` — Get institution by ID
* `POST /institutions/search` — Search institutions: `query`, `products`, `country_codes`
* `POST /institutions/get` — Get institutions: `count`, `offset`, `country_codes`

The defaults are Plaid's sandbox institutions: `ins_109508` First Platypus Bank, `ins_109509` First Gingham Credit Union, `ins_109510` Tattersall Federal Credit Union, `ins_109511` Tartan Bank, and `ins_109512` Houndstooth Bank.

## Sandbox helpers

* `POST /sandbox/public_token/create` — Create public token: makes an item without Link. `options.override_username` and `override_password` pick the login (MFA logins succeed; error logins fail with their code), and `options.webhook` and `options.transactions.days_requested` apply
* `POST /sandbox/item/fire_webhook` — Fire webhook: `webhook_code` (and `webhook_type` for `DEFAULT_UPDATE`): `SYNC_UPDATES_AVAILABLE`, `DEFAULT_UPDATE`, `RECURRING_TRANSACTIONS_UPDATE`, `NEW_ACCOUNTS_AVAILABLE`, `USER_PERMISSION_REVOKED`, `USER_ACCOUNT_REVOKED`, `PENDING_DISCONNECT`, `LOGIN_REPAIRED`, `ERROR`, `SMS_MICRODEPOSITS_VERIFICATION`, and the Baseline extras `PENDING_EXPIRATION`, `AUTOMATICALLY_VERIFIED`, and `VERIFICATION_EXPIRED`. An item without a webhook is `SANDBOX_WEBHOOK_INVALID`
* `POST /sandbox/item/reset_login` — Reset login: puts the item in `ITEM_LOGIN_REQUIRED` and sends `ITEM` `ERROR`
* `POST /sandbox/transactions/create` — Create transactions: adds transactions and sends `SYNC_UPDATES_AVAILABLE`

## Webhooks

Webhooks go to the item's `webhook` (from the Link token, the sandbox helper, `/item/webhook/update`, or the seed). The emulator sends `TRANSACTIONS` `SYNC_UPDATES_AVAILABLE` (plus `INITIAL_UPDATE`, `HISTORICAL_UPDATE`, `DEFAULT_UPDATE`, and `TRANSACTIONS_REMOVED` until the item first calls sync), `ITEM` `ERROR`, `PENDING_EXPIRATION`, `USER_PERMISSION_REVOKED`, `LOGIN_REPAIRED`, and `WEBHOOK_UPDATE_ACKNOWLEDGED`, `AUTH` events, and `LINK` `ITEM_ADD_RESULT` and `SESSION_FINISHED` for hosted Link. Failed posts are retried after 1, 4, and 16 seconds.

Each post carries a `Plaid-Verification` header: an ES256 JWT with `iat` and `request_body_sha256`. Verify it the way Plaid's docs describe:

```ts theme={null}
import { createHash, timingSafeEqual } from "node:crypto";
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";

const token = req.headers["plaid-verification"];
const { alg, kid } = decodeProtectedHeader(token);
if (alg !== "ES256") throw new Error("unexpected alg");
const { data } = await client.webhookVerificationKeyGet({ key_id: kid });
const { payload } = await jwtVerify(token, await importJWK(data.key, "ES256"), { maxTokenAge: "5 min" });
const hash = createHash("sha256").update(rawBody).digest("hex");
const ok = timingSafeEqual(Buffer.from(hash), Buffer.from(String(payload.request_body_sha256)));
```

Hash the raw body exactly as received: Plaid's bodies are JSON with two space indentation.

* `POST /webhook_verification_key/get` — Get webhook verification key: the public JWK for a `key_id`; an unknown id is `INVALID_WEBHOOK_VERIFICATION_KEY_ID`

Set `webhook_signing_key` in the seed to keep the same key across fresh starts.

## Seed config

```json theme={null}
{
  "plaid": {
    "client_id": "6650f1c0d1e2f3a4b5c6d7e8",
    "secret": "baselinesandboxsecret000000000",
    "client_name": "Acme",
    "transactions_seed": "baseline",
    "transactions_ready_delay_ms": 0,
    "institutions": [
      {
        "institution_id": "ins_200001",
        "name": "Acme Credit Union",
        "products": ["auth", "transactions"]
      }
    ],
    "sandbox_users": [
      {
        "username": "ada",
        "password": "secret",
        "mfa": "device"
      },
      {
        "username": "locked",
        "password": "secret",
        "error_code": "ITEM_LOCKED"
      }
    ],
    "items": [
      {
        "institution_id": "ins_109508",
        "access_token": "access-sandbox-baseline-local",
        "item_id": "baselineLocalItem",
        "products": ["transactions", "auth"],
        "webhook": "http://localhost:3000/plaid/webhooks",
        "days_requested": 90
      }
    ]
  }
}
```

* `transactions_seed`: changes the generated history
* `transactions_ready_delay_ms`: how long new items stay PRODUCT\_NOT\_READY
* `access_token`: derived when left out

Sandbox users may list their own `accounts` (`name`, `mask`, `type`, `subtype`, `available`, `current`, `limit`); they get Plaid's sandbox accounts otherwise. `transactions_ready: false` on an item holds its transactions until `POST /_plaid/items/<item_id>/transactions/ready`. Seeding again does not duplicate entries.

## Inspector

`GET /_inspector` has tabs for items with their accounts, transactions per item, Link tokens and sessions, webhook deliveries with their bodies, and setup (credentials and sandbox logins).

## Limits

* Only Transactions, Auth, and Balance have endpoints. Link tokens accept the other product names, but Identity, Investments, Liabilities, Assets, Transfer, and Income routes are not served (404 `NOT_FOUND`).
* No OAuth institutions, Plaid Link SDK script (`link-initialize.js`), or embedded Link; use the hosted Link page.
* No recurring transactions endpoint, enrichment, or categorization beyond the generated personal finance categories.
* Webhook retries run three times over about 20 seconds, where Plaid retries for longer.
* Plaid publishes no licensed OpenAPI spec, so routes are checked against the SDK, not a vendored spec.

## Conformance

| | |
| - | - |
| Routes | 33 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `jose@6.2.12`, `plaid@48.0.0` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.