@crowdevelopment/convex-plaid
v0.5.3
Published
A Plaid component for Convex - bank accounts, transactions, and liabilities.
Maintainers
Readme
@crowdevelopment/convex-plaid
A Convex component for integrating Plaid banking into your application.
Features
- 🔗 Plaid Link - Create link tokens and exchange public tokens for access
- 🏦 Accounts - Fetch and store bank/credit accounts with real-time balances
- 💸 Transactions - Cursor-based incremental sync with merchant and category data
- 💳 Liabilities - Credit card APRs, payment due dates, statement balances
- 🔄 Recurring Streams - Automatic subscription and income detection
- 🔔 Webhook Handling - JWT signature verification and auto-sync triggers
- 🔐 Re-auth Flow - Update Link mode for expired credentials
- ⚛️ React Hooks -
usePlaidLinkanduseUpdatePlaidLinkfor seamless integration - 🔒 Encryption - Access tokens encrypted with JWE (A256GCM) before storage
Quick Start
1. Install the Component
npm install @crowdevelopment/convex-plaid2. Add to Your Convex App
Create or update convex/convex.config.ts:
import { defineApp } from "convex/server";
import plaid from "@crowdevelopment/convex-plaid/convex.config";
const app = defineApp();
app.use(plaid);
export default app;3. Set Up Environment Variables
Add these to your Convex Dashboard → Settings → Environment Variables:
| Variable | Description |
| -------- | ----------- |
| PLAID_CLIENT_ID | Your Plaid client ID from Plaid Dashboard → Keys |
| PLAID_SECRET | Your Plaid secret key (sandbox/development/production) |
| PLAID_ENV | sandbox, development, or production |
| ENCRYPTION_KEY | Base64-encoded 256-bit key (see below) |
Generate an encryption key:
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"4. Configure Plaid Webhooks
- Go to Plaid Dashboard → Developers → Webhooks
- Click "Add webhook"
- Enter your webhook URL:
(Find your deployment name in the Convex dashboard)https://<your-convex-deployment>.convex.site/plaid/webhook - Webhooks are registered per-item when calling
createLinkToken
5. Register Webhook Routes
Create convex/http.ts:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@crowdevelopment/convex-plaid";
const http = httpRouter();
// Register Plaid webhook handler at /plaid/webhook
registerRoutes(http, components.plaid, {
webhookPath: "/plaid/webhook",
plaidConfig: {
PLAID_CLIENT_ID: process.env.PLAID_CLIENT_ID!,
PLAID_SECRET: process.env.PLAID_SECRET!,
PLAID_ENV: process.env.PLAID_ENV!,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY!,
},
});
export default http;6. Use the Component
Create convex/plaid.ts:
import { action, query } from "./_generated/server";
import { components } from "./_generated/api";
import { Plaid } from "@crowdevelopment/convex-plaid";
import { v } from "convex/values";
const plaidClient = new Plaid(components.plaid, {
PLAID_CLIENT_ID: process.env.PLAID_CLIENT_ID!,
PLAID_SECRET: process.env.PLAID_SECRET!,
PLAID_ENV: process.env.PLAID_ENV!,
ENCRYPTION_KEY: process.env.ENCRYPTION_KEY!,
});
// Create a link token for Plaid Link
export const createLinkToken = action({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await plaidClient.createLinkToken(ctx, {
userId: args.userId,
products: ["transactions", "liabilities"],
});
},
});
// Exchange public token after user completes Plaid Link
export const exchangePublicToken = action({
args: { publicToken: v.string(), userId: v.string() },
handler: async (ctx, args) => {
return await plaidClient.exchangePublicToken(ctx, args);
},
});
// Sync all data for a newly connected item
export const onboardItem = action({
args: { plaidItemId: v.string() },
handler: async (ctx, args) => {
return await plaidClient.onboardItem(ctx, args);
},
});
// Query accounts for a user
export const getAccountsByUser = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await ctx.runQuery(plaidClient.api.getAccountsByUser, args);
},
});API Reference
Plaid Client
import { Plaid } from "@crowdevelopment/convex-plaid";
const plaidClient = new Plaid(components.plaid, {
PLAID_CLIENT_ID: "...", // From Plaid Dashboard
PLAID_SECRET: "...", // From Plaid Dashboard
PLAID_ENV: "sandbox", // "sandbox" | "development" | "production"
ENCRYPTION_KEY: "...", // Base64-encoded 256-bit key
});Methods
| Method | Description |
| ------ | ----------- |
| createLinkToken() | Create a Plaid Link token |
| exchangePublicToken() | Exchange public token, create item |
| fetchAccounts() | Fetch and store accounts |
| syncTransactions() | Incremental transaction sync |
| fetchLiabilities() | Fetch credit card liabilities |
| fetchRecurringStreams() | Detect subscriptions/income |
| createUpdateLinkToken() | Create re-auth link token |
| completeReauth() | Complete re-auth flow |
| onboardItem() | Run all sync operations |
createLinkToken
await plaidClient.createLinkToken(ctx, {
userId: "user_123", // Required: your user identifier
products: ["transactions"], // Optional: Plaid products
webhookUrl: "https://...", // Optional: webhook URL
});syncTransactions
const result = await plaidClient.syncTransactions(ctx, {
plaidItemId: "...",
maxPages: 10, // Optional: max pages per call (default: 10)
maxTransactions: 5000, // Optional: max transactions (default: 5000)
});
if (result.hasMore) {
// Schedule another sync to continue
}Component Queries
Access data directly via the component's public queries:
import { query } from "./_generated/server";
import { components } from "./_generated/api";
import { Plaid } from "@crowdevelopment/convex-plaid";
const plaidClient = new Plaid(components.plaid, { /* config */ });
// List accounts for a user
export const getUserAccounts = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await ctx.runQuery(plaidClient.api.getAccountsByUser, args);
},
});
// List transactions for a user
export const getUserTransactions = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
return await ctx.runQuery(plaidClient.api.getTransactionsByUser, args);
},
});Available Public Queries
| Query | Arguments | Description |
| ----- | --------- | ----------- |
| getItemsByUser | userId | All linked items for a user |
| getItem | plaidItemId | Single item by ID |
| getAccountsByUser | userId | All accounts for a user |
| getAccountsByItem | plaidItemId | Accounts for a specific item |
| getTransactionsByUser | userId, startDate?, endDate?, limit? | Transactions with filtering |
| getTransactionsByAccount | accountId, limit? | Transactions for an account |
| getLiabilitiesByUser | userId | All credit card liabilities |
| getLiabilitiesByItem | plaidItemId | Liabilities for a specific item |
| getRecurringStreamsByUser | userId | All recurring streams |
| getRecurringStreamsByItem | plaidItemId | Streams for a specific item |
| getActiveSubscriptions | userId | Active subscription streams |
| getRecurringIncome | userId | Active income streams |
| getSubscriptionsSummary | userId | Count, monthly total, breakdown |
Available Public Mutations
| Mutation | Arguments | Description |
| -------- | --------- | ----------- |
| deletePlaidItem | plaidItemId | Delete item and all associated data |
React Hooks
usePlaidLink
Main hook for connecting new bank accounts:
import { usePlaidLink } from "@crowdevelopment/convex-plaid/react";
import { api } from "../convex/_generated/api";
import { useAction } from "convex/react";
function ConnectBank({ userId }: { userId: string }) {
const onboardItem = useAction(api.plaid.onboardItem);
const { open, ready, isLoading, isExchanging } = usePlaidLink({
createLinkToken: api.plaid.createLinkToken,
exchangePublicToken: api.plaid.exchangePublicToken,
userId,
products: ["transactions", "liabilities"],
onSuccess: async (plaidItemId) => {
await onboardItem({ plaidItemId });
},
});
return (
<button onClick={open} disabled={!ready || isLoading}>
{isLoading ? "Loading..." : isExchanging ? "Connecting..." : "Connect Bank"}
</button>
);
}useUpdatePlaidLink
Hook for re-authentication when credentials expire:
import { useUpdatePlaidLink } from "@crowdevelopment/convex-plaid/react";
function ReauthBank({ plaidItemId }: { plaidItemId: string }) {
const { open, ready, refreshToken } = useUpdatePlaidLink({
createUpdateLinkToken: api.plaid.createUpdateLinkToken,
completeReauth: api.plaid.completeReauth,
plaidItemId,
onSuccess: () => console.log("Re-authenticated!"),
});
const handleReauth = async () => {
await refreshToken();
open();
};
return <button onClick={handleReauth}>Re-authenticate</button>;
}Webhook Events
The component automatically handles these Plaid webhook events:
| Event | Action |
| ----- | ------ |
| TRANSACTIONS.SYNC_UPDATES_AVAILABLE | Auto-triggers syncTransactions |
| ITEM.ERROR | Updates item status to error |
| ITEM.PENDING_EXPIRATION | Marks item as needs_reauth |
| ITEM.USER_PERMISSION_REVOKED | Deactivates item |
| LIABILITIES.DEFAULT_UPDATE | Auto-triggers fetchLiabilities |
Custom Webhook Handlers
Add custom logic to webhook events:
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { registerRoutes } from "@crowdevelopment/convex-plaid";
const http = httpRouter();
registerRoutes(http, components.plaid, {
webhookPath: "/plaid/webhook",
plaidConfig: { /* ... */ },
onWebhook: async (ctx, webhookType, webhookCode, itemId, payload) => {
// Called for ALL events - useful for logging/analytics
console.log("Plaid webhook:", webhookType, webhookCode);
},
});
export default http;JWT Verification
Webhooks are verified using Plaid's ES256 JWT signature:
- Fetches Plaid's public key from their JWKS endpoint
- Verifies the JWT signature
- Validates request body hash
- Checks timestamp is within 5 minutes
- Deduplicates webhooks (24-hour window)
Database Schema
The component creates these tables in its namespace. All monetary values are stored as MILLIUNITS (amount × 1000) to avoid floating-point precision errors.
plaidItems
| Field | Type | Description |
| ----- | ---- | ----------- |
| userId | string | Host app user ID |
| itemId | string | Plaid item_id |
| accessToken | string | JWE encrypted access token |
| cursor | string? | Transaction sync cursor |
| institutionId | string? | Bank identifier |
| institutionName | string? | "Chase", "Wells Fargo", etc. |
| status | string | pending, syncing, active, error, needs_reauth |
| syncError | string? | Error message from last sync |
| createdAt | number | Unix timestamp |
| lastSyncedAt | number? | Last successful sync timestamp |
plaidAccounts
| Field | Type | Description |
| ----- | ---- | ----------- |
| userId | string | Host app user ID |
| plaidItemId | string | Reference to plaidItem |
| accountId | string | Plaid account_id |
| name | string | Account name |
| type | string | credit, depository, loan |
| subtype | string? | credit card, checking, savings |
| mask | string? | Last 4 digits |
| balances.available | number? | Available balance (milliunits) |
| balances.current | number? | Current balance (milliunits) |
| balances.limit | number? | Credit limit (milliunits) |
plaidTransactions
| Field | Type | Description |
| ----- | ---- | ----------- |
| transactionId | string | Plaid transaction_id |
| accountId | string | Plaid account_id |
| amount | number | Amount in milliunits |
| date | string | ISO date (e.g., "2025-01-15") |
| name | string | Raw transaction name |
| merchantName | string? | Cleaned merchant name |
| pending | boolean | Is pending |
| categoryPrimary | string? | Primary category |
| categoryDetailed | string? | Detailed category |
plaidCreditCardLiabilities
| Field | Type | Description |
| ----- | ---- | ----------- |
| accountId | string | Plaid account_id |
| aprs | array | APR entries |
| isOverdue | boolean | Payment overdue |
| minimumPaymentAmount | number? | Minimum payment (milliunits) |
| nextPaymentDueDate | string? | Next due date |
| lastStatementBalance | number? | Statement balance (milliunits) |
plaidRecurringStreams
| Field | Type | Description |
| ----- | ---- | ----------- |
| streamId | string | Plaid stream_id |
| description | string | Stream name |
| merchantName | string? | Cleaned merchant |
| averageAmount | number | Average amount (milliunits) |
| frequency | string | WEEKLY, BIWEEKLY, MONTHLY, ANNUALLY |
| status | string | MATURE, EARLY_DETECTION, TOMBSTONED |
| type | string | inflow (income) or outflow (expense) |
| isActive | boolean | Currently active |
| predictedNextDate | string? | Next expected date |
Example App
Check out the example setup in the example/ directory.
Troubleshooting
"Not authenticated" errors
The component doesn't use ctx.auth. Pass userId as a string argument to all methods.
Empty data after connecting
- Ensure you call
onboardItemafterexchangePublicToken - Check the item status - if
error, checksyncErrorfield - Verify environment variables are set correctly
Webhooks not working
- Check webhook URL:
https://<deployment>.convex.site/plaid/webhook - Verify
plaidConfigis passed toregisterRoutes - Check Convex logs for verification errors
Re-auth required
When item status is needs_reauth:
- Call
createUpdateLinkToken({ plaidItemId }) - Open Plaid Link in update mode
- After user completes, call
completeReauth({ plaidItemId })
License
MIT
