Vibeship-spawner-skills plaid-fintech

Plaid Fintech Integration Skill

install
source · Clone the upstream repo
git clone https://github.com/vibeforge1111/vibeship-spawner-skills
manifest: integrations/plaid-fintech/skill.yaml
source content

Plaid 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: