[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7CFVRXND6PC04JZ2MH9E7VT":3,"skill-same:01M7CFVRXND6PC04JZ2MH9E7VT":50},{"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":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":27,"commitSha":28,"skillDirectory":29,"forksCount":30,"repositoryHtmlUrl":31,"repositoryHomepageUrl":32,"ownerHtmlUrl":33,"ownerBlogUrl":6,"ownerDescription":34,"updatedAt":35,"install":36,"resources":49},"01M7CFVRXND6PC04JZ2MH9E7VT","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.","YYC-Cube\u002FYYC3-Knowledge-Base","YYC-Cube","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F235006543?v=4","01M79J2PH4SWD2X0825X6PN748","yyc3","01M79J2PHVK3F6ZBYT8K2SCZCW",1,"MIT","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-Skills-技能包\u002FYYC3-Skills-技能包-antigravity-awesome-skills\u002Fplugins\u002Fantigravity-bundle-integration-apis\u002Fskills\u002Fplaid-fintech\u002FSKILL.md",[],[],"2026-11-15 14:19:17",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- 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\n## Limitations\n- Use this skill only when the task clearly matches the scope described above.\n- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.\n- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.\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- 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\n## Limitations\n- Use this skill only when the task clearly matches the scope described above.\n- Do not treat the output as a substitute for environment-specific validation, testing, or expert review.\n- Stop and ask for clarification if required inputs, permissions, safety boundaries, or success criteria are missing.\n",{"name":5,"risk":24,"source":25,"date_added":26,"description":7},"unknown","vibeship-spawner-skills (Apache 2.0)",1772150400000,"main","87afe6ce96220accf50a345dea46784c8c9f210e","YYC3-Skills-技能包\u002FYYC3-Skills-技能包-antigravity-awesome-skills\u002Fplugins\u002Fantigravity-bundle-integration-apis\u002Fskills\u002Fplaid-fintech",0,"https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base","https:\u002F\u002Fkb.yyc3.top\u002F","https:\u002F\u002Fgithub.com\u002FYYC-Cube","万象归元于云枢 | 深栈智启新纪元","2026-11-10 10:37:32",{"source":8,"skillName":5,"commands":37,"prompt":47,"zipAvailable":48},[38,41,44],{"tool":39,"command":40},"npx","npx skills add YYC-Cube\u002FYYC3-Knowledge-Base --skill plaid-fintech",{"tool":42,"command":43},"bunx","bunx skills add YYC-Cube\u002FYYC3-Knowledge-Base --skill plaid-fintech",{"tool":45,"command":46},"pnpm","pnpm dlx skills add YYC-Cube\u002FYYC3-Knowledge-Base --skill plaid-fintech","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-Skills-技能包\u002FYYC3-Skills-技能包-antigravity-awesome-skills\u002Fplugins\u002Fantigravity-bundle-integration-apis\u002Fskills\u002Fplaid-fintech\u002FSKILL.md",true,[],[51,58,65,72,79,86],{"skillNo":52,"name":53,"nameZh":6,"description":54,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":55,"categories":56,"keywords":57,"publishedAt":19},"01M7CTDWHERCXPQCP0K03A86AS","vercel-sandbox","Run agent-browser + Chrome inside Vercel Sandbox microVMs for browser automation from any Vercel-deployed app. Use when the user needs browser automation in a Vercel app (Next.js, SvelteKit, Nuxt, Remix, Astro, etc.), wants to run headless Chrome without binary size limits, needs persistent browser sessions across commands, or wants ephemeral isolated browser environments. Triggers include \"Vercel Sandbox browser\", \"microVM Chrome\", \"agent-browser in sandbox\", \"browser automation on Vercel\", or any task requiring Chrome in a Vercel Sandbox.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-WebSpec-Web标准\u002FYYC3-WebSpec-Web标准-standards\u002Fbrowser-agent\u002Fskills\u002Fvercel-sandbox\u002FSKILL.md",[],[],{"skillNo":59,"name":60,"nameZh":6,"description":61,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":62,"categories":63,"keywords":64,"publishedAt":19},"01M7CTD8D2BNJY54S65M47HEJA","slack","Interact with Slack workspaces using browser automation. Use when the user needs to check unread channels, navigate Slack, send messages, extract data, find information, search conversations, or automate any Slack task. Triggers include \"check my Slack\", \"what channels have unreads\", \"send a message to\", \"search Slack for\", \"extract from Slack\", \"find who said\", or any task requiring programmatic Slack interaction.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-WebSpec-Web标准\u002FYYC3-WebSpec-Web标准-standards\u002Fbrowser-agent\u002Fskills\u002Fslack\u002FSKILL.md",[],[],{"skillNo":66,"name":67,"nameZh":6,"description":68,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":69,"categories":70,"keywords":71,"publishedAt":19},"01M7CTCSSB8ZP3Q58C3YZDZK1W","electron","Automate Electron desktop apps (VS Code, Slack, Discord, Figma, Notion, Spotify, etc.) using agent-browser via Chrome DevTools Protocol. Use when the user needs to interact with an Electron app, automate a desktop app, connect to a running app, control a native app, or test an Electron application. Triggers include \"automate Slack app\", \"control VS Code\", \"interact with Discord app\", \"test this Electron app\", \"connect to desktop app\", or any task requiring automation of a native Electron application.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-WebSpec-Web标准\u002FYYC3-WebSpec-Web标准-standards\u002Fbrowser-agent\u002Fskills\u002Felectron\u002FSKILL.md",[],[],{"skillNo":73,"name":74,"nameZh":6,"description":75,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":76,"categories":77,"keywords":78,"publishedAt":19},"01M7CTCBJY76688V4DCZGWFHGQ","dogfood","Systematically explore and test a web application to find bugs, UX issues, and other problems. Use when asked to \"dogfood\", \"QA\", \"exploratory test\", \"find issues\", \"bug hunt\", \"test this app\u002Fsite\u002Fplatform\", or review the quality of a web application. Produces a structured report with full reproduction evidence -- step-by-step screenshots, repro videos, and detailed repro steps for every issue -- so findings can be handed directly to the responsible teams.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-WebSpec-Web标准\u002FYYC3-WebSpec-Web标准-standards\u002Fbrowser-agent\u002Fskills\u002Fdogfood\u002FSKILL.md",[],[],{"skillNo":80,"name":81,"nameZh":6,"description":82,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":83,"categories":84,"keywords":85,"publishedAt":19},"01M7CTC4H0FFGVEAQR9PA4GA1A","agent-browser","Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-WebSpec-Web标准\u002FYYC3-WebSpec-Web标准-standards\u002Fbrowser-agent\u002Fskills\u002Fagent-browser\u002FSKILL.md",[],[],{"skillNo":87,"name":88,"nameZh":6,"description":89,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":90,"categories":91,"keywords":92,"publishedAt":19},"01M7CTBKVFYBG78FDSABSDJTTQ","shadcn","Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI, including chat interfaces. Provides project context, component docs, and usage examples. Applies when working with shadcn\u002Fui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for \"shadcn init\", \"create an app with --preset\", \"switch to --preset\", and for building a design system or applying a DESIGN.md or brand spec to shadcn\u002Fui.","https:\u002F\u002Fgithub.com\u002FYYC-Cube\u002FYYC3-Knowledge-Base\u002Fblob\u002Fmain\u002FYYC3-UI-界面组件\u002FYYC3-UI-界面组件-ui\u002Fskills\u002Fshadcn\u002FSKILL.md",[],[]]