Plaid Integration skill for Codex
Bank connections that sync cleanly and stay connected. Codex wires Plaid Link through a server-created link token, keeps access tokens encrypted and out of logs, and syncs transactions from a stored cursor applied in one database transaction. Webhooks are verified with Plaid's signed JWT and body hash, ITEM_LOGIN_REQUIRED routes users through update mode instead of new Items, and everything is proven in Sandbox first.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- plaid-integration Installs to ~/.agents/skills/plaid-integration/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install plaid-integration The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project plaid-integration Installs to .agents/skills/plaid-integration/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/plaid-integration.zipunzip -o plaid-integration.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) plaid-integration PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $plaid-integration, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Integrates Plaid for read-only financial data - Link token creation, public token exchange, encrypted access token storage, Transactions Sync with cursors, Auth, Balance and Identity, signed webhook verification, update mode for ITEM_LOGIN_REQUIRED, OAuth institution redirects and Sandbox testing. Use when connecting bank accounts with Plaid Link, syncing transactions, verifying account ownership, handling Plaid webhooks or errors, or preparing a Plaid integration for production.
Use it when
- Connecting bank accounts with Plaid Link in a web or mobile app
- Importing and keeping transactions in sync with a cursor
- Verifying and handling Plaid webhooks
- Items keep breaking or duplicating after users reconnect
Use something else when
- You are taking card payments rather than reading bank data: use stripe-payments
- You need modelling or forecasting from the data rather than the integration: use financial-modeling
How to check the result
A Sandbox Item goes through Link, exchange, fired webhook and repeat sync without duplicate transactions, and reset_login is repaired by update mode without a new item_id.
The mistake it prevents
Creating a brand-new Item when a login breaks instead of using update mode, which duplicates history and billing.
Done well means: access tokens never reach the browser or logs, transactions sync incrementally from a stored cursor and survive restarts, webhooks are verified before they are trusted, broken logins are repaired through update mode, and the integration only reads data. This skill does not cover moving money (Transfer, payment initiation) or giving financial advice.
Inspect first
- Read
AGENTS.md; search withrg -n "plaid|link_token|access_token" --glob '!node_modules'. - Note the environment (
sandboxorproduction; the olddevelopmentenvironment is gone), the products enabled on the Plaid dashboard, and the countries the app serves. - Find where secrets live.
PLAID_CLIENT_IDandPLAID_SECRETare per environment and server only. - Confirm the data the product needs. Request the fewest products: each one adds consent text, cost and review scope.
Flow
browser: request link_token -> server: /link/token/create
browser: open Link (token) -> user connects bank
browser: onSuccess(public_token) -> server: /item/public_token/exchange -> access_token + item_id
server: store encrypted access_token, then fetch data / wait for webhooks
Server setup (Node)
import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';
export 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, not email
client_name: 'Acme Budget',
products: [Products.Transactions],
country_codes: [CountryCode.Us],
language: 'en',
webhook: 'https://api.acme.example/webhooks/plaid',
redirect_uri: process.env.PLAID_REDIRECT_URI, // required for OAuth banks; must be registered
});
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.items.upsert({
userId, itemId: data.item_id,
accessToken: encrypt(data.access_token), // KMS or app-level envelope encryption
cursor: null, status: 'active',
});
}
Python uses plaid-python with the same request names (LinkTokenCreateRequest,
ItemPublicTokenExchangeRequest). Installing either SDK needs network approval in the sandbox.
Client: Plaid Link
import { usePlaidLink } from 'react-plaid-link';
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess: (public_token, metadata) =>
fetch('/api/plaid/exchange', { method: 'POST', body: JSON.stringify({ public_token }) }),
onExit: (err, metadata) => { if (err) report(err.error_code, metadata.link_session_id); },
});
- OAuth institutions redirect back to
redirect_uri; on that page re-initialise Link with the same link token andreceivedRedirectUri: window.location.href. Store the token in session storage before opening Link. - Log
link_session_idandrequest_idvalues; Plaid support asks for them.
Products and endpoints
| Need | Endpoint | Notes |
|---|---|---|
| Transactions | /transactions/sync | Cursor-based; replaces /transactions/get |
| Account and routing numbers | /auth/get | Show masked numbers only |
| Real-time balance | /accounts/balance/get | Billed per call and slow; cache, do not poll |
| Account holder name, email, address | /identity/get | Personal data; minimise and protect |
| Item health | /item/get | Check error and consent expiry |
Transactions sync
export async function syncItem(itemId: string) {
const item = await db.items.get(itemId);
let cursor = item.cursor ?? undefined;
let added = [], modified = [], removed = [];
for (;;) {
const { data } = await plaid.transactionsSync({ access_token: decrypt(item.accessToken), cursor, count: 500 });
added.push(...data.added); modified.push(...data.modified); removed.push(...data.removed);
cursor = data.next_cursor;
if (!data.has_more) break;
}
await db.tx(async (t) => { // apply all pages and the cursor together
await t.transactions.upsertMany(added.concat(modified)); // keyed by transaction_id
await t.transactions.deleteMany(removed.map((r) => r.transaction_id));
await t.items.update(itemId, { cursor });
});
}
- Trigger sync on the
SYNC_UPDATES_AVAILABLEwebhook (webhook_typeTRANSACTIONS) and once after exchange. Run one sync per item at a time (lock onitem_id). - If a page fails with
TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION, restart from the cursor you stored before the loop. - Pending transactions are replaced by posted ones with a new
transaction_id(pending_transaction_idlinks them); the removal arrives inremoved. - Amounts are positive for money leaving the account; check the sign convention before display.
Webhooks
Verify the Plaid-Verification header before acting:
- Decode the JWT header without verifying; require
alg=ES256and readkid. - Fetch the key with
/webhook_verification_key/get(key_id: kid); cache keys bykid. - Verify the JWT signature with that JWK and reject if
iatis older than 5 minutes. - Compare the claim
request_body_sha256to the SHA-256 of the raw request body (constant time).
Then enqueue and return 200 quickly. Handle at least:
| Webhook | Action |
|---|---|
TRANSACTIONS / SYNC_UPDATES_AVAILABLE | Run syncItem |
ITEM / ERROR with ITEM_LOGIN_REQUIRED | Mark item needs_reauth, prompt the user |
ITEM / PENDING_EXPIRATION or PENDING_DISCONNECT | Prompt the user to re-consent before data stops |
ITEM / USER_PERMISSION_REVOKED / USER_ACCOUNT_REVOKED | Stop syncing, delete data per policy |
ITEM / NEW_ACCOUNTS_AVAILABLE | Offer update mode with account selection |
Update mode
For ITEM_LOGIN_REQUIRED and expiring consent, create a link token with the existing
access_token (and no products), open Link, and on success keep the same access token; no
exchange is needed. Do not create a new Item for the same login; duplicates cost money and split
transaction history.
Removing an Item
/item/remove invalidates the access token permanently and cannot be undone. Call it only when the
user disconnects an account or deletes their profile, after explicit confirmation, and then delete or
anonymise the stored data according to the retention policy.
Sandbox testing
- Link credentials
user_good/pass_good; MFA and error cases have documented test users. - Skip Link in automated tests:
/sandbox/public_token/createwith an institution such asins_109508, then exchange as normal. - Force events with
/sandbox/item/fire_webhook(for exampleSYNC_UPDATES_AVAILABLE) and/sandbox/item/reset_loginto test update mode. - Expose the webhook URL locally with
$webhook-testing-tools.
Verification
- Sandbox Item end to end: Link, exchange, initial sync, fired webhook, second sync with no duplicates.
-
reset_loginthen update mode restores the Item without creating a newitem_id. - A webhook with a modified body or an old
iatis rejected. -
rgover logs and error reports finds noaccess-sandbox-/access-production-strings. - Restarting the worker mid-sync resumes from the stored cursor.
Compliance notes
- Show data use clearly in the UI before Link opens; Plaid’s production access review checks this.
- Encrypt tokens and financial data at rest, restrict access, and document retention and deletion.
- Do not present derived insights as investment, tax or credit advice; regulatory questions belong to the user’s counsel and compliance team.
Output
Report: products and environment, endpoints used, token storage method, sync and webhook design, update-mode handling, and sandbox test results.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Plaid Integration"
short_description: "Bank connections that sync cleanly and stay connected"
default_prompt: "$plaid-integration Let users connect their bank with Plaid and import transactions" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.