[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill-10cd3015-939b-4031-ac86-e471444f74b4":3,"$fwWRBgypSqX-tKChWnSgal23EGHQ2bN7AJDWyU49ENkg":42},{"id":4,"title":5,"description":6,"categoryId":7,"moduleId":8,"tags":9,"prompt":10,"icon":11,"source":12,"sourceUrl":13,"authorId":14,"authorName":15,"isPublic":16,"stars":17,"runs":18,"createdAt":19,"updatedAt":19,"module":20,"category":27,"packages":33},"10cd3015-939b-4031-ac86-e471444f74b4","plaid-fintech","Plaid API集成的高级模式包括Link令牌","cat_coding_backend","mod_coding","sickn33,coding","---\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","","imported","https:\u002F\u002Fgithub.com\u002Fsickn33\u002Fantigravity-awesome-skills","user_system_seed","SkillOPIC",true,186,1622,"2026-05-16 13:34:02",{"id":8,"name":21,"slug":22,"icon":23,"description":24,"sort":25,"createdAt":26},"编程开发","coding","mdi-code-braces","代码生成、调试、审查，提升开发效率",2,"2026-05-16 12:53:40",{"id":7,"name":28,"slug":29,"icon":30,"description":31,"moduleId":8,"sort":25,"skillCount":32,"createdAt":26},"后端开发","backend","mdi-server","API、数据库、服务端架构",296,[34],{"id":35,"skillId":4,"version":36,"fileName":37,"fileSize":38,"filePath":39,"fileHash":40,"manifest":41,"createdAt":19},"6ab2fa87-ce68-433a-a097-f73e8d62755a","1.0.0","plaid-fintech.zip",6919,"uploads\u002Fskills\u002F10cd3015-939b-4031-ac86-e471444f74b4\u002Fplaid-fintech.zip","8dc11e14d1d9d016aa8874076f957a858d7928303f16de93aa81c98c61c643e2","[{\"path\":\"SKILL.md\",\"isDirectory\":false,\"size\":22938}]",{"code":43,"message":44,"data":45},200,"success",{"items":46,"stats":47,"page":50},[],{"averageRating":48,"totalRatings":48,"ratingCounts":49},0,[48,48,48,48,48],{"limit":51,"offset":48,"hasMore":52,"nextOffset":51,"ratedOnly":16},15,false]