--- name: plaid-fintech description: 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. risk: unknown source: vibeship-spawner-skills (Apache 2.0) date_added: 2026-02-27 --- # Plaid Fintech 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. ## Patterns ### Link Token Creation and Exchange 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: PlaidEnvironments[process.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(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 ( ); } ### Context - initial bank linking - user onboarding - connecting accounts ### Transactions Sync 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 { // 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); }); ### Context - fetching transactions - transaction history - account activity ### Item Error Handling and Update Mode 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; } ### Context - error recovery - reauthorization - credential updates ### Auth for ACH Transfers 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 { 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 { // Get identity from linked account const identityResponse = await plaidClient.identityGet({ access_token: accessToken, }); const accountOwners = identityResponse.data.accounts[0]?.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.accounts[0]?.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.accounts[0]; // 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, }); } ### Context - ach transfers - money movement - account funding ### Real-Time Balance Check 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 { 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 { 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 { 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, }; } ### Context - balance checking - fund availability - payment validation ### Webhook Verification 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 { const key = await client.getSigningKey(kid); return key.getPublicKey(); } async function verifyPlaidWebhook(req: Request): Promise { 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); }); ### Context - webhook security - event processing - production deployment ## Sharp Edges ### Access Tokens Never Expire But Are Highly Sensitive Severity: CRITICAL ### accounts/get Returns Cached Balances, Not Real-Time Severity: HIGH ### Webhooks May Arrive Out of Order or Duplicated Severity: HIGH ### Items Enter Error States That Require User Action Severity: HIGH ### Sandbox Does Not Reflect Production Complexity Severity: MEDIUM ### TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION Requires Restart Severity: MEDIUM ### Link Tokens Are Short-Lived and Single-Use Severity: MEDIUM ### Recurring Transactions Need 180+ Days of History Severity: MEDIUM ## Validation Checks ### Access Token Stored in Plain Text Severity: ERROR Plaid access tokens must be encrypted at rest Message: Plaid access token appears to be stored unencrypted. Encrypt at rest. ### Plaid Secret in Client Code Severity: ERROR Plaid secret must never be exposed to clients Message: Plaid secret may be exposed. Keep server-side only. ### Hardcoded Plaid Credentials Severity: ERROR Credentials must use environment variables Message: Hardcoded Plaid credentials. Use environment variables. ### Missing Webhook Signature Verification Severity: ERROR Plaid webhooks must verify JWT signature Message: Webhook handler without signature verification. Verify Plaid-Verification header. ### Using Cached Balance for Payment Decision Severity: ERROR Use real-time balance for payment validation Message: Using accountsGet (cached) for payment. Use accountsBalanceGet for real-time balance. ### Missing Item Error State Handling Severity: WARNING API calls should handle ITEM_LOGIN_REQUIRED Message: API call without ITEM_LOGIN_REQUIRED handling. Handle item error states. ### Polling for Transactions Instead of Webhooks Severity: WARNING Use webhooks for transaction updates Message: Polling for transactions. Configure webhooks for SYNC_UPDATES_AVAILABLE. ### Link Token Cached or Reused 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. ### Using Deprecated Public Key Severity: ERROR Public key integration ended January 2025 Message: Public key is deprecated. Use Link tokens instead. ### Transaction Sync Without Cursor Storage Severity: WARNING Store cursor for incremental syncs Message: Transaction sync without cursor persistence. Store cursor for incremental sync. ## Collaboration ### Delegation Triggers - user needs payment processing -> stripe-integration (Stripe for actual payment, Plaid for account linking) - user needs budgeting features -> analytics-specialist (Transaction categorization and analysis) - user needs investment tracking -> data-engineer (Portfolio analysis and reporting) - user needs compliance/audit -> security-specialist (SOC 2, PCI compliance) - user needs mobile app -> mobile-developer (React Native Plaid SDK) ## When to Use - User mentions or implies: plaid - User mentions or implies: bank account linking - User mentions or implies: bank connection - User mentions or implies: ach - User mentions or implies: account aggregation - User mentions or implies: bank transactions - User mentions or implies: open banking - User mentions or implies: fintech - User mentions or implies: identity verification banking ## Limitations - Use this skill only when the task clearly matches the scope described above. - Do not treat the output as a substitute for environment-specific validation, testing, or expert review. - Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.