Plaid Banking skill for Claude Code

Bank linking with encrypted tokens and cursor-based sync. Claude wires Plaid the way fintech teams ship it: link tokens minted per session, access tokens envelope-encrypted and never exposed to clients, transaction sync applied atomically per cursor with restarts on mid-pagination mutations, ES256 webhook verification with body hashes, update mode for broken logins, and /item/remove plus deletion when a user disconnects.

  • nameplaid-banking
  • size227 lines · ≈2,800 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Integrates Plaid for bank account linking and financial data - server-created link tokens, Link on web and mobile including OAuth redirects, public token exchange, encrypted access token storage, /transactions/sync with cursors and pagination restarts, JWT-verified webhooks, update mode for ITEM_LOGIN_REQUIRED, balances, Auth with processor tokens, and data-handling basics for production approval. Use when adding "connect your bank", syncing transactions, verifying accounts for ACH, handling Plaid webhooks or Item errors, or preparing a Plaid app for production review.

Try asking

  • Add a connect-your-bank flow with Plaid Link and store the access token safely
  • Our transactions are duplicated after pending ones post. Fix the Plaid sync
  • Handle ITEM_LOGIN_REQUIRED so users can repair a broken bank connection

Use it when

  • Adding bank account linking with Plaid Link on web or mobile
  • Syncing transactions or balances into your database
  • Verifying accounts for ACH through a payment processor
  • Handling Plaid webhooks, Item errors, or disconnects

Skip it when

How to check the result

Sandbox runs cover happy path, reset login with update-mode repair, and fired sync webhooks; repeated syncs write nothing new, tampered webhooks are rejected, and logs contain no access tokens or account numbers.

The common mistake it prevents

Persisting the cursor after each page instead of after the whole update, so a mid-pagination failure leaves transactions permanently missing or duplicated.

~/.claude/skills/plaid-banking/SKILL.md

--- name: plaid-banking description: Integrates Plaid for bank account linking and financial data - server-created link tokens, Link on web and mobile including OAuth… license: MIT ---

Done well means: access tokens never leave the server and are encrypted at rest, transaction sync is cursor-based and replay-safe, every webhook is verified before it triggers work, broken connections are repaired through update mode instead of re-linking duplicates, and the app holds the least financial data it needs for the shortest time.

Ground rules

  • PLAID_CLIENT_ID and PLAID_SECRET (per environment) come from a secret manager. Never ship them, the access_token, or account/routing numbers to a browser or mobile app.
  • Environments are Sandbox and Production (Limited Production for early live testing). Keep separate secrets and separate DB rows per environment; tokens are not portable.
  • Encrypt access_token with envelope encryption (KMS data key) before storing. Log the item_id and request_id, never tokens, account numbers, or full transaction payloads.
  • This skill is engineering guidance, not legal or compliance advice. Whether you need money-transmission licensing, GLBA programs, or specific disclosures is for counsel.

Workflow

- [ ] 1. Decide products (transactions, auth, balance, identity) and request only those
- [ ] 2. Server: /link/token/create per user session (webhook URL, redirect_uri if OAuth)
- [ ] 3. Client: open Link with the link_token; send public_token to the server
- [ ] 4. Server: /item/public_token/exchange; encrypt and store access_token + item_id
- [ ] 5. Data: /transactions/sync loop, /accounts/get, Auth via processor token
- [ ] 6. Webhooks: verify JWT, enqueue, handle sync and Item health events
- [ ] 7. Update mode, disconnect (/item/remove), retention and deletion
- [ ] 8. Sandbox test matrix, then production checklist

Data model

CREATE TABLE plaid_item (
  item_id            text PRIMARY KEY,
  user_id            uuid NOT NULL REFERENCES app_user(id),
  institution_id     text,
  access_token_enc   bytea NOT NULL,         -- envelope-encrypted
  transactions_cursor text,                  -- last fully applied cursor
  status             text NOT NULL DEFAULT 'healthy',  -- healthy | login_required | revoked
  consent_expires_at timestamptz,
  created_at         timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE bank_txn (
  transaction_id     text PRIMARY KEY,
  item_id            text NOT NULL REFERENCES plaid_item(item_id) ON DELETE CASCADE,
  account_id         text NOT NULL,
  amount             numeric(14,2) NOT NULL, -- Plaid sign: positive = money leaving the account
  iso_currency_code  text,
  date               date NOT NULL,
  name               text,
  pending            boolean NOT NULL,
  pending_transaction_id text
);
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from "plaid";

const plaid = new PlaidApi(new Configuration({
  basePath: PlaidEnvironments[process.env.PLAID_ENV ?? "sandbox"],
  baseOptions: { headers: { "PLAID-CLIENT-ID": process.env.PLAID_CLIENT_ID!, "PLAID-SECRET": process.env.PLAID_SECRET! } },
}));

export async function createLinkToken(userId: string) {
  const { data } = await plaid.linkTokenCreate({
    user: { client_user_id: userId },          // your stable internal ID, never email or SSN
    client_name: "Acme Budget",
    products: [Products.Transactions],
    country_codes: [CountryCode.Us],
    language: "en",
    webhook: "https://api.example.com/webhooks/plaid",
    redirect_uri: process.env.PLAID_REDIRECT_URI,   // must be registered in the Dashboard; needed for OAuth banks
    transactions: { days_requested: 90 },           // ask for what you need; more history = slower first sync
  });
  return data.link_token;                          // short-lived; create per session
}

export async function exchange(userId: string, publicToken: string) {
  const { data } = await plaid.itemPublicTokenExchange({ public_token: publicToken });
  await db.plaidItem.upsert({ item_id: data.item_id, user_id: userId, access_token_enc: await encrypt(data.access_token) });
}

Client side, use react-plaid-link (usePlaidLink({ token, onSuccess, onExit })), the iOS and Android SDKs, or Hosted Link. For OAuth institutions on web, the bank redirects back to redirect_uri; reinitialize Link on that page with the same link token and receivedRedirectUri: window.location.href. On mobile, configure universal/app links.

Before exchanging, check whether this user already has an Item for the same institution_id (from onSuccess metadata). Duplicate Items double-bill and double-count transactions; route the user to update mode instead.

5. Transactions sync

Apply a whole update (all pages) atomically, then store the cursor. Never persist an intermediate cursor.

# Shape of the loop; with plaid-python wrap the dict in TransactionsSyncRequest(...)
# and read the PlaidError code from json.loads(e.body)["error_code"].
def sync_item(item_id: str) -> None:
    item = repo.get_item(item_id)
    start_cursor = item.transactions_cursor or ""        # "" = full history
    while True:                                          # outer loop: restart on mutation
        cursor, added, modified, removed = start_cursor, [], [], []
        try:
            while True:
                resp = plaid.transactions_sync({"access_token": decrypt(item.access_token_enc),
                                                "cursor": cursor, "count": 500})
                added += resp["added"]; modified += resp["modified"]; removed += resp["removed"]
                cursor = resp["next_cursor"]
                if not resp["has_more"]:
                    break
        except PlaidError as e:
            if e.code == "TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION":
                continue                                  # restart from start_cursor, not mid-page
            raise
        with db.transaction():
            repo.upsert_txns(added + modified)            # keyed on transaction_id
            repo.delete_txns([r["transaction_id"] for r in removed])
            repo.set_cursor(item_id, cursor)
        return
  • Pending transactions are later removed and replaced by posted ones with pending_transaction_id pointing at the pending ID. Carry user edits (categories, notes) across that link.
  • On the very first call the data may not be ready: you get no transactions and an empty next_cursor; wait for the webhook rather than polling hard.
  • Amount sign is inverted from most UIs: positive = outflow. Convert at the display layer only.
  • /accounts/get returns cached balances; /accounts/balance/get fetches live balances, is slower, and may be billed per call. Use live balance only right before moving money.

Auth and money movement

Do not store account and routing numbers if a payment processor can hold them. Create a processor token (/processor/token/create) or, for Stripe, the bank account token flow, and pass that to the processor. If you must call /auth/get, encrypt numbers, mask in UI (last 4), and restrict access to the payments service.

6. Webhooks

Verify every webhook before acting:

  1. Read the Plaid-Verification header (a JWT). Decode its header without verifying; require alg == "ES256" and take kid.
  2. Fetch the key with /webhook_verification_key/get (key_id = kid); cache by kid.
  3. Verify the JWT signature with that JWK; reject if iat is older than 5 minutes.
  4. Compute SHA-256 of the raw request body and compare it to the request_body_sha256 claim in constant time.
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
import crypto from "node:crypto";

const keyCache = new Map<string, CryptoKey>();
export async function verifyPlaid(raw: Buffer, token: string | undefined) {
  if (!token) return false;
  const { alg, kid } = decodeProtectedHeader(token);
  if (alg !== "ES256" || !kid) return false;
  let key = keyCache.get(kid);
  if (!key) {
    const { data } = await plaid.webhookVerificationKeyGet({ key_id: kid });
    key = (await importJWK(data.key as any, "ES256")) as CryptoKey;
    keyCache.set(kid, key);
  }
  const { payload } = await jwtVerify(token, key, { algorithms: ["ES256"], maxTokenAge: "5 min" });
  const digest = crypto.createHash("sha256").update(raw).digest("hex");
  const claim = String(payload.request_body_sha256 ?? "");
  return claim.length === digest.length && crypto.timingSafeEqual(Buffer.from(claim), Buffer.from(digest));
}

Respond 200 fast and enqueue. Webhooks can repeat or arrive out of order; handlers must be idempotent (a sync is naturally idempotent because it reads from the stored cursor).

Webhook (webhook_type / webhook_code)Action
TRANSACTIONS / SYNC_UPDATES_AVAILABLERun sync_item (debounce per item)
ITEM / ERROR with ITEM_LOGIN_REQUIREDMark login_required, prompt user to repair via update mode
ITEM / PENDING_EXPIRATION or PENDING_DISCONNECTNotify user to re-authenticate before access lapses
ITEM / USER_PERMISSION_REVOKED or USER_ACCOUNT_REVOKEDMark revoked, stop syncing, delete data per policy
ITEM / NEW_ACCOUNTS_AVAILABLEOffer update mode with account selection
ITEM / LOGIN_REPAIREDMark healthy, trigger a sync

Check the current webhook reference for codes added since; log and ignore unknown codes rather than failing the endpoint.

7. Update mode and disconnect

  • Update mode: call /link/token/create with the Item’s access_token (and no products), open Link, and on success keep using the same access token. No new exchange is needed.
  • User disconnects: call /item/remove, then delete the encrypted token and the user’s financial data according to your retention policy. Removing only locally keeps billing on.

Gotchas

  • client_user_id must be stable and not PII; it is used for Plaid-side dedupe and support.
  • Link tokens expire within hours and public tokens within minutes; create them on demand.
  • redirect_uri mismatches (trailing slash, http vs https) break every OAuth bank silently in production while Sandbox non-OAuth institutions work fine.
  • Some institutions return multiple accounts of mixed types; filter by subtype before allowing ACH.
  • Currency can be null with unofficial_currency_code set (crypto, some foreign accounts).
  • Do not compute balances by summing transactions; history is limited and pending changes.

Verification

  • Sandbox matrix: user_good / pass_good happy path; /sandbox/item/reset_login then confirm the ITEM_LOGIN_REQUIRED path and update-mode repair; /sandbox/item/fire_webhook for SYNC_UPDATES_AVAILABLE; a sandbox OAuth institution to exercise redirect_uri.
  • Run sync twice with no new data: zero writes, cursor unchanged. Simulate the mutation error and confirm the loop restarts from the stored cursor.
  • POST a captured webhook with one byte changed: rejected. Replay a valid one: no duplicate rows.
  • Grep logs for access- token prefixes and account numbers: none.
  • Production readiness: Dashboard application and security questionnaire complete, data retention and deletion documented, user-facing disclosure and consent in place.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc