git clone https://github.com/vibeforge1111/vibeship-spawner-skills
integrations/plaid-fintech/skill.yamlPlaid Fintech Integration Skill
Bank account linking, transactions, identity, and payments
id: plaid-fintech name: Plaid Fintech version: 1.0.0 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.
category: integrations tags:
- plaid
- fintech
- banking
- payments
- ach
- transactions
- identity
triggers:
- "plaid"
- "bank account linking"
- "bank connection"
- "ach"
- "account aggregation"
- "bank transactions"
- "open banking"
- "fintech"
- "identity verification banking"
Claude already knows: general OAuth patterns, REST APIs
Claude doesn't know well: link_token lifecycle, webhook patterns, item states
patterns:
-
name: "Link Token Creation and Exchange" description: | 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. context:
- "initial bank linking"
- "user onboarding"
- "connecting accounts" example: | // 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<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> );}
-
name: "Transactions Sync" description: | Use /transactions/sync for incremental transaction updates. More efficient than /transactions/get. Handle webhooks for real-time updates instead of polling. context:
- "fetching transactions"
- "transaction history"
- "account activity" example: | // 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);});
-
name: "Item Error Handling and Update Mode" description: | Handle ITEM_LOGIN_REQUIRED errors by putting users through Link update mode. Listen for PENDING_DISCONNECT webhook to proactively prompt users. context:
-
"error recovery"
-
"reauthorization"
-
"credential updates" example: | // 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;}
-
-
name: "Auth for ACH Transfers" description: | Use Auth product to get account and routing numbers for ACH transfers. Combine with Identity to verify account ownership before initiating transfers. context:
-
"ach transfers"
-
"money movement"
-
"account funding" example: | // 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.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, });}
-
-
name: "Real-Time Balance Check" description: | Use /accounts/balance/get for real-time balance (paid endpoint). /accounts/get returns cached data suitable for display but not real-time decisions. context:
- "balance checking"
- "fund availability"
- "payment validation" example: | 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, };}
-
name: "Webhook Verification" description: | Verify Plaid webhooks using the verification key endpoint. Handle duplicate webhooks idempotently and design for out-of-order delivery. context:
- "webhook security"
- "event processing"
- "production deployment" example: | 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);});
anti_patterns:
-
name: "Storing Access Tokens in Plain Text" description: "Access tokens grant full account access and must be encrypted" example: | // WRONG: Plain text storage await db.plaidItem.create({ data: { accessToken: access_token, // Stored unencrypted! } });
// RIGHT: Encrypt at rest await db.plaidItem.create({ data: { accessToken: await encrypt(access_token), } });
-
name: "Polling Instead of Webhooks" description: "Webhooks are more efficient than constant polling" example: | // WRONG: Polling for transactions setInterval(async () => { for (const item of items) { await syncTransactions(item.accessToken); } }, 60000); // Every minute!
// RIGHT: Use webhooks // Configure webhook URL in link_token/create // Process SYNC_UPDATES_AVAILABLE webhook
-
name: "Ignoring Item Errors" description: "Item errors require user action through update mode" example: | // WRONG: Retry on ITEM_LOGIN_REQUIRED try { await plaidClient.transactionsSync({ access_token }); } catch (error) { // Just retry later? await scheduleRetry(itemId); }
// RIGHT: Handle specific error try { await plaidClient.transactionsSync({ access_token }); } catch (error) { if (error.response?.data?.error_code === 'ITEM_LOGIN_REQUIRED') { await markItemForUpdate(itemId); await notifyUserReconnect(itemId); } }
-
name: "Using Public Key Instead of Link Token" description: "Public key integration ended January 2025" example: | // WRONG: Deprecated public key <PlaidLink publicKey={process.env.PLAID_PUBLIC_KEY} // Deprecated! />
// RIGHT: Use link token const { link_token } = await createLinkToken(); <PlaidLink token={link_token} />
references:
- title: "Plaid API Documentation" url: "https://plaid.com/docs/"
- title: "Plaid Link Overview" url: "https://plaid.com/docs/link/"
- title: "Transactions API" url: "https://plaid.com/docs/api/products/transactions/"
- title: "Webhooks Reference" url: "https://plaid.com/docs/api/webhooks/"
- title: "Launch Checklist" url: "https://plaid.com/docs/launch-checklist/"