Plaid Integration
Overview
The Console application integrates with Plaid to provide secure bank connectivity, real-time balance updates, and transaction synchronization. This guide covers implementation, configuration, and troubleshooting.
Quick Start
Prerequisites
- Plaid Account: Sign up at dashboard.plaid.comÂ
- Environment Variables: Configure in
.env.local
or Vercel - Supabase: Database tables for storing encrypted tokens
Environment Setup
# .env.local
PLAID_CLIENT_ID=your_client_id_here
PLAID_SECRET=your_secret_key_here
NEXT_PUBLIC_PLAID_ENV=sandbox
PLAID_PRODUCTS=accounts,transactions,auth
PLAID_COUNTRY_CODES=US,CA
TOKEN_ENCRYPTION_KEY=your_32_byte_hex_key
USE_SUPABASE_FOR_PLAID=true
⚠️ Critical: For Vercel deployments, use CLI to avoid newlines:
printf "your_key_here" | vercel env add TOKEN_ENCRYPTION_KEY production
Frontend Implementation
Plaid Link Component
// components/PlaidLink.tsx
import { usePlaidLink } from 'react-plaid-link'
import { useState, useCallback, useEffect } from 'react'
export default function PlaidLinkButton() {
const [linkToken, setLinkToken] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Generate link token
useEffect(() => {
const createLinkToken = async () => {
const response = await fetch('/api/plaid/create-link-token', {
method: 'POST',
})
const data = await response.json()
setLinkToken(data.link_token)
}
createLinkToken()
}, [])
// Handle successful connection
const onSuccess = useCallback(async (public_token: string, metadata: any) => {
setLoading(true)
try {
// Exchange public token for access token
const response = await fetch('/api/plaid/exchange-token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
public_token,
institution: metadata.institution,
accounts: metadata.accounts
})
})
if (response.ok) {
const data = await response.json()
console.log('Bank connected successfully:', data)
// Trigger initial sync
await fetch('/api/plaid/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id: data.item_id })
})
// Refresh UI
window.location.reload()
}
} catch (error) {
console.error('Failed to connect bank:', error)
} finally {
setLoading(false)
}
}, [])
const config = {
token: linkToken,
onSuccess,
onExit: (err: any, metadata: any) => {
if (err) console.error('Plaid Link error:', err)
}
}
const { open, ready } = usePlaidLink(config)
return (
<button
onClick={() => open()}
disabled={!ready || loading}
className="btn btn-primary"
>
{loading ? 'Connecting...' : 'Connect Bank Account'}
</button>
)
}
Account Balance Display
// components/AccountBalances.tsx
import { useEffect, useState } from 'react'
import { formatCurrency } from '@/lib/utils'
interface Account {
account_id: string
name: string
type: string
subtype: string
balances: {
available: number | null
current: number | null
limit: number | null
}
}
export default function AccountBalances() {
const [accounts, setAccounts] = useState<Account[]>([])
const [loading, setLoading] = useState(true)
const [lastSync, setLastSync] = useState<Date | null>(null)
useEffect(() => {
fetchAccounts()
// Set up real-time updates via WebSocket
const ws = new WebSocket(process.env.NEXT_PUBLIC_WS_URL!)
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.event === 'balance.updated') {
updateAccountBalance(data.accountId, data.balance)
}
}
return () => ws.close()
}, [])
const fetchAccounts = async () => {
try {
const response = await fetch('/api/plaid/accounts')
const data = await response.json()
setAccounts(data.accounts)
setLastSync(new Date(data.last_sync))
} catch (error) {
console.error('Failed to fetch accounts:', error)
} finally {
setLoading(false)
}
}
const updateAccountBalance = (accountId: string, newBalance: any) => {
setAccounts(prev =>
prev.map(account =>
account.account_id === accountId
? { ...account, balances: newBalance }
: account
)
)
}
const refreshBalances = async () => {
setLoading(true)
await fetch('/api/plaid/refresh', { method: 'POST' })
await fetchAccounts()
}
if (loading) return <div>Loading accounts...</div>
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">Bank Accounts</h2>
<button onClick={refreshBalances} className="btn btn-secondary">
Refresh Balances
</button>
</div>
{accounts.map(account => (
<div key={account.account_id} className="card p-4">
<div className="flex justify-between">
<div>
<h3 className="font-semibold">{account.name}</h3>
<p className="text-sm text-gray-500">
{account.subtype} • {account.type}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold">
{formatCurrency(account.balances.available || account.balances.current || 0)}
</p>
{account.balances.limit && (
<p className="text-sm text-gray-500">
Limit: {formatCurrency(account.balances.limit)}
</p>
)}
</div>
</div>
</div>
))}
{lastSync && (
<p className="text-sm text-gray-500">
Last synced: {lastSync.toLocaleString()}
</p>
)}
</div>
)
}
Transaction List
// components/TransactionList.tsx
import { useState, useEffect } from 'react'
import { formatCurrency, formatDate } from '@/lib/utils'
interface Transaction {
transaction_id: string
account_id: string
amount: number
date: string
name: string
merchant_name?: string
category: string[]
pending: boolean
}
export default function TransactionList() {
const [transactions, setTransactions] = useState<Transaction[]>([])
const [filter, setFilter] = useState('')
const [dateRange, setDateRange] = useState({ start: '', end: '' })
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchTransactions()
}, [dateRange])
const fetchTransactions = async () => {
try {
const params = new URLSearchParams({
...(dateRange.start && { start_date: dateRange.start }),
...(dateRange.end && { end_date: dateRange.end })
})
const response = await fetch(`/api/plaid/transactions?${params}`)
const data = await response.json()
setTransactions(data.transactions)
} catch (error) {
console.error('Failed to fetch transactions:', error)
} finally {
setLoading(false)
}
}
const filteredTransactions = transactions.filter(tx =>
tx.name.toLowerCase().includes(filter.toLowerCase()) ||
tx.merchant_name?.toLowerCase().includes(filter.toLowerCase()) ||
tx.category.some(cat => cat.toLowerCase().includes(filter.toLowerCase()))
)
if (loading) return <div>Loading transactions...</div>
return (
<div className="space-y-4">
<div className="flex gap-4">
<input
type="text"
placeholder="Search transactions..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="input flex-1"
/>
<input
type="date"
value={dateRange.start}
onChange={(e) => setDateRange(prev => ({ ...prev, start: e.target.value }))}
className="input"
/>
<input
type="date"
value={dateRange.end}
onChange={(e) => setDateRange(prev => ({ ...prev, end: e.target.value }))}
className="input"
/>
</div>
<div className="divide-y">
{filteredTransactions.map(tx => (
<div key={tx.transaction_id} className="py-3 flex justify-between">
<div>
<p className="font-medium">
{tx.merchant_name || tx.name}
{tx.pending && (
<span className="ml-2 text-xs bg-yellow-100 text-yellow-800 px-2 py-1 rounded">
Pending
</span>
)}
</p>
<p className="text-sm text-gray-500">
{tx.category.join(' • ')} • {formatDate(tx.date)}
</p>
</div>
<p className={`font-semibold ${tx.amount > 0 ? 'text-red-600' : 'text-green-600'}`}>
{tx.amount > 0 ? '-' : '+'}{formatCurrency(Math.abs(tx.amount))}
</p>
</div>
))}
</div>
</div>
)
}
Backend API Implementation
Create Link Token
// app/api/plaid/create-link-token/route.ts
import { plaidClient } from '@/lib/plaid'
import { getServerSession } from 'next-auth'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const session = await getServerSession()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
try {
const response = await plaidClient.linkTokenCreate({
user: {
client_user_id: session.user.id
},
client_name: 'Earna AI',
products: (process.env.PLAID_PRODUCTS?.split(',') || ['accounts', 'transactions']) as any,
country_codes: (process.env.PLAID_COUNTRY_CODES?.split(',') || ['US', 'CA']) as any,
language: 'en',
webhook: process.env.PLAID_WEBHOOK_URL,
redirect_uri: process.env.PLAID_REDIRECT_URI,
})
return NextResponse.json({ link_token: response.data.link_token })
} catch (error) {
console.error('Failed to create link token:', error)
return NextResponse.json(
{ error: 'Failed to create link token' },
{ status: 500 }
)
}
}
Exchange Public Token
// app/api/plaid/exchange-token/route.ts
import { plaidClient } from '@/lib/plaid'
import { encrypt } from '@/lib/encryption'
import { supabase } from '@/lib/supabase'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const session = await getServerSession()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { public_token, institution, accounts } = await request.json()
try {
// Exchange public token for access token
const exchangeResponse = await plaidClient.itemPublicTokenExchange({
public_token
})
const { access_token, item_id } = exchangeResponse.data
// Encrypt access token before storage
const encryptedToken = encrypt(access_token)
// Store connection in database
const { error } = await supabase
.from('plaid_connections')
.insert({
user_id: session.user.id,
item_id,
access_token: encryptedToken,
institution_name: institution.name,
institution_id: institution.institution_id,
accounts: accounts,
created_at: new Date().toISOString()
})
if (error) throw error
// Trigger initial sync
await fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/plaid/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ item_id })
})
return NextResponse.json({
success: true,
item_id,
institution_name: institution.name
})
} catch (error) {
console.error('Token exchange failed:', error)
return NextResponse.json(
{ error: 'Failed to connect bank account' },
{ status: 500 }
)
}
}
Webhook Handler
// app/api/plaid/webhook/route.ts
import { verifyWebhookSignature } from '@/lib/plaid'
import { temporalClient } from '@/lib/temporal'
import { NextResponse } from 'next/server'
export async function POST(request: Request) {
const body = await request.text()
const signature = request.headers.get('plaid-verification')
// Verify webhook signature
if (!verifyWebhookSignature(body, signature)) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 })
}
const webhook = JSON.parse(body)
switch (webhook.webhook_type) {
case 'TRANSACTIONS':
await handleTransactionWebhook(webhook)
break
case 'ITEM':
await handleItemWebhook(webhook)
break
case 'ACCOUNTS':
await handleAccountWebhook(webhook)
break
}
return NextResponse.json({ received: true })
}
async function handleTransactionWebhook(webhook: any) {
if (webhook.webhook_code === 'SYNC_UPDATES_AVAILABLE') {
// Start sync workflow
await temporalClient.start('transactionSyncWorkflow', {
args: [webhook.item_id],
taskQueue: 'plaid-sync',
workflowId: `sync-${webhook.item_id}-${Date.now()}`
})
}
}
async function handleItemWebhook(webhook: any) {
if (webhook.webhook_code === 'ERROR') {
// Handle item error (e.g., user needs to re-authenticate)
await notifyUserOfError(webhook.item_id, webhook.error)
}
}
Security Implementation
Token Encryption
// lib/encryption.ts
import crypto from 'crypto'
const algorithm = 'aes-256-cbc'
const key = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY!, 'hex')
export function encrypt(text: string): string {
const iv = crypto.randomBytes(16)
const cipher = crypto.createCipheriv(algorithm, key, iv)
let encrypted = cipher.update(text, 'utf8', 'hex')
encrypted += cipher.final('hex')
return iv.toString('hex') + ':' + encrypted
}
export function decrypt(text: string): string {
const parts = text.split(':')
const iv = Buffer.from(parts[0], 'hex')
const encryptedText = parts[1]
const decipher = crypto.createDecipheriv(algorithm, key, iv)
let decrypted = decipher.update(encryptedText, 'hex', 'utf8')
decrypted += decipher.final('utf8')
return decrypted
}
Row Level Security
-- Supabase RLS policies
CREATE POLICY "Users can only see their own connections"
ON plaid_connections
FOR ALL
USING (auth.uid() = user_id);
CREATE POLICY "Users can only see their own accounts"
ON plaid_accounts
FOR ALL
USING (auth.uid() = user_id);
CREATE POLICY "Users can only see their own transactions"
ON plaid_transactions
FOR ALL
USING (
account_id IN (
SELECT account_id
FROM plaid_accounts
WHERE user_id = auth.uid()
)
);
Troubleshooting
Common Issues
Issue | Solution |
---|---|
Token decryption failed | Verify TOKEN_ENCRYPTION_KEY matches in all environments |
Webhooks not received | Check webhook URL is publicly accessible |
Sync delays | Implement manual refresh button as fallback |
Missing transactions | Check cursor is properly stored and updated |
Vercel Deployment
Critical steps for Vercel:
- Use CLI to set environment variables (avoid UI)
- Verify no newlines in encryption keys
- Force redeploy after environment changes
- Check build logs for environment warnings
Testing in Sandbox
Use these credentials in Plaid Link sandbox:
- Username:
user_good
- Password:
pass_good
- PIN:
credential_good
Performance Optimization
Caching Strategy
// lib/cache.ts
import { Redis } from '@upstash/redis'
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!
})
export async function getCachedBalance(accountId: string) {
const cached = await redis.get(`balance:${accountId}`)
if (cached) return cached
const fresh = await fetchFreshBalance(accountId)
await redis.set(`balance:${accountId}`, fresh, { ex: 30 })
return fresh
}
Batch Processing
// Process transactions in batches
async function processBatchTransactions(transactions: Transaction[]) {
const batchSize = 100
for (let i = 0; i < transactions.length; i += batchSize) {
const batch = transactions.slice(i, i + batchSize)
await Promise.all([
storeBatch(batch),
enrichBatch(batch),
indexBatch(batch)
])
}
}
Next Steps
- Security & Encryption - Advanced security patterns
- API Reference - Complete API documentation
- Platform Services Integration - Backend implementation details
Last updated on