[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M719GET3TVPRN0ZFGHCB84KB":3,"skill-same:01M719GET3TVPRN0ZFGHCB84KB":66},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":39,"linkOnly":40,"markdownContent":41,"markdownBody":42,"frontMatter":43,"refName":46,"commitSha":47,"skillDirectory":48,"forksCount":14,"repositoryHtmlUrl":49,"repositoryHomepageUrl":6,"ownerHtmlUrl":50,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":51,"install":52,"resources":65},"01M719GET3TVPRN0ZFGHCB84KB","plaid-fintech",null,"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.","sapsapshen\u002Fai-memory-router","sapsapshen","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F20347959?v=4","01M6XDQEF1DP50DKF37H81J3J5","shenmian","01M6XDQEJPE41Y3DT2763X8ZJN",0,"MIT","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fglobal\u002Fplaid-fintech\u002FSKILL.md",[],[19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"fintech","plaid fintech","plaid","2026","auth","spawner","unknown","for","balance","flows","webhook","token","patterns","expert","transactions","api","apache","verification","identity","sync","2026-11-10 10:12:30",false,"---\nname: plaid-fintech\ndescription: Expert patterns for Plaid API integration including Link token\n  flows, transactions sync, identity verification, Auth for ACH, balance checks,\n  webhook handling, and fintech compliance best practices.\nrisk: unknown\nsource: vibeship-spawner-skills (Apache 2.0)\ndate_added: 2026-02-27\n---\n\n# Plaid Fintech\n\nExpert patterns for Plaid API integration including Link token flows,\ntransactions sync, identity verification, Auth for ACH, balance checks,\nwebhook handling, and fintech compliance best practices.\n\n## Patterns\n\n### Link Token Creation and Exchange\n\nCreate a link_token for Plaid Link, exchange public_token for access_token.\nLink tokens are short-lived, one-time use. Access tokens don't expire but\nmay need updating when users change passwords.\n\n\u002F\u002F server.ts - Link token creation endpoint\nimport { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';\n\nconst configuration = new Configuration({\n  basePath: PlaidEnvironments[process.env.PLAID_ENV || 'sandbox'],\n  baseOptions: {\n    headers: {\n      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,\n      'PLAID-SECRET': process.env.PLAID_SECRET,\n    },\n  },\n});\n\nconst plaidClient = new PlaidApi(configuration);\n\n\u002F\u002F Create link token for new user\napp.post('\u002Fapi\u002Fplaid\u002Fcreate-link-token', async (req, res) => {\n  const { userId } = req.body;\n\n  try {\n    const response = await plaidClient.linkTokenCreate({\n      user: {\n        client_user_id: userId,  \u002F\u002F Your internal user ID\n      },\n      client_name: 'My Finance App',\n      products: [Products.Transactions],\n      country_codes: [CountryCode.Us],\n      language: 'en',\n      webhook: 'https:\u002F\u002Fyourapp.com\u002Fapi\u002Fplaid\u002Fwebhooks',\n      \u002F\u002F Request 180 days for recurring transactions\n      transactions: {\n        days_requested: 180,\n      },\n    });\n\n    res.json({ link_token: response.data.link_token });\n  } catch (error) {\n    console.error('Link token creation failed:', error);\n    res.status(500).json({ error: 'Failed to create link token' });\n  }\n});\n\n\u002F\u002F Exchange public token for access token\napp.post('\u002Fapi\u002Fplaid\u002Fexchange-token', async (req, res) => {\n  const { publicToken, userId } = req.body;\n\n  try {\n    \u002F\u002F Exchange for permanent access token\n    const exchangeResponse = await plaidClient.itemPublicTokenExchange({\n      public_token: publicToken,\n    });\n\n    const { access_token, item_id } = exchangeResponse.data;\n\n    \u002F\u002F Store securely - access_token doesn't expire!\n    await db.plaidItem.create({\n      data: {\n        userId,\n        itemId: item_id,\n        accessToken: await encrypt(access_token),  \u002F\u002F Encrypt at rest\n        status: 'ACTIVE',\n        products: ['transactions'],\n      },\n    });\n\n    \u002F\u002F Trigger initial transaction sync\n    await initiateTransactionSync(item_id, access_token);\n\n    res.json({ success: true, itemId: item_id });\n  } catch (error) {\n    console.error('Token exchange failed:', error);\n    res.status(500).json({ error: 'Failed to exchange token' });\n  }\n});\n\n\u002F\u002F Frontend - React component\nimport { usePlaidLink } from 'react-plaid-link';\n\nfunction BankLinkButton({ userId }: { userId: string }) {\n  const [linkToken, setLinkToken] = useState\u003Cstring | null>(null);\n\n  useEffect(() => {\n    async function createLinkToken() {\n      const response = await fetch('\u002Fapi\u002Fplaid\u002Fcreate-link-token', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ userId }),\n      });\n      const { link_token } = await response.json();\n      setLinkToken(link_token);\n    }\n    createLinkToken();\n  }, [userId]);\n\n  const { open, ready } = usePlaidLink({\n    token: linkToken,\n    onSuccess: async (publicToken, metadata) => {\n      \u002F\u002F Exchange public token for access token\n      await fetch('\u002Fapi\u002Fplaid\u002Fexchange-token', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ publicToken, userId }),\n      });\n    },\n    onExit: (error, metadata) => {\n      if (error) {\n        console.error('Link exit error:', error);\n      }\n    },\n  });\n\n  return (\n    \u003Cbutton onClick={() => open()} disabled={!ready}>\n      Connect Bank Account\n    \u003C\u002Fbutton>\n  );\n}\n\n### Context\n\n- initial bank linking\n- user onboarding\n- connecting accounts\n\n### Transactions Sync\n\nUse \u002Ftransactions\u002Fsync for incremental transaction updates. More efficient\nthan \u002Ftransactions\u002Fget. Handle webhooks for real-time updates instead of\npolling.\n\n\u002F\u002F Transactions sync service\ninterface TransactionSyncState {\n  cursor: string | null;\n  hasMore: boolean;\n}\n\nasync function syncTransactions(\n  accessToken: string,\n  itemId: string\n): Promise\u003Cvoid> {\n  \u002F\u002F Get last cursor from database\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n  });\n\n  let cursor = item?.transactionsCursor || null;\n  let hasMore = true;\n  let addedCount = 0;\n  let modifiedCount = 0;\n  let removedCount = 0;\n\n  while (hasMore) {\n    try {\n      const response = await plaidClient.transactionsSync({\n        access_token: accessToken,\n        cursor: cursor || undefined,\n        count: 500,  \u002F\u002F Max per request\n      });\n\n      const { added, modified, removed, next_cursor, has_more } = response.data;\n\n      \u002F\u002F Process added transactions\n      if (added.length > 0) {\n        await db.transaction.createMany({\n          data: added.map(txn => ({\n            plaidTransactionId: txn.transaction_id,\n            itemId,\n            accountId: txn.account_id,\n            amount: txn.amount,\n            date: new Date(txn.date),\n            name: txn.name,\n            merchantName: txn.merchant_name,\n            category: txn.personal_finance_category?.primary,\n            subcategory: txn.personal_finance_category?.detailed,\n            pending: txn.pending,\n            paymentChannel: txn.payment_channel,\n            location: txn.location ? JSON.stringify(txn.location) : null,\n          })),\n          skipDuplicates: true,\n        });\n        addedCount += added.length;\n      }\n\n      \u002F\u002F Process modified transactions\n      for (const txn of modified) {\n        await db.transaction.updateMany({\n          where: { plaidTransactionId: txn.transaction_id },\n          data: {\n            amount: txn.amount,\n            name: txn.name,\n            merchantName: txn.merchant_name,\n            pending: txn.pending,\n            updatedAt: new Date(),\n          },\n        });\n        modifiedCount++;\n      }\n\n      \u002F\u002F Process removed transactions\n      if (removed.length > 0) {\n        await db.transaction.deleteMany({\n          where: {\n            plaidTransactionId: {\n              in: removed.map(r => r.transaction_id),\n            },\n          },\n        });\n        removedCount += removed.length;\n      }\n\n      cursor = next_cursor;\n      hasMore = has_more;\n\n    } catch (error: any) {\n      if (error.response?.data?.error_code === 'TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION') {\n        \u002F\u002F Data changed during pagination, restart from null\n        cursor = null;\n        continue;\n      }\n      throw error;\n    }\n  }\n\n  \u002F\u002F Save cursor for next sync\n  await db.plaidItem.update({\n    where: { itemId },\n    data: { transactionsCursor: cursor },\n  });\n\n  console.log(`Sync complete: +${addedCount} ~${modifiedCount} -${removedCount}`);\n}\n\n\u002F\u002F Webhook handler for real-time updates\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  const { webhook_type, webhook_code, item_id } = req.body;\n\n  \u002F\u002F Verify webhook (see webhook verification pattern)\n  if (!verifyPlaidWebhook(req)) {\n    return res.status(401).send('Invalid webhook');\n  }\n\n  if (webhook_type === 'TRANSACTIONS') {\n    switch (webhook_code) {\n      case 'SYNC_UPDATES_AVAILABLE':\n        \u002F\u002F New transactions available, trigger sync\n        await queueTransactionSync(item_id);\n        break;\n      case 'INITIAL_UPDATE':\n        \u002F\u002F Initial batch of transactions ready\n        await queueTransactionSync(item_id);\n        break;\n      case 'HISTORICAL_UPDATE':\n        \u002F\u002F Historical transactions ready\n        await queueTransactionSync(item_id);\n        break;\n    }\n  }\n\n  res.sendStatus(200);\n});\n\n### Context\n\n- fetching transactions\n- transaction history\n- account activity\n\n### Item Error Handling and Update Mode\n\nHandle ITEM_LOGIN_REQUIRED errors by putting users through Link update mode.\nListen for PENDING_DISCONNECT webhook to proactively prompt users.\n\n\u002F\u002F Create link token for update mode\napp.post('\u002Fapi\u002Fplaid\u002Fcreate-update-token', async (req, res) => {\n  const { itemId } = req.body;\n\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n    include: { user: true },\n  });\n\n  if (!item) {\n    return res.status(404).json({ error: 'Item not found' });\n  }\n\n  try {\n    const response = await plaidClient.linkTokenCreate({\n      user: {\n        client_user_id: item.userId,\n      },\n      client_name: 'My Finance App',\n      country_codes: [CountryCode.Us],\n      language: 'en',\n      webhook: 'https:\u002F\u002Fyourapp.com\u002Fapi\u002Fplaid\u002Fwebhooks',\n      \u002F\u002F Update mode: provide access_token instead of products\n      access_token: await decrypt(item.accessToken),\n    });\n\n    res.json({ link_token: response.data.link_token });\n  } catch (error) {\n    console.error('Update token creation failed:', error);\n    res.status(500).json({ error: 'Failed to create update token' });\n  }\n});\n\n\u002F\u002F Handle item errors from webhooks\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  const { webhook_type, webhook_code, item_id, error } = req.body;\n\n  if (webhook_type === 'ITEM') {\n    switch (webhook_code) {\n      case 'ERROR':\n        \u002F\u002F Item has entered an error state\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: {\n            status: 'ERROR',\n            errorCode: error?.error_code,\n            errorMessage: error?.error_message,\n          },\n        });\n\n        \u002F\u002F Notify user to reconnect\n        if (error?.error_code === 'ITEM_LOGIN_REQUIRED') {\n          await notifyUserReconnect(item_id, 'Please reconnect your bank account');\n        }\n        break;\n\n      case 'PENDING_DISCONNECT':\n        \u002F\u002F User needs to reauthorize soon\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: { status: 'PENDING_DISCONNECT' },\n        });\n\n        \u002F\u002F Proactive notification\n        await notifyUserReconnect(item_id, 'Your bank connection will expire soon');\n        break;\n\n      case 'USER_PERMISSION_REVOKED':\n        \u002F\u002F User revoked access at their bank\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: { status: 'REVOKED' },\n        });\n\n        \u002F\u002F Clean up stored data\n        await db.transaction.deleteMany({\n          where: { itemId: item_id },\n        });\n        break;\n    }\n  }\n\n  res.sendStatus(200);\n});\n\n\u002F\u002F Check item status before API calls\nasync function getItemWithValidation(itemId: string) {\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n  });\n\n  if (!item) {\n    throw new Error('Item not found');\n  }\n\n  if (item.status === 'ERROR') {\n    throw new ItemNeedsUpdateError(item.errorCode, item.errorMessage);\n  }\n\n  return item;\n}\n\n### Context\n\n- error recovery\n- reauthorization\n- credential updates\n\n### Auth for ACH Transfers\n\nUse Auth product to get account and routing numbers for ACH transfers.\nCombine with Identity to verify account ownership before initiating\ntransfers.\n\n\u002F\u002F Get account and routing numbers\nasync function getACHNumbers(accessToken: string): Promise\u003CACHInfo[]> {\n  const response = await plaidClient.authGet({\n    access_token: accessToken,\n  });\n\n  const { accounts, numbers } = response.data;\n\n  \u002F\u002F Map ACH numbers to accounts\n  return accounts.map(account => {\n    const achNumber = numbers.ach.find(\n      n => n.account_id === account.account_id\n    );\n\n    return {\n      accountId: account.account_id,\n      name: account.name,\n      mask: account.mask,\n      type: account.type,\n      subtype: account.subtype,\n      routing: achNumber?.routing,\n      account: achNumber?.account,\n      wireRouting: achNumber?.wire_routing,\n    };\n  });\n}\n\n\u002F\u002F Verify identity before ACH transfer\nasync function verifyAndInitiateTransfer(\n  accessToken: string,\n  userId: string,\n  amount: number\n): Promise\u003CTransferResult> {\n  \u002F\u002F Get identity from linked account\n  const identityResponse = await plaidClient.identityGet({\n    access_token: accessToken,\n  });\n\n  const accountOwners = identityResponse.data.accounts[0]?.owners || [];\n\n  \u002F\u002F Get user's stored identity\n  const user = await db.user.findUnique({\n    where: { id: userId },\n  });\n\n  \u002F\u002F Match identity\n  const matchResponse = await plaidClient.identityMatch({\n    access_token: accessToken,\n    user: {\n      legal_name: user.legalName,\n      phone_number: user.phoneNumber,\n      email_address: user.email,\n      address: {\n        street: user.street,\n        city: user.city,\n        region: user.state,\n        postal_code: user.postalCode,\n        country: 'US',\n      },\n    },\n  });\n\n  const matchScores = matchResponse.data.accounts[0]?.legal_name;\n\n  \u002F\u002F Require high confidence for transfers\n  if ((matchScores?.score || 0) \u003C 70) {\n    throw new Error('Identity verification failed');\n  }\n\n  \u002F\u002F Get real-time balance for the transfer\n  const balanceResponse = await plaidClient.accountsBalanceGet({\n    access_token: accessToken,\n  });\n\n  const account = balanceResponse.data.accounts[0];\n\n  \u002F\u002F Check sufficient funds (consider pending)\n  const availableBalance = account.balances.available ?? account.balances.current;\n  if (availableBalance \u003C amount) {\n    throw new Error('Insufficient funds');\n  }\n\n  \u002F\u002F Get ACH numbers and initiate transfer\n  const authResponse = await plaidClient.authGet({\n    access_token: accessToken,\n  });\n\n  const achNumbers = authResponse.data.numbers.ach.find(\n    n => n.account_id === account.account_id\n  );\n\n  \u002F\u002F Initiate ACH transfer with your payment processor\n  return await initiateACHTransfer({\n    routingNumber: achNumbers.routing,\n    accountNumber: achNumbers.account,\n    amount,\n    accountType: account.subtype,\n  });\n}\n\n### Context\n\n- ach transfers\n- money movement\n- account funding\n\n### Real-Time Balance Check\n\nUse \u002Faccounts\u002Fbalance\u002Fget for real-time balance (paid endpoint).\n\u002Faccounts\u002Fget returns cached data suitable for display but not\nreal-time decisions.\n\ninterface BalanceInfo {\n  accountId: string;\n  available: number | null;\n  current: number;\n  limit: number | null;\n  isoCurrencyCode: string;\n  lastUpdated: Date;\n  isRealtime: boolean;\n}\n\n\u002F\u002F Get cached balance (free, suitable for display)\nasync function getCachedBalances(accessToken: string): Promise\u003CBalanceInfo[]> {\n  const response = await plaidClient.accountsGet({\n    access_token: accessToken,\n  });\n\n  return response.data.accounts.map(account => ({\n    accountId: account.account_id,\n    available: account.balances.available,\n    current: account.balances.current,\n    limit: account.balances.limit,\n    isoCurrencyCode: account.balances.iso_currency_code || 'USD',\n    lastUpdated: new Date(account.balances.last_updated_datetime || Date.now()),\n    isRealtime: false,\n  }));\n}\n\n\u002F\u002F Get real-time balance (paid, for payment validation)\nasync function getRealTimeBalance(\n  accessToken: string,\n  accountIds?: string[]\n): Promise\u003CBalanceInfo[]> {\n  const response = await plaidClient.accountsBalanceGet({\n    access_token: accessToken,\n    options: accountIds ? { account_ids: accountIds } : undefined,\n  });\n\n  return response.data.accounts.map(account => ({\n    accountId: account.account_id,\n    available: account.balances.available,\n    current: account.balances.current,\n    limit: account.balances.limit,\n    isoCurrencyCode: account.balances.iso_currency_code || 'USD',\n    lastUpdated: new Date(),\n    isRealtime: true,\n  }));\n}\n\n\u002F\u002F Payment validation with balance check\nasync function validatePayment(\n  accessToken: string,\n  accountId: string,\n  amount: number\n): Promise\u003CPaymentValidation> {\n  const balances = await getRealTimeBalance(accessToken, [accountId]);\n  const account = balances.find(b => b.accountId === accountId);\n\n  if (!account) {\n    return { valid: false, reason: 'Account not found' };\n  }\n\n  const available = account.available ?? account.current;\n\n  if (available \u003C amount) {\n    return {\n      valid: false,\n      reason: 'Insufficient funds',\n      available,\n      requested: amount,\n    };\n  }\n\n  return {\n    valid: true,\n    available,\n    requested: amount,\n  };\n}\n\n### Context\n\n- balance checking\n- fund availability\n- payment validation\n\n### Webhook Verification\n\nVerify Plaid webhooks using the verification key endpoint.\nHandle duplicate webhooks idempotently and design for out-of-order\ndelivery.\n\nimport jwt from 'jsonwebtoken';\nimport jwksClient from 'jwks-rsa';\n\n\u002F\u002F Cache JWKS client\nconst client = jwksClient({\n  jwksUri: 'https:\u002F\u002Fproduction.plaid.com\u002F.well-known\u002Fjwks.json',\n  cache: true,\n  cacheMaxAge: 86400000,  \u002F\u002F 24 hours\n});\n\nasync function getSigningKey(kid: string): Promise\u003Cstring> {\n  const key = await client.getSigningKey(kid);\n  return key.getPublicKey();\n}\n\nasync function verifyPlaidWebhook(req: Request): Promise\u003Cboolean> {\n  const signedJwt = req.headers['plaid-verification'];\n\n  if (!signedJwt) {\n    return false;\n  }\n\n  try {\n    \u002F\u002F Decode to get kid\n    const decoded = jwt.decode(signedJwt, { complete: true });\n    if (!decoded?.header?.kid) {\n      return false;\n    }\n\n    \u002F\u002F Get signing key\n    const key = await getSigningKey(decoded.header.kid);\n\n    \u002F\u002F Verify JWT\n    const claims = jwt.verify(signedJwt, key, {\n      algorithms: ['ES256'],\n    }) as any;\n\n    \u002F\u002F Verify body hash\n    const bodyHash = crypto\n      .createHash('sha256')\n      .update(JSON.stringify(req.body))\n      .digest('hex');\n\n    if (claims.request_body_sha256 !== bodyHash) {\n      return false;\n    }\n\n    \u002F\u002F Check timestamp (within 5 minutes)\n    const issuedAt = new Date(claims.iat * 1000);\n    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);\n    if (issuedAt \u003C fiveMinutesAgo) {\n      return false;\n    }\n\n    return true;\n  } catch (error) {\n    console.error('Webhook verification failed:', error);\n    return false;\n  }\n}\n\n\u002F\u002F Idempotent webhook handler\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  \u002F\u002F Verify webhook signature\n  if (!await verifyPlaidWebhook(req)) {\n    return res.status(401).send('Invalid signature');\n  }\n\n  const { webhook_type, webhook_code, item_id } = req.body;\n\n  \u002F\u002F Create idempotency key\n  const idempotencyKey = `${webhook_type}:${webhook_code}:${item_id}:${JSON.stringify(req.body)}`;\n  const idempotencyHash = crypto.createHash('sha256').update(idempotencyKey).digest('hex');\n\n  \u002F\u002F Check if already processed\n  const existing = await db.webhookLog.findUnique({\n    where: { idempotencyHash },\n  });\n\n  if (existing) {\n    console.log('Duplicate webhook, skipping:', idempotencyHash);\n    return res.sendStatus(200);\n  }\n\n  \u002F\u002F Record webhook before processing\n  await db.webhookLog.create({\n    data: {\n      idempotencyHash,\n      webhookType: webhook_type,\n      webhookCode: webhook_code,\n      itemId: item_id,\n      payload: req.body,\n      processedAt: new Date(),\n    },\n  });\n\n  \u002F\u002F Process webhook (async for quick response)\n  processWebhookAsync(req.body).catch(console.error);\n\n  res.sendStatus(200);\n});\n\n### Context\n\n- webhook security\n- event processing\n- production deployment\n\n## Sharp Edges\n\n### Access Tokens Never Expire But Are Highly Sensitive\n\nSeverity: CRITICAL\n\n### accounts\u002Fget Returns Cached Balances, Not Real-Time\n\nSeverity: HIGH\n\n### Webhooks May Arrive Out of Order or Duplicated\n\nSeverity: HIGH\n\n### Items Enter Error States That Require User Action\n\nSeverity: HIGH\n\n### Sandbox Does Not Reflect Production Complexity\n\nSeverity: MEDIUM\n\n### TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION Requires Restart\n\nSeverity: MEDIUM\n\n### Link Tokens Are Short-Lived and Single-Use\n\nSeverity: MEDIUM\n\n### Recurring Transactions Need 180+ Days of History\n\nSeverity: MEDIUM\n\n## Validation Checks\n\n### Access Token Stored in Plain Text\n\nSeverity: ERROR\n\nPlaid access tokens must be encrypted at rest\n\nMessage: Plaid access token appears to be stored unencrypted. Encrypt at rest.\n\n### Plaid Secret in Client Code\n\nSeverity: ERROR\n\nPlaid secret must never be exposed to clients\n\nMessage: Plaid secret may be exposed. Keep server-side only.\n\n### Hardcoded Plaid Credentials\n\nSeverity: ERROR\n\nCredentials must use environment variables\n\nMessage: Hardcoded Plaid credentials. Use environment variables.\n\n### Missing Webhook Signature Verification\n\nSeverity: ERROR\n\nPlaid webhooks must verify JWT signature\n\nMessage: Webhook handler without signature verification. Verify Plaid-Verification header.\n\n### Using Cached Balance for Payment Decision\n\nSeverity: ERROR\n\nUse real-time balance for payment validation\n\nMessage: Using accountsGet (cached) for payment. Use accountsBalanceGet for real-time balance.\n\n### Missing Item Error State Handling\n\nSeverity: WARNING\n\nAPI calls should handle ITEM_LOGIN_REQUIRED\n\nMessage: API call without ITEM_LOGIN_REQUIRED handling. Handle item error states.\n\n### Polling for Transactions Instead of Webhooks\n\nSeverity: WARNING\n\nUse webhooks for transaction updates\n\nMessage: Polling for transactions. Configure webhooks for SYNC_UPDATES_AVAILABLE.\n\n### Link Token Cached or Reused\n\nSeverity: WARNING\n\nLink tokens are single-use and expire in 4 hours\n\nMessage: Link tokens should not be cached. Create fresh token for each session.\n\n### Using Deprecated Public Key\n\nSeverity: ERROR\n\nPublic key integration ended January 2025\n\nMessage: Public key is deprecated. Use Link tokens instead.\n\n### Transaction Sync Without Cursor Storage\n\nSeverity: WARNING\n\nStore cursor for incremental syncs\n\nMessage: Transaction sync without cursor persistence. Store cursor for incremental sync.\n\n## Collaboration\n\n### Delegation Triggers\n\n- user needs payment processing -> stripe-integration (Stripe for actual payment, Plaid for account linking)\n- user needs budgeting features -> analytics-specialist (Transaction categorization and analysis)\n- user needs investment tracking -> data-engineer (Portfolio analysis and reporting)\n- user needs compliance\u002Faudit -> security-specialist (SOC 2, PCI compliance)\n- user needs mobile app -> mobile-developer (React Native Plaid SDK)\n\n## When to Use\n\n- User mentions or implies: plaid\n- User mentions or implies: bank account linking\n- User mentions or implies: bank connection\n- User mentions or implies: ach\n- User mentions or implies: account aggregation\n- User mentions or implies: bank transactions\n- User mentions or implies: open banking\n- User mentions or implies: fintech\n- User mentions or implies: identity verification banking\n","# Plaid Fintech\n\nExpert patterns for Plaid API integration including Link token flows,\ntransactions sync, identity verification, Auth for ACH, balance checks,\nwebhook handling, and fintech compliance best practices.\n\n## Patterns\n\n### Link Token Creation and Exchange\n\nCreate a link_token for Plaid Link, exchange public_token for access_token.\nLink tokens are short-lived, one-time use. Access tokens don't expire but\nmay need updating when users change passwords.\n\n\u002F\u002F server.ts - Link token creation endpoint\nimport { Configuration, PlaidApi, PlaidEnvironments, Products, CountryCode } from 'plaid';\n\nconst configuration = new Configuration({\n  basePath: PlaidEnvironments[process.env.PLAID_ENV || 'sandbox'],\n  baseOptions: {\n    headers: {\n      'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,\n      'PLAID-SECRET': process.env.PLAID_SECRET,\n    },\n  },\n});\n\nconst plaidClient = new PlaidApi(configuration);\n\n\u002F\u002F Create link token for new user\napp.post('\u002Fapi\u002Fplaid\u002Fcreate-link-token', async (req, res) => {\n  const { userId } = req.body;\n\n  try {\n    const response = await plaidClient.linkTokenCreate({\n      user: {\n        client_user_id: userId,  \u002F\u002F Your internal user ID\n      },\n      client_name: 'My Finance App',\n      products: [Products.Transactions],\n      country_codes: [CountryCode.Us],\n      language: 'en',\n      webhook: 'https:\u002F\u002Fyourapp.com\u002Fapi\u002Fplaid\u002Fwebhooks',\n      \u002F\u002F Request 180 days for recurring transactions\n      transactions: {\n        days_requested: 180,\n      },\n    });\n\n    res.json({ link_token: response.data.link_token });\n  } catch (error) {\n    console.error('Link token creation failed:', error);\n    res.status(500).json({ error: 'Failed to create link token' });\n  }\n});\n\n\u002F\u002F Exchange public token for access token\napp.post('\u002Fapi\u002Fplaid\u002Fexchange-token', async (req, res) => {\n  const { publicToken, userId } = req.body;\n\n  try {\n    \u002F\u002F Exchange for permanent access token\n    const exchangeResponse = await plaidClient.itemPublicTokenExchange({\n      public_token: publicToken,\n    });\n\n    const { access_token, item_id } = exchangeResponse.data;\n\n    \u002F\u002F Store securely - access_token doesn't expire!\n    await db.plaidItem.create({\n      data: {\n        userId,\n        itemId: item_id,\n        accessToken: await encrypt(access_token),  \u002F\u002F Encrypt at rest\n        status: 'ACTIVE',\n        products: ['transactions'],\n      },\n    });\n\n    \u002F\u002F Trigger initial transaction sync\n    await initiateTransactionSync(item_id, access_token);\n\n    res.json({ success: true, itemId: item_id });\n  } catch (error) {\n    console.error('Token exchange failed:', error);\n    res.status(500).json({ error: 'Failed to exchange token' });\n  }\n});\n\n\u002F\u002F Frontend - React component\nimport { usePlaidLink } from 'react-plaid-link';\n\nfunction BankLinkButton({ userId }: { userId: string }) {\n  const [linkToken, setLinkToken] = useState\u003Cstring | null>(null);\n\n  useEffect(() => {\n    async function createLinkToken() {\n      const response = await fetch('\u002Fapi\u002Fplaid\u002Fcreate-link-token', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ userId }),\n      });\n      const { link_token } = await response.json();\n      setLinkToken(link_token);\n    }\n    createLinkToken();\n  }, [userId]);\n\n  const { open, ready } = usePlaidLink({\n    token: linkToken,\n    onSuccess: async (publicToken, metadata) => {\n      \u002F\u002F Exchange public token for access token\n      await fetch('\u002Fapi\u002Fplaid\u002Fexchange-token', {\n        method: 'POST',\n        headers: { 'Content-Type': 'application\u002Fjson' },\n        body: JSON.stringify({ publicToken, userId }),\n      });\n    },\n    onExit: (error, metadata) => {\n      if (error) {\n        console.error('Link exit error:', error);\n      }\n    },\n  });\n\n  return (\n    \u003Cbutton onClick={() => open()} disabled={!ready}>\n      Connect Bank Account\n    \u003C\u002Fbutton>\n  );\n}\n\n### Context\n\n- initial bank linking\n- user onboarding\n- connecting accounts\n\n### Transactions Sync\n\nUse \u002Ftransactions\u002Fsync for incremental transaction updates. More efficient\nthan \u002Ftransactions\u002Fget. Handle webhooks for real-time updates instead of\npolling.\n\n\u002F\u002F Transactions sync service\ninterface TransactionSyncState {\n  cursor: string | null;\n  hasMore: boolean;\n}\n\nasync function syncTransactions(\n  accessToken: string,\n  itemId: string\n): Promise\u003Cvoid> {\n  \u002F\u002F Get last cursor from database\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n  });\n\n  let cursor = item?.transactionsCursor || null;\n  let hasMore = true;\n  let addedCount = 0;\n  let modifiedCount = 0;\n  let removedCount = 0;\n\n  while (hasMore) {\n    try {\n      const response = await plaidClient.transactionsSync({\n        access_token: accessToken,\n        cursor: cursor || undefined,\n        count: 500,  \u002F\u002F Max per request\n      });\n\n      const { added, modified, removed, next_cursor, has_more } = response.data;\n\n      \u002F\u002F Process added transactions\n      if (added.length > 0) {\n        await db.transaction.createMany({\n          data: added.map(txn => ({\n            plaidTransactionId: txn.transaction_id,\n            itemId,\n            accountId: txn.account_id,\n            amount: txn.amount,\n            date: new Date(txn.date),\n            name: txn.name,\n            merchantName: txn.merchant_name,\n            category: txn.personal_finance_category?.primary,\n            subcategory: txn.personal_finance_category?.detailed,\n            pending: txn.pending,\n            paymentChannel: txn.payment_channel,\n            location: txn.location ? JSON.stringify(txn.location) : null,\n          })),\n          skipDuplicates: true,\n        });\n        addedCount += added.length;\n      }\n\n      \u002F\u002F Process modified transactions\n      for (const txn of modified) {\n        await db.transaction.updateMany({\n          where: { plaidTransactionId: txn.transaction_id },\n          data: {\n            amount: txn.amount,\n            name: txn.name,\n            merchantName: txn.merchant_name,\n            pending: txn.pending,\n            updatedAt: new Date(),\n          },\n        });\n        modifiedCount++;\n      }\n\n      \u002F\u002F Process removed transactions\n      if (removed.length > 0) {\n        await db.transaction.deleteMany({\n          where: {\n            plaidTransactionId: {\n              in: removed.map(r => r.transaction_id),\n            },\n          },\n        });\n        removedCount += removed.length;\n      }\n\n      cursor = next_cursor;\n      hasMore = has_more;\n\n    } catch (error: any) {\n      if (error.response?.data?.error_code === 'TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION') {\n        \u002F\u002F Data changed during pagination, restart from null\n        cursor = null;\n        continue;\n      }\n      throw error;\n    }\n  }\n\n  \u002F\u002F Save cursor for next sync\n  await db.plaidItem.update({\n    where: { itemId },\n    data: { transactionsCursor: cursor },\n  });\n\n  console.log(`Sync complete: +${addedCount} ~${modifiedCount} -${removedCount}`);\n}\n\n\u002F\u002F Webhook handler for real-time updates\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  const { webhook_type, webhook_code, item_id } = req.body;\n\n  \u002F\u002F Verify webhook (see webhook verification pattern)\n  if (!verifyPlaidWebhook(req)) {\n    return res.status(401).send('Invalid webhook');\n  }\n\n  if (webhook_type === 'TRANSACTIONS') {\n    switch (webhook_code) {\n      case 'SYNC_UPDATES_AVAILABLE':\n        \u002F\u002F New transactions available, trigger sync\n        await queueTransactionSync(item_id);\n        break;\n      case 'INITIAL_UPDATE':\n        \u002F\u002F Initial batch of transactions ready\n        await queueTransactionSync(item_id);\n        break;\n      case 'HISTORICAL_UPDATE':\n        \u002F\u002F Historical transactions ready\n        await queueTransactionSync(item_id);\n        break;\n    }\n  }\n\n  res.sendStatus(200);\n});\n\n### Context\n\n- fetching transactions\n- transaction history\n- account activity\n\n### Item Error Handling and Update Mode\n\nHandle ITEM_LOGIN_REQUIRED errors by putting users through Link update mode.\nListen for PENDING_DISCONNECT webhook to proactively prompt users.\n\n\u002F\u002F Create link token for update mode\napp.post('\u002Fapi\u002Fplaid\u002Fcreate-update-token', async (req, res) => {\n  const { itemId } = req.body;\n\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n    include: { user: true },\n  });\n\n  if (!item) {\n    return res.status(404).json({ error: 'Item not found' });\n  }\n\n  try {\n    const response = await plaidClient.linkTokenCreate({\n      user: {\n        client_user_id: item.userId,\n      },\n      client_name: 'My Finance App',\n      country_codes: [CountryCode.Us],\n      language: 'en',\n      webhook: 'https:\u002F\u002Fyourapp.com\u002Fapi\u002Fplaid\u002Fwebhooks',\n      \u002F\u002F Update mode: provide access_token instead of products\n      access_token: await decrypt(item.accessToken),\n    });\n\n    res.json({ link_token: response.data.link_token });\n  } catch (error) {\n    console.error('Update token creation failed:', error);\n    res.status(500).json({ error: 'Failed to create update token' });\n  }\n});\n\n\u002F\u002F Handle item errors from webhooks\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  const { webhook_type, webhook_code, item_id, error } = req.body;\n\n  if (webhook_type === 'ITEM') {\n    switch (webhook_code) {\n      case 'ERROR':\n        \u002F\u002F Item has entered an error state\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: {\n            status: 'ERROR',\n            errorCode: error?.error_code,\n            errorMessage: error?.error_message,\n          },\n        });\n\n        \u002F\u002F Notify user to reconnect\n        if (error?.error_code === 'ITEM_LOGIN_REQUIRED') {\n          await notifyUserReconnect(item_id, 'Please reconnect your bank account');\n        }\n        break;\n\n      case 'PENDING_DISCONNECT':\n        \u002F\u002F User needs to reauthorize soon\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: { status: 'PENDING_DISCONNECT' },\n        });\n\n        \u002F\u002F Proactive notification\n        await notifyUserReconnect(item_id, 'Your bank connection will expire soon');\n        break;\n\n      case 'USER_PERMISSION_REVOKED':\n        \u002F\u002F User revoked access at their bank\n        await db.plaidItem.update({\n          where: { itemId: item_id },\n          data: { status: 'REVOKED' },\n        });\n\n        \u002F\u002F Clean up stored data\n        await db.transaction.deleteMany({\n          where: { itemId: item_id },\n        });\n        break;\n    }\n  }\n\n  res.sendStatus(200);\n});\n\n\u002F\u002F Check item status before API calls\nasync function getItemWithValidation(itemId: string) {\n  const item = await db.plaidItem.findUnique({\n    where: { itemId },\n  });\n\n  if (!item) {\n    throw new Error('Item not found');\n  }\n\n  if (item.status === 'ERROR') {\n    throw new ItemNeedsUpdateError(item.errorCode, item.errorMessage);\n  }\n\n  return item;\n}\n\n### Context\n\n- error recovery\n- reauthorization\n- credential updates\n\n### Auth for ACH Transfers\n\nUse Auth product to get account and routing numbers for ACH transfers.\nCombine with Identity to verify account ownership before initiating\ntransfers.\n\n\u002F\u002F Get account and routing numbers\nasync function getACHNumbers(accessToken: string): Promise\u003CACHInfo[]> {\n  const response = await plaidClient.authGet({\n    access_token: accessToken,\n  });\n\n  const { accounts, numbers } = response.data;\n\n  \u002F\u002F Map ACH numbers to accounts\n  return accounts.map(account => {\n    const achNumber = numbers.ach.find(\n      n => n.account_id === account.account_id\n    );\n\n    return {\n      accountId: account.account_id,\n      name: account.name,\n      mask: account.mask,\n      type: account.type,\n      subtype: account.subtype,\n      routing: achNumber?.routing,\n      account: achNumber?.account,\n      wireRouting: achNumber?.wire_routing,\n    };\n  });\n}\n\n\u002F\u002F Verify identity before ACH transfer\nasync function verifyAndInitiateTransfer(\n  accessToken: string,\n  userId: string,\n  amount: number\n): Promise\u003CTransferResult> {\n  \u002F\u002F Get identity from linked account\n  const identityResponse = await plaidClient.identityGet({\n    access_token: accessToken,\n  });\n\n  const accountOwners = identityResponse.data.accounts[0]?.owners || [];\n\n  \u002F\u002F Get user's stored identity\n  const user = await db.user.findUnique({\n    where: { id: userId },\n  });\n\n  \u002F\u002F Match identity\n  const matchResponse = await plaidClient.identityMatch({\n    access_token: accessToken,\n    user: {\n      legal_name: user.legalName,\n      phone_number: user.phoneNumber,\n      email_address: user.email,\n      address: {\n        street: user.street,\n        city: user.city,\n        region: user.state,\n        postal_code: user.postalCode,\n        country: 'US',\n      },\n    },\n  });\n\n  const matchScores = matchResponse.data.accounts[0]?.legal_name;\n\n  \u002F\u002F Require high confidence for transfers\n  if ((matchScores?.score || 0) \u003C 70) {\n    throw new Error('Identity verification failed');\n  }\n\n  \u002F\u002F Get real-time balance for the transfer\n  const balanceResponse = await plaidClient.accountsBalanceGet({\n    access_token: accessToken,\n  });\n\n  const account = balanceResponse.data.accounts[0];\n\n  \u002F\u002F Check sufficient funds (consider pending)\n  const availableBalance = account.balances.available ?? account.balances.current;\n  if (availableBalance \u003C amount) {\n    throw new Error('Insufficient funds');\n  }\n\n  \u002F\u002F Get ACH numbers and initiate transfer\n  const authResponse = await plaidClient.authGet({\n    access_token: accessToken,\n  });\n\n  const achNumbers = authResponse.data.numbers.ach.find(\n    n => n.account_id === account.account_id\n  );\n\n  \u002F\u002F Initiate ACH transfer with your payment processor\n  return await initiateACHTransfer({\n    routingNumber: achNumbers.routing,\n    accountNumber: achNumbers.account,\n    amount,\n    accountType: account.subtype,\n  });\n}\n\n### Context\n\n- ach transfers\n- money movement\n- account funding\n\n### Real-Time Balance Check\n\nUse \u002Faccounts\u002Fbalance\u002Fget for real-time balance (paid endpoint).\n\u002Faccounts\u002Fget returns cached data suitable for display but not\nreal-time decisions.\n\ninterface BalanceInfo {\n  accountId: string;\n  available: number | null;\n  current: number;\n  limit: number | null;\n  isoCurrencyCode: string;\n  lastUpdated: Date;\n  isRealtime: boolean;\n}\n\n\u002F\u002F Get cached balance (free, suitable for display)\nasync function getCachedBalances(accessToken: string): Promise\u003CBalanceInfo[]> {\n  const response = await plaidClient.accountsGet({\n    access_token: accessToken,\n  });\n\n  return response.data.accounts.map(account => ({\n    accountId: account.account_id,\n    available: account.balances.available,\n    current: account.balances.current,\n    limit: account.balances.limit,\n    isoCurrencyCode: account.balances.iso_currency_code || 'USD',\n    lastUpdated: new Date(account.balances.last_updated_datetime || Date.now()),\n    isRealtime: false,\n  }));\n}\n\n\u002F\u002F Get real-time balance (paid, for payment validation)\nasync function getRealTimeBalance(\n  accessToken: string,\n  accountIds?: string[]\n): Promise\u003CBalanceInfo[]> {\n  const response = await plaidClient.accountsBalanceGet({\n    access_token: accessToken,\n    options: accountIds ? { account_ids: accountIds } : undefined,\n  });\n\n  return response.data.accounts.map(account => ({\n    accountId: account.account_id,\n    available: account.balances.available,\n    current: account.balances.current,\n    limit: account.balances.limit,\n    isoCurrencyCode: account.balances.iso_currency_code || 'USD',\n    lastUpdated: new Date(),\n    isRealtime: true,\n  }));\n}\n\n\u002F\u002F Payment validation with balance check\nasync function validatePayment(\n  accessToken: string,\n  accountId: string,\n  amount: number\n): Promise\u003CPaymentValidation> {\n  const balances = await getRealTimeBalance(accessToken, [accountId]);\n  const account = balances.find(b => b.accountId === accountId);\n\n  if (!account) {\n    return { valid: false, reason: 'Account not found' };\n  }\n\n  const available = account.available ?? account.current;\n\n  if (available \u003C amount) {\n    return {\n      valid: false,\n      reason: 'Insufficient funds',\n      available,\n      requested: amount,\n    };\n  }\n\n  return {\n    valid: true,\n    available,\n    requested: amount,\n  };\n}\n\n### Context\n\n- balance checking\n- fund availability\n- payment validation\n\n### Webhook Verification\n\nVerify Plaid webhooks using the verification key endpoint.\nHandle duplicate webhooks idempotently and design for out-of-order\ndelivery.\n\nimport jwt from 'jsonwebtoken';\nimport jwksClient from 'jwks-rsa';\n\n\u002F\u002F Cache JWKS client\nconst client = jwksClient({\n  jwksUri: 'https:\u002F\u002Fproduction.plaid.com\u002F.well-known\u002Fjwks.json',\n  cache: true,\n  cacheMaxAge: 86400000,  \u002F\u002F 24 hours\n});\n\nasync function getSigningKey(kid: string): Promise\u003Cstring> {\n  const key = await client.getSigningKey(kid);\n  return key.getPublicKey();\n}\n\nasync function verifyPlaidWebhook(req: Request): Promise\u003Cboolean> {\n  const signedJwt = req.headers['plaid-verification'];\n\n  if (!signedJwt) {\n    return false;\n  }\n\n  try {\n    \u002F\u002F Decode to get kid\n    const decoded = jwt.decode(signedJwt, { complete: true });\n    if (!decoded?.header?.kid) {\n      return false;\n    }\n\n    \u002F\u002F Get signing key\n    const key = await getSigningKey(decoded.header.kid);\n\n    \u002F\u002F Verify JWT\n    const claims = jwt.verify(signedJwt, key, {\n      algorithms: ['ES256'],\n    }) as any;\n\n    \u002F\u002F Verify body hash\n    const bodyHash = crypto\n      .createHash('sha256')\n      .update(JSON.stringify(req.body))\n      .digest('hex');\n\n    if (claims.request_body_sha256 !== bodyHash) {\n      return false;\n    }\n\n    \u002F\u002F Check timestamp (within 5 minutes)\n    const issuedAt = new Date(claims.iat * 1000);\n    const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);\n    if (issuedAt \u003C fiveMinutesAgo) {\n      return false;\n    }\n\n    return true;\n  } catch (error) {\n    console.error('Webhook verification failed:', error);\n    return false;\n  }\n}\n\n\u002F\u002F Idempotent webhook handler\napp.post('\u002Fapi\u002Fplaid\u002Fwebhooks', async (req, res) => {\n  \u002F\u002F Verify webhook signature\n  if (!await verifyPlaidWebhook(req)) {\n    return res.status(401).send('Invalid signature');\n  }\n\n  const { webhook_type, webhook_code, item_id } = req.body;\n\n  \u002F\u002F Create idempotency key\n  const idempotencyKey = `${webhook_type}:${webhook_code}:${item_id}:${JSON.stringify(req.body)}`;\n  const idempotencyHash = crypto.createHash('sha256').update(idempotencyKey).digest('hex');\n\n  \u002F\u002F Check if already processed\n  const existing = await db.webhookLog.findUnique({\n    where: { idempotencyHash },\n  });\n\n  if (existing) {\n    console.log('Duplicate webhook, skipping:', idempotencyHash);\n    return res.sendStatus(200);\n  }\n\n  \u002F\u002F Record webhook before processing\n  await db.webhookLog.create({\n    data: {\n      idempotencyHash,\n      webhookType: webhook_type,\n      webhookCode: webhook_code,\n      itemId: item_id,\n      payload: req.body,\n      processedAt: new Date(),\n    },\n  });\n\n  \u002F\u002F Process webhook (async for quick response)\n  processWebhookAsync(req.body).catch(console.error);\n\n  res.sendStatus(200);\n});\n\n### Context\n\n- webhook security\n- event processing\n- production deployment\n\n## Sharp Edges\n\n### Access Tokens Never Expire But Are Highly Sensitive\n\nSeverity: CRITICAL\n\n### accounts\u002Fget Returns Cached Balances, Not Real-Time\n\nSeverity: HIGH\n\n### Webhooks May Arrive Out of Order or Duplicated\n\nSeverity: HIGH\n\n### Items Enter Error States That Require User Action\n\nSeverity: HIGH\n\n### Sandbox Does Not Reflect Production Complexity\n\nSeverity: MEDIUM\n\n### TRANSACTIONS_SYNC_MUTATION_DURING_PAGINATION Requires Restart\n\nSeverity: MEDIUM\n\n### Link Tokens Are Short-Lived and Single-Use\n\nSeverity: MEDIUM\n\n### Recurring Transactions Need 180+ Days of History\n\nSeverity: MEDIUM\n\n## Validation Checks\n\n### Access Token Stored in Plain Text\n\nSeverity: ERROR\n\nPlaid access tokens must be encrypted at rest\n\nMessage: Plaid access token appears to be stored unencrypted. Encrypt at rest.\n\n### Plaid Secret in Client Code\n\nSeverity: ERROR\n\nPlaid secret must never be exposed to clients\n\nMessage: Plaid secret may be exposed. Keep server-side only.\n\n### Hardcoded Plaid Credentials\n\nSeverity: ERROR\n\nCredentials must use environment variables\n\nMessage: Hardcoded Plaid credentials. Use environment variables.\n\n### Missing Webhook Signature Verification\n\nSeverity: ERROR\n\nPlaid webhooks must verify JWT signature\n\nMessage: Webhook handler without signature verification. Verify Plaid-Verification header.\n\n### Using Cached Balance for Payment Decision\n\nSeverity: ERROR\n\nUse real-time balance for payment validation\n\nMessage: Using accountsGet (cached) for payment. Use accountsBalanceGet for real-time balance.\n\n### Missing Item Error State Handling\n\nSeverity: WARNING\n\nAPI calls should handle ITEM_LOGIN_REQUIRED\n\nMessage: API call without ITEM_LOGIN_REQUIRED handling. Handle item error states.\n\n### Polling for Transactions Instead of Webhooks\n\nSeverity: WARNING\n\nUse webhooks for transaction updates\n\nMessage: Polling for transactions. Configure webhooks for SYNC_UPDATES_AVAILABLE.\n\n### Link Token Cached or Reused\n\nSeverity: WARNING\n\nLink tokens are single-use and expire in 4 hours\n\nMessage: Link tokens should not be cached. Create fresh token for each session.\n\n### Using Deprecated Public Key\n\nSeverity: ERROR\n\nPublic key integration ended January 2025\n\nMessage: Public key is deprecated. Use Link tokens instead.\n\n### Transaction Sync Without Cursor Storage\n\nSeverity: WARNING\n\nStore cursor for incremental syncs\n\nMessage: Transaction sync without cursor persistence. Store cursor for incremental sync.\n\n## Collaboration\n\n### Delegation Triggers\n\n- user needs payment processing -> stripe-integration (Stripe for actual payment, Plaid for account linking)\n- user needs budgeting features -> analytics-specialist (Transaction categorization and analysis)\n- user needs investment tracking -> data-engineer (Portfolio analysis and reporting)\n- user needs compliance\u002Faudit -> security-specialist (SOC 2, PCI compliance)\n- user needs mobile app -> mobile-developer (React Native Plaid SDK)\n\n## When to Use\n\n- User mentions or implies: plaid\n- User mentions or implies: bank account linking\n- User mentions or implies: bank connection\n- User mentions or implies: ach\n- User mentions or implies: account aggregation\n- User mentions or implies: bank transactions\n- User mentions or implies: open banking\n- User mentions or implies: fintech\n- User mentions or implies: identity verification banking\n",{"name":5,"risk":25,"source":44,"date_added":45,"description":7},"vibeship-spawner-skills (Apache 2.0)",1772150400000,"main","7a38d8e7073e61c16eda85681cb32fb68a8657a5","skills-store\u002Fglobal\u002Fplaid-fintech","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router","https:\u002F\u002Fgithub.com\u002Fsapsapshen","2026-04-14 21:12:12",{"source":8,"skillName":5,"commands":53,"prompt":63,"zipAvailable":64},[54,57,60],{"tool":55,"command":56},"npx","npx skills add sapsapshen\u002Fai-memory-router --skill plaid-fintech",{"tool":58,"command":59},"bunx","bunx skills add sapsapshen\u002Fai-memory-router --skill plaid-fintech",{"tool":61,"command":62},"pnpm","pnpm dlx skills add sapsapshen\u002Fai-memory-router --skill plaid-fintech","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fglobal\u002Fplaid-fintech\u002FSKILL.md",true,[],[67,91,116,126,139,158],{"skillNo":68,"name":69,"nameZh":6,"description":70,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":71,"categories":72,"keywords":78,"publishedAt":39},"01M71ATQRKG48SH06FQ697T5G9","wechat-search","Search WeChat Official Account articles using OpenClaw's web search and fetch capabilities with compliance-focused design.","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Fwechat-search-release\u002FSKILL.md",[73],{"categoryNo":74,"categoryName":75,"taxonomyType":76,"taxonomyTypeDesc":77},"01JBSEEDCAT000000000000037","律师与法务","PROFESSION","适用职业",[79,80,81,82,83,84,85,86,87,88,89,90],"wechat search","wechat","search","capabilities","openclaw","design","web search","compliance","web","official","fetch","with",{"skillNo":92,"name":93,"nameZh":6,"description":94,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":95,"categories":96,"keywords":97,"publishedAt":39},"01M71ATNAVGZB35PFYM2GC5N00","trend-tap","Real-time trending topics aggregator across 7 platforms (X\u002FTwitter, Reddit, Google Trends, Hacker News, Zhihu, Bilibili, Weibo). Trigger: when user says 'trend', 'trends', 'trend tap', '热点', '热搜', or asks what is trending\u002Fhot. Do NOT use web_search — this skill already fetches live data from all platforms with zero API keys.","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Ftrend-tap\u002FSKILL.md",[],[98,99,100,101,102,103,81,104,105,106,107,108,109,110,111,112,113,114,115,34],"trend","trend tap","tap","api keys","Bash","time","trending","hacker","aggregator","do","when","brew","live","data","google","install","read","across",{"skillNo":117,"name":118,"nameZh":6,"description":119,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":120,"categories":121,"keywords":122,"publishedAt":39},"01M71ATJ9AM5KM15AA8PS302EB","trend-radar","Real-time trending topics aggregator across 7 platforms (X\u002FTwitter, Reddit, Google Trends, Hacker News, Zhihu, Bilibili, Weibo). Trigger: when user says 'trend', 'trends', 'trend pulse', '热点', '热搜', or asks what is trending\u002Fhot. Do NOT use web_search — this skill already fetches live data from all platforms with zero API keys.","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Ftrend-radar\u002FSKILL.md",[],[123,124,98,109,110,111,112,102,81,114,125,115,34,108,106,105,104,113,103,101],"trend radar","radar","pulse",{"skillNo":127,"name":128,"nameZh":6,"description":129,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":130,"categories":131,"keywords":132,"publishedAt":39},"01M71ATGF095N3EJM9G8SD0BB5","tech-news-daily","每日科技新闻汇——从全球多个 RSS 源抓取最新科技新闻，翻译（英→中）、去重、排版成每日简报。当用户询问科技新闻、科技新鲜事、每日科技、技术动态、Daily Tech News 或类似表达时触发此技能。","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Ftech-news-daily\u002FSKILL.md",[],[133,134,135,136,137,138],"news","tech","tech news daily","daily","rss","翻译",{"skillNo":140,"name":141,"nameZh":6,"description":142,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":143,"categories":144,"keywords":150,"publishedAt":39},"01M71ATFAEAMQ17M70NQNY7SR8","summarize","Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube).","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Fsummarize\u002FSKILL.md",[145],{"categoryNo":146,"categoryName":147,"taxonomyType":148,"taxonomyTypeDesc":149},"01JBSEEDCAT000000000000021","资料检索与研究","SCENARIO","使用场景",[141,87,151,152,153,90,109,154,155,156,100,157,113],"Audio","cli","youtube","images","https","files","pdfs",{"skillNo":159,"name":160,"nameZh":6,"description":161,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":162,"categories":163,"keywords":164,"publishedAt":39},"01M71ATDNS3R0M4H936PACQAWZ","sonoscli","Control Sonos speakers (discover\u002Fstatus\u002Fplay\u002Fvolume\u002Fgroup).","https:\u002F\u002Fgithub.com\u002Fsapsapshen\u002Fai-memory-router\u002Fblob\u002Fmain\u002Fskills-store\u002Fopenclaw\u002Fsonoscli\u002FSKILL.md",[],[160,165,166,167,113,168,155,169,170,171,172,173],"status","play","go","latest","github","discover","control","cmd","volume"]