Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Expert patterns for Plaid API integration including Link token flows, transactions sync, identity verification, Auth for ACH, balance checks, webhook handling, and fintech compliance best practices.
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-05 | ✗→✓ | ▲ Improved | 223% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 1247% | 0% |
| case-11 | ✗→✓ | ▲ Improved | 214% | 0% |
| case-18 | ✗→✓ | ▲ Improved | 220% | 0% |
| case-01 | ✓→✓ | = Same ✓ | 299% | 0% |
Expert patterns for Plaid API integration including Link token flows, transactions sync, identity verification, Auth for ACH, balance checks, webhook handling, and fintech compliance best practices.
Create a link_token for Plaid Link, exchange public_token for access_token. Link tokens are short-lived, one-time use. Access tokens don't expire but may need updating when users change passwords.
// server.ts - Link token creation endpoint import { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';
const configuration = new Configuration({ basePath: PlaidEnvironmentsprocess.env.PLAID_ENV || 'sandbox'], baseOptions: { headers: { 'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID, 'PLAID-SECRET': process.env.PLAID_SECRET, }, }, });
const plaidClient = new PlaidApi(configuration);
// Create link token for new user app.post('/api/plaid/create-link-token', async (req, res) => { const { userId } = req.body;
try { const response = await plaidClient.linkTokenCreate({ user: { client_user_id: userId, // Your internal user ID }, client_name: 'My Finance App', products: Products.Transactions], country_codes: CountryCode.Us], language: 'en', webhook: 'https://yourapp.com/api/plaid/webhooks', // Request 180 days for recurring transactions transactions: { days_requested: 180, }, });
res.json({ link_token: response.data.link_token }); } catch (error) { console.error('Link token creation failed:', error); res.status(500).json({ error: 'Failed to create link token' }); } });
// Exchange public token for access token app.post('/api/plaid/exchange-token', async (req, res) => { const { publicToken, userId } = req.body;
try { // Exchange for permanent access token const exchangeResponse = await plaidClient.itemPublicTokenExchange({ public_token: publicToken, });
const { access_token, item_id } = exchangeResponse.data;
// Store securely - access_token doesn't expire! await db.plaidItem.create({ data: { userId, itemId: item_id, accessToken: await encrypt(access_token), // Encrypt at rest status: 'ACTIVE', products: 'transactions'], }, });
// Trigger initial transaction sync await initiateTransactionSync(item_id, access_token);
res.json({ success: true, itemId: item_id }); } catch (error) { console.error('Token exchange failed:', error); res.status(500).json({ error: 'Failed to exchange token' }); } });
// Frontend - React component import { usePlaidLink } from 'react-plaid-link';
function BankLinkButton({ userId }: { userId: string }) { const linkToken, setLinkToken] = useState<string | null>(null);
useEffect(() => { async function createLinkToken() { const response = await fetch('/api/plaid/create-link-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId }), }); const { link_token } = await response.json(); setLinkToken(link_token); } createLinkToken(); }, userId]);
const { open, ready } = usePlaidLink({ token: linkToken, onSuccess: async (publicToken, metadata) => { // Exchange public token for access token await fetch('/api/plaid/exchange-token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ publicToken, userId }), }); }, onExit: (error, metadata) => { if (error) { console.error('Link exit error:', error); } }, });
return ( <button onClick={() => open()} disabled={!ready}> Connect Bank Account </button> ); }
Use /transactions/sync for incremental transaction updates. More efficient than /transactions/get. Handle webhooks for real-time updates instead of polling.
// Transactions sync service interface TransactionSyncState { cursor: string | null; hasMore: boolean; }
async function syncTransactions( accessToken: string, itemId: string ): Promise<void> { // Get last cursor from database const item = await db.plaidItem.findUnique({ where: { itemId }, });
let cursor = item?.transactionsCursor || null; let hasMore = true; let addedCount = 0; let modifiedCount = 0; let removedCount = 0;
while (hasMore) { try { const response = await plaidClient.transactionsSync({ access_token: accessToken, cursor: cursor || undefined, count: 500, // Max per request });
const { added, modified, removed, next_cursor, has_more } = response.data;
// Process added transactions if (added.length > 0) { await db.transaction.createMany({ data: added.map(txn => ({ plaidTransactionId: txn.transaction_id, itemId, accountId: txn.account_id, amount: txn.amount, date: new Date(txn.date), name: txn.name, merchantName: txn.merchant_name, category: txn.personal_finance_category?.primary, subcategory: txn.personal_finance_category?.detailed, pending: txn.pending, paymentChannel: txn.payment_channel, location: txn.location ? JSON.stringify(txn.location) : null, })), skipDuplicates: true, }); addedCount += added.length; }
// Process modified transactions for (const txn of modified) { await db.transaction.updateMany({ where: { plaidTransactionId: txn.transaction_id }, data: { amount: txn.amount, name: txn.name, merchantName: txn.merchant_name, pending: txn.pending, updatedAt: new Date(), }, }); modifiedCount++; }
// Process removed transactions if (removed.length > 0) { await db.transaction.deleteMany({ where: { plaidTransactionId: { in: removed.map(r => r.transaction_id), }, }, }); removedCount += removed.length; }
cursor = next_cursor; hasMore = has_more;
} catch (error: any) { if (error.response?.data?.error_code === 'TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION') { // Data changed during pagination, restart from null cursor = null; continue; } throw error; } }
// Save cursor for next sync await db.plaidItem.update({ where: { itemId }, data: { transactionsCursor: cursor }, });
console.log(Sync complete: +${addedCount} ~${modifiedCount} -${removedCount}); }
// Webhook handler for real-time updates app.post('/api/plaid/webhooks', async (req, res) => { const { webhook_type, webhook_code, item_id } = req.body;
// Verify webhook (see webhook verification pattern) if (!verifyPlaidWebhook(req)) { return res.status(401).send('Invalid webhook'); }
if (webhook_type === 'TRANSACTIONS') { switch (webhook_code) { case 'SYNC_UPDATES_AVAILABLE': // New transactions available, trigger sync await queueTransactionSync(item_id); break; case 'INITIAL_UPDATE': // Initial batch of transactions ready await queueTransactionSync(item_id); break; case 'HISTORICAL_UPDATE': // Historical transactions ready await queueTransactionSync(item_id); break; } }
res.sendStatus(200); });
Handle ITEM_LOGIN_REQUIRED errors by putting users through Link update mode. Listen for PENDING_DISCONNECT webhook to proactively prompt users.
// Create link token for update mode app.post('/api/plaid/create-update-token', async (req, res) => { const { itemId } = req.body;
const item = await db.plaidItem.findUnique({ where: { itemId }, include: { user: true }, });
if (!item) { return res.status(404).json({ error: 'Item not found' }); }
try { const response = await plaidClient.linkTokenCreate({ user: { client_user_id: item.userId, }, client_name: 'My Finance App', country_codes: CountryCode.Us], language: 'en', webhook: 'https://yourapp.com/api/plaid/webhooks', // Update mode: provide access_token instead of products access_token: await decrypt(item.accessToken), });
res.json({ link_token: response.data.link_token }); } catch (error) { console.error('Update token creation failed:', error); res.status(500).json({ error: 'Failed to create update token' }); } });
// Handle item errors from webhooks app.post('/api/plaid/webhooks', async (req, res) => { const { webhook_type, webhook_code, item_id, error } = req.body;
if (webhook_type === 'ITEM') { switch (webhook_code) { case 'ERROR': // Item has entered an error state await db.plaidItem.update({ where: { itemId: item_id }, data: { status: 'ERROR', errorCode: error?.error_code, errorMessage: error?.error_message, }, });
// Notify user to reconnect if (error?.error_code === 'ITEM_LOGIN_REQUIRED') { await notifyUserReconnect(item_id, 'Please reconnect your bank account'); } break;
case 'PENDING_DISCONNECT': // User needs to reauthorize soon await db.plaidItem.update({ where: { itemId: item_id }, data: { status: 'PENDING_DISCONNECT' }, });
// Proactive notification await notifyUserReconnect(item_id, 'Your bank connection will expire soon'); break;
case 'USER_PERMISSION_REVOKED': // User revoked access at their bank await db.plaidItem.update({ where: { itemId: item_id }, data: { status: 'REVOKED' }, });
// Clean up stored data await db.transaction.deleteMany({ where: { itemId: item_id }, }); break; } }
res.sendStatus(200); });
// Check item status before API calls async function getItemWithValidation(itemId: string) { const item = await db.plaidItem.findUnique({ where: { itemId }, });
if (!item) { throw new Error('Item not found'); }
if (item.status === 'ERROR') { throw new ItemNeedsUpdateError(item.errorCode, item.errorMessage); }
return item; }
Use Auth product to get account and routing numbers for ACH transfers. Combine with Identity to verify account ownership before initiating transfers.
// Get account and routing numbers async function getACHNumbers(accessToken: string): Promise<ACHInfo]> { const response = await plaidClient.authGet({ access_token: accessToken, });
const { accounts, numbers } = response.data;
// Map ACH numbers to accounts return accounts.map(account => { const achNumber = numbers.ach.find( n => n.account_id === account.account_id );
return { accountId: account.account_id, name: account.name, mask: account.mask, type: account.type, subtype: account.subtype, routing: achNumber?.routing, account: achNumber?.account, wireRouting: achNumber?.wire_routing, }; }); }
// Verify identity before ACH transfer async function verifyAndInitiateTransfer( accessToken: string, userId: string, amount: number ): Promise<TransferResult> { // Get identity from linked account const identityResponse = await plaidClient.identityGet({ access_token: accessToken, });
const accountOwners = identityResponse.data.accounts0]?.owners || ];
// Get user's stored identity const user = await db.user.findUnique({ where: { id: userId }, });
// Match identity const matchResponse = await plaidClient.identityMatch({ access_token: accessToken, user: { legal_name: user.legalName, phone_number: user.phoneNumber, email_address: user.email, address: { street: user.street, city: user.city, region: user.state, postal_code: user.postalCode, country: 'US', }, }, });
const matchScores = matchResponse.data.accounts0]?.legal_name;
// Require high confidence for transfers if ((matchScores?.score || 0) < 70) { throw new Error('Identity verification failed'); }
// Get real-time balance for the transfer const balanceResponse = await plaidClient.accountsBalanceGet({ access_token: accessToken, });
const account = balanceResponse.data.accounts0];
// Check sufficient funds (consider pending) const availableBalance = account.balances.available ?? account.balances.current; if (availableBalance < amount) { throw new Error('Insufficient funds'); }
// Get ACH numbers and initiate transfer const authResponse = await plaidClient.authGet({ access_token: accessToken, });
const achNumbers = authResponse.data.numbers.ach.find( n => n.account_id === account.account_id );
// Initiate ACH transfer with your payment processor return await initiateACHTransfer({ routingNumber: achNumbers.routing, accountNumber: achNumbers.account, amount, accountType: account.subtype, }); }
Use /accounts/balance/get for real-time balance (paid endpoint). /accounts/get returns cached data suitable for display but not real-time decisions.
interface BalanceInfo { accountId: string; available: number | null; current: number; limit: number | null; isoCurrencyCode: string; lastUpdated: Date; isRealtime: boolean; }
// Get cached balance (free, suitable for display) async function getCachedBalances(accessToken: string): Promise<BalanceInfo]> { const response = await plaidClient.accountsGet({ access_token: accessToken, });
return response.data.accounts.map(account => ({ accountId: account.account_id, available: account.balances.available, current: account.balances.current, limit: account.balances.limit, isoCurrencyCode: account.balances.iso_currency_code || 'USD', lastUpdated: new Date(account.balances.last_updated_datetime || Date.now()), isRealtime: false, })); }
// Get real-time balance (paid, for payment validation) async function getRealTimeBalance( accessToken: string, accountIds?: string] ): Promise<BalanceInfo]> { const response = await plaidClient.accountsBalanceGet({ access_token: accessToken, options: accountIds ? { account_ids: accountIds } : undefined, });
return response.data.accounts.map(account => ({ accountId: account.account_id, available: account.balances.available, current: account.balances.current, limit: account.balances.limit, isoCurrencyCode: account.balances.iso_currency_code || 'USD', lastUpdated: new Date(), isRealtime: true, })); }
// Payment validation with balance check async function validatePayment( accessToken: string, accountId: string, amount: number ): Promise<PaymentValidation> { const balances = await getRealTimeBalance(accessToken, accountId]); const account = balances.find(b => b.accountId === accountId);
if (!account) { return { valid: false, reason: 'Account not found' }; }
const available = account.available ?? account.current;
if (available < amount) { return { valid: false, reason: 'Insufficient funds', available, requested: amount, }; }
return { valid: true, available, requested: amount, }; }
Verify Plaid webhooks using the verification key endpoint. Handle duplicate webhooks idempotently and design for out-of-order delivery.
import jwt from 'jsonwebtoken'; import jwksClient from 'jwks-rsa';
// Cache JWKS client const client = jwksClient({ jwksUri: 'https://production.plaid.com/.well-known/jwks.json', cache: true, cacheMaxAge: 86400000, // 24 hours });
async function getSigningKey(kid: string): Promise<string> { const key = await client.getSigningKey(kid); return key.getPublicKey(); }
async function verifyPlaidWebhook(req: Request): Promise<boolean> { const signedJwt = req.headers'plaid-verification'];
if (!signedJwt) { return false; }
try { // Decode to get kid const decoded = jwt.decode(signedJwt, { complete: true }); if (!decoded?.header?.kid) { return false; }
// Get signing key const key = await getSigningKey(decoded.header.kid);
// Verify JWT const claims = jwt.verify(signedJwt, key, { algorithms: 'ES256'], }) as any;
// Verify body hash const bodyHash = crypto .createHash('sha256') .update(JSON.stringify(req.body)) .digest('hex');
if (claims.request_body_sha256 !== bodyHash) { return false; }
// Check timestamp (within 5 minutes) const issuedAt = new Date(claims.iat 1000); const fiveMinutesAgo = new Date(Date.now() - 5 60 1000); if (issuedAt < fiveMinutesAgo) { return false; }
return true; } catch (error) { console.error('Webhook verification failed:', error); return false; } }
// Idempotent webhook handler app.post('/api/plaid/webhooks', async (req, res) => { // Verify webhook signature if (!await verifyPlaidWebhook(req)) { return res.status(401).send('Invalid signature'); }
const { webhook_type, webhook_code, item_id } = req.body;
// Create idempotency key const idempotencyKey = ${webhook_type}:${webhook_code}:${item_id}:${JSON.stringify(req.body)}; const idempotencyHash = crypto.createHash('sha256').update(idempotencyKey).digest('hex');
// Check if already processed const existing = await db.webhookLog.findUnique({ where: { idempotencyHash }, });
if (existing) { console.log('Duplicate webhook, skipping:', idempotencyHash); return res.sendStatus(200); }
// Record webhook before processing await db.webhookLog.create({ data: { idempotencyHash, webhookType: webhook_type, webhookCode: webhook_code, itemId: item_id, payload: req.body, processedAt: new Date(), }, });
// Process webhook (async for quick response) processWebhookAsync(req.body).catch(console.error);
res.sendStatus(200); });
Severity: CRITICAL
Severity: HIGH
Severity: HIGH
Severity: HIGH
Severity: MEDIUM
Severity: MEDIUM
Severity: MEDIUM
Severity: MEDIUM
Severity: ERROR
Plaid access tokens must be encrypted at rest
Message: Plaid access token appears to be stored unencrypted. Encrypt at rest.
Severity: ERROR
Plaid secret must never be exposed to clients
Message: Plaid secret may be exposed. Keep server-side only.
Severity: ERROR
Credentials must use environment variables
Message: Hardcoded Plaid credentials. Use environment variables.
Severity: ERROR
Plaid webhooks must verify JWT signature
Message: Webhook handler without signature verification. Verify Plaid-Verification header.
Severity: ERROR
Use real-time balance for payment validation
Message: Using accountsGet (cached) for payment. Use accountsBalanceGet for real-time balance.
Severity: WARNING
API calls should handle ITEM_LOGIN_REQUIRED
Message: API call without ITEM_LOGIN_REQUIRED handling. Handle item error states.
Severity: WARNING
Use webhooks for transaction updates
Message: Polling for transactions. Configure webhooks for SYNC_UPDATES_AVAILABLE.
Severity: WARNING
Link tokens are single-use and expire in 4 hours
Message: Link tokens should not be cached. Create fresh token for each session.
Severity: ERROR
Public key integration ended January 2025
Message: Public key is deprecated. Use Link tokens instead.
Severity: WARNING
Store cursor for incremental syncs
Message: Transaction sync without cursor persistence. Store cursor for incremental sync.
Other measured skills in the registry, with their headline benchmark lift.