Skip to Content
ConsolePlaid Integration

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

  1. Plaid Account: Sign up at dashboard.plaid.com 
  2. Environment Variables: Configure in .env.local or Vercel
  3. 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

// 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

// 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

IssueSolution
Token decryption failedVerify TOKEN_ENCRYPTION_KEY matches in all environments
Webhooks not receivedCheck webhook URL is publicly accessible
Sync delaysImplement manual refresh button as fallback
Missing transactionsCheck cursor is properly stored and updated

Vercel Deployment

Critical steps for Vercel:

  1. Use CLI to set environment variables (avoid UI)
  2. Verify no newlines in encryption keys
  3. Force redeploy after environment changes
  4. 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

  1. Security & Encryption - Advanced security patterns
  2. API Reference - Complete API documentation
  3. Platform Services Integration - Backend implementation details
Last updated on