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.
- name
plaid-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
- You are charging cards or running subscriptions; use stripe-payments instead
- You only need general webhook receiver patterns; use webhook-handling instead
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.
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_IDandPLAID_SECRET(per environment) come from a secret manager. Never ship them, theaccess_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_tokenwith envelope encryption (KMS data key) before storing. Log theitem_idandrequest_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
);
2–4. Link flow
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_idpointing 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/getreturns cached balances;/accounts/balance/getfetches 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:
- Read the
Plaid-Verificationheader (a JWT). Decode its header without verifying; requirealg == "ES256"and takekid. - Fetch the key with
/webhook_verification_key/get(key_id = kid); cache bykid. - Verify the JWT signature with that JWK; reject if
iatis older than 5 minutes. - Compute SHA-256 of the raw request body and compare it to the
request_body_sha256claim 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_AVAILABLE | Run sync_item (debounce per item) |
ITEM / ERROR with ITEM_LOGIN_REQUIRED | Mark login_required, prompt user to repair via update mode |
ITEM / PENDING_EXPIRATION or PENDING_DISCONNECT | Notify user to re-authenticate before access lapses |
ITEM / USER_PERMISSION_REVOKED or USER_ACCOUNT_REVOKED | Mark revoked, stop syncing, delete data per policy |
ITEM / NEW_ACCOUNTS_AVAILABLE | Offer update mode with account selection |
ITEM / LOGIN_REPAIRED | Mark 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/createwith the Item’saccess_token(and noproducts), 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_idmust 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_urimismatches (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
subtypebefore allowing ACH. - Currency can be null with
unofficial_currency_codeset (crypto, some foreign accounts). - Do not compute balances by summing transactions; history is limited and pending changes.
Verification
- Sandbox matrix:
user_good/pass_goodhappy path;/sandbox/item/reset_loginthen confirm the ITEM_LOGIN_REQUIRED path and update-mode repair;/sandbox/item/fire_webhookforSYNC_UPDATES_AVAILABLE; a sandbox OAuth institution to exerciseredirect_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.