Plaid Integration Guide
This guide provides comprehensive documentation for developers integrating Plaid into OneLibro. Learn how to set up Plaid, work with the sandbox environment, implement the Link flow, sync transactions, and deploy to production.
Overview
OneLibro uses Plaid to securely connect users' bank accounts and retrieve transaction data. Plaid provides:
- Secure bank authentication - Users log in through Plaid, not OneLibro
- Read-only access - Cannot transfer funds or modify accounts
- 12,000+ institutions - Major banks, credit unions, and investment platforms
- Real-time data - Transaction updates typically within 6-24 hours
- Automatic categorization - Transactions are pre-categorized
Plaid API Products Used:
- Transactions: Access transaction history (up to 2 years)
- Auth: Access account/routing numbers (for ACH)
Prerequisites
1. Plaid Account
Sign up for a free Plaid account:
- Go to https://dashboard.plaid.com/signup
- Create account with email
- Verify email address
- Access Plaid Dashboard
2. Get API Credentials
From Plaid Dashboard:
- Click Team Settings → Keys
- Copy your credentials:
client_id: Your unique client identifiersandboxsecret: For testing (free, unlimited)developmentsecret: For testing with real banks (100 free connections)productionsecret: For live deployment (paid)
The Plaid sandbox is completely free with unlimited use. Perfect for development and testing!
Environment Configuration
Required Environment Variables
Add these to your .env.local file:
# Plaid Configuration
PLAID_CLIENT_ID=your_client_id_here
PLAID_SECRET=your_sandbox_secret_here
PLAID_ENV=sandbox
# Optional: Webhook URL (for production)
PLAID_WEBHOOK_URL=https://finance.yatheeshnagella.com/api/plaid/webhook
Environment Modes
| Mode | Use Case | Cost | Real Banks |
|---|---|---|---|
| sandbox | Development, testing | Free | ❌ Fake banks only |
| development | Pre-production testing | Free (100 items) | ✅ Real banks |
| production | Live users | Paid per item | ✅ Real banks |
Recommendation: Start with sandbox, move to development for final testing, then production for launch.
Sandbox Testing
Sandbox Banks
Plaid sandbox provides fake banks for testing:
- First Platypus Bank - Multi-account bank
- Tartan Bank - Popular test bank
- Houndstooth Bank - Credit cards
- Tattersall Federal Credit Union - Credit union
Sandbox Credentials
Use these credentials to test bank connections:
| Username | Password | Result |
|---|---|---|
user_good | pass_good | ✅ Successful connection |
user_bad | pass_good | ❌ Invalid credentials error |
user_custom | pass_good | ⚠️ Requires MFA (test 2FA flow) |
Test Accounts:
- Checking: Usually account ending in ...0000
- Savings: Usually account ending in ...1111
- Credit Card: Usually account ending in ...3333
Use user_custom / pass_good to test multi-factor authentication. Plaid will prompt for a code - enter any 4 digits.
Plaid Link Flow
Step 1: Create Link Token (Backend)
API Endpoint: POST /api/plaid/create-link-token
// lib/plaid.ts
import { plaidClient } from '@/lib/plaid';
import { Products, CountryCode } from 'plaid';
export async function createLinkToken(userId: string) {
const response = await plaidClient.linkTokenCreate({
user: {
client_user_id: userId, // Your internal user ID
},
client_name: 'OneLibro',
products: [Products.Transactions, Products.Auth],
country_codes: [CountryCode.Us],
language: 'en',
webhook: process.env.PLAID_WEBHOOK_URL, // Optional
});
return {
linkToken: response.data.link_token,
expiration: response.data.expiration,
};
}
Request:
POST /api/plaid/create-link-token
Authorization: Bearer <user-session-token>
Content-Type: application/json
{
"userId": "user-uuid-here"
}
Response:
{
"link_token": "link-sandbox-abc123...",
"expiration": "2025-01-25T12:00:00Z"
}
Step 2: Open Plaid Link (Frontend)
Component: PlaidLink.tsx
import { usePlaidLink } from 'react-plaid-link';
function PlaidLinkButton() {
const [linkToken, setLinkToken] = useState(null);
// Fetch link token on mount
useEffect(() => {
async function fetchLinkToken() {
const response = await fetch('/api/plaid/create-link-token', {
method: 'POST',
headers: { 'Authorization': `Bearer ${sessionToken}` },
body: JSON.stringify({ userId }),
});
const data = await response.json();
setLinkToken(data.link_token);
}
fetchLinkToken();
}, []);
// Handle success
const onSuccess = async (publicToken, metadata) => {
// Exchange public token for access token
await fetch('/api/plaid/exchange-token', {
method: 'POST',
body: JSON.stringify({ publicToken, metadata }),
});
};
// Initialize Plaid Link
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess,
});
return (
<button onClick={() => open()} disabled={!ready}>
Connect Bank Account
</button>
);
}
Step 3: Exchange Public Token (Backend)
After user completes Plaid Link, exchange the temporary public token for a permanent access token.
API Endpoint: POST /api/plaid/exchange-token
// lib/plaid.ts
export async function exchangePublicToken(publicToken: string) {
const response = await plaidClient.itemPublicTokenExchange({
public_token: publicToken,
});
return {
accessToken: response.data.access_token,
itemId: response.data.item_id,
};
}
Important:
- ✅ DO: Encrypt access tokens before storing in database
- ❌ DON'T: Store access tokens in plain text
- ❌ DON'T: Send access tokens to frontend
Encryption Example:
import crypto from 'crypto';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!; // 32-byte key
const IV_LENGTH = 16;
function encryptAccessToken(token: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(
'aes-256-cbc',
Buffer.from(ENCRYPTION_KEY, 'hex'),
iv
);
let encrypted = cipher.update(token, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
Step 4: Fetch Accounts
After exchanging tokens, fetch the user's accounts.
// lib/plaid.ts
export async function getAccounts(accessToken: string) {
const response = await plaidClient.accountsGet({
access_token: accessToken,
});
return response.data.accounts.map(account => ({
account_id: account.account_id,
name: account.name,
type: account.type, // "depository", "credit", "loan", etc.
subtype: account.subtype, // "checking", "savings", "credit card"
balances: {
current: account.balances.current,
available: account.balances.available,
limit: account.balances.limit,
currency: account.balances.iso_currency_code || 'USD',
},
}));
}
Store in Database:
INSERT INTO accounts (
user_id,
plaid_item_id,
plaid_account_id,
account_name,
account_type,
account_subtype,
current_balance,
available_balance,
currency_code
) VALUES (...);
Transaction Syncing
Initial Transaction Fetch
When first connecting an account, use transactionsSync to get historical transactions (up to 2 years).
// lib/plaid.ts
export async function syncTransactions(
accessToken: string,
cursor?: string
) {
const response = await plaidClient.transactionsSync({
access_token: accessToken,
cursor: cursor, // undefined for first sync
});
return {
added: response.data.added,
modified: response.data.modified,
removed: response.data.removed,
nextCursor: response.data.next_cursor,
hasMore: response.data.has_more,
};
}
Pagination Loop:
let cursor: string | undefined;
let hasMore = true;
while (hasMore) {
const result = await syncTransactions(accessToken, cursor);
// Process transactions
await saveTransactions(result.added);
await updateTransactions(result.modified);
await deleteTransactions(result.removed);
// Update for next iteration
cursor = result.nextCursor;
hasMore = result.hasMore;
}
// Save cursor for next sync
await saveCursor(itemId, cursor);
Incremental Syncing
For subsequent syncs, use the saved cursor to only fetch new/updated transactions.
API Endpoint: POST /api/plaid/sync-transactions
async function incrementalSync(itemId: string) {
// Get saved cursor from database
const { cursor } = await getCursor(itemId);
// Fetch only new transactions
const result = await syncTransactions(accessToken, cursor);
console.log(`Added: ${result.added.length}`);
console.log(`Modified: ${result.modified.length}`);
console.log(`Removed: ${result.removed.length}`);
// Process changes
await processTransactionChanges(result);
// Update cursor
await saveCursor(itemId, result.nextCursor);
}
When to Sync:
- Initial: Immediately after account connection
- Automatic: Daily via cron job or webhook
- Manual: When user clicks "Sync" button
- Webhook: When Plaid sends update notification
Webhook Integration
Setup Webhook URL
Production: https://finance.yatheeshnagella.com/api/plaid/webhook
Development: Use ngrok or similar tunneling tool
Webhook Handler
API Endpoint: POST /api/plaid/webhook
// app/api/plaid/webhook/route.ts
import { verifyWebhookSignature } from '@/lib/plaid';
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get('plaid-verification');
// Verify webhook is from Plaid
if (!verifyWebhookSignature(body, signature, WEBHOOK_SECRET)) {
return new Response('Invalid signature', { status: 401 });
}
const data = JSON.parse(body);
// Handle different webhook types
switch (data.webhook_type) {
case 'TRANSACTIONS':
await handleTransactionsWebhook(data);
break;
case 'ITEM':
await handleItemWebhook(data);
break;
default:
console.log('Unknown webhook type:', data.webhook_type);
}
return new Response('OK', { status: 200 });
}
Common Webhook Events
| Webhook Code | Description | Action |
|---|---|---|
SYNC_UPDATES_AVAILABLE | New transactions available | Trigger sync |
DEFAULT_UPDATE | Regular transaction update | Trigger sync |
INITIAL_UPDATE | Initial historical transactions ready | Fetch all |
ITEM_LOGIN_REQUIRED | User needs to re-authenticate | Notify user |
ERROR | Plaid error occurred | Log and alert |
Error Handling
Common Plaid Errors
1. Invalid Credentials
{
error_code: 'INVALID_CREDENTIALS',
error_message: 'The provided credentials were incorrect'
}
Solution: Prompt user to reconnect account
2. Item Login Required
{
error_code: 'ITEM_LOGIN_REQUIRED',
error_message: 'User needs to re-authenticate'
}
Solution: Use update mode Link token to re-authenticate
const { linkToken } = await createUpdateLinkToken(accessToken);
// Open Plaid Link with this token for user to re-login
3. Product Not Ready
{
error_code: 'PRODUCT_NOT_READY',
error_message: 'Product is still being set up'
}
Solution: Wait 5-10 minutes and retry
4. Rate Limit Exceeded
{
error_code: 'RATE_LIMIT_EXCEEDED',
error_message: 'Too many requests'
}
Solution: Implement exponential backoff
async function retryWithBackoff(fn, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (error) {
if (error.error_code === 'RATE_LIMIT_EXCEEDED' && i < maxRetries - 1) {
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
continue;
}
throw error;
}
}
}
Sandbox Testing Scenarios
Test Successful Connection
Bank: First Platypus Bank
Username: user_good
Password: pass_good
Expected: Connection succeeds, accounts load
Test Invalid Credentials
Bank: First Platypus Bank
Username: user_bad
Password: pass_good
Expected: "Invalid credentials" error
Test MFA Flow
Bank: First Platypus Bank
Username: user_custom
Password: pass_good
MFA Code: 1234 (any 4 digits)
Expected: MFA prompt appears, then connection succeeds
Test Re-Authentication
// Simulate expired login
await sandboxResetLogin(accessToken);
// User will need to re-authenticate
// Create update mode link token
const { linkToken } = await createUpdateLinkToken(accessToken);
Test Webhook
// Fire test webhook (sandbox only)
await sandboxFireWebhook(accessToken, 'DEFAULT_UPDATE');
// Your webhook handler should receive the event
Production Deployment
1. Switch to Production Environment
# .env.local (local) or Vercel (production)
PLAID_CLIENT_ID=your_client_id
PLAID_SECRET=your_production_secret # ⚠️ Use production secret!
PLAID_ENV=production
PLAID_WEBHOOK_URL=https://finance.yatheeshnagella.com/api/plaid/webhook
2. Enable Production Access
- Go to Plaid Dashboard
- Click Go Live or Request Production Access
- Fill out application:
- Company information
- Use case description
- Compliance questionnaire
- Wait for approval (typically 1-3 business days)
3. Configure Webhook
In Plaid Dashboard:
- Go to Team Settings → Webhooks
- Add webhook URL:
https://finance.yatheeshnagella.com/api/plaid/webhook - Select events to receive
- Save configuration
4. Test in Production
Use your own real bank account to test:
- Connect your bank through Plaid Link
- Verify accounts appear correctly
- Check transactions sync properly
- Test webhook delivery
- Verify re-authentication flow
Only test with accounts you own. Never test with fake credentials in production mode.
Best Practices
1. Encrypt Access Tokens
Always encrypt Plaid access tokens before storing in database.
// ✅ GOOD
const encryptedToken = encryptAccessToken(accessToken);
await db.insert({ access_token: encryptedToken });
// ❌ BAD
await db.insert({ access_token: accessToken });
2. Handle Webhooks Asynchronously
Process webhooks in background jobs, return 200 immediately.
export async function POST(request: Request) {
const data = await request.json();
// Queue background job
await queueSyncJob(data.item_id);
// Return immediately
return new Response('OK', { status: 200 });
}
3. Implement Retry Logic
Plaid API can be temporarily unavailable. Retry with exponential backoff.
4. Log Everything
Log all Plaid API calls for debugging:
console.log('[Plaid] Creating link token for user:', userId);
console.log('[Plaid] Exchanging public token');
console.log('[Plaid] Syncing transactions, cursor:', cursor);
5. Monitor Error Rates
Track Plaid error codes in your monitoring system (e.g., Sentry):
if (error.error_code) {
Sentry.captureException(error, {
tags: { plaid_error_code: error.error_code },
});
}
Troubleshooting
Link Token Creation Fails
Symptoms: linkTokenCreate() returns error
Possible Causes:
- Invalid
PLAID_CLIENT_IDorPLAID_SECRET - Wrong
PLAID_ENVsetting - Network/firewall issue
Solutions:
- Verify credentials in Plaid Dashboard
- Check
PLAID_ENVmatches your secret (sandbox/development/production) - Test API connectivity:
curl https://sandbox.plaid.com
Accounts Not Appearing
Symptoms: User connects bank but no accounts show
Possible Causes:
- Public token exchange failed
- Accounts not being saved to database
- User selected no accounts in Plaid Link
Solutions:
- Check server logs for token exchange errors
- Verify database insert succeeded
- Log
metadatainonSuccessto see selected accounts
Transactions Not Syncing
Symptoms: No new transactions after initial sync
Possible Causes:
- Cursor not being saved
- Webhook not configured
- Manual sync not implemented
Solutions:
- Verify cursor is saved after each sync
- Check webhook endpoint is publicly accessible
- Implement manual sync button for users
Webhook Not Receiving Events
Symptoms: Webhook URL configured but no events received
Possible Causes:
- URL not publicly accessible
- SSL certificate issues
- Webhook URL not saved in Plaid Dashboard
Solutions:
- Test webhook URL:
curl -X POST https://your-domain.com/api/plaid/webhook - Verify SSL certificate is valid
- Re-save webhook URL in Plaid Dashboard
Rate Limits
Plaid enforces rate limits to prevent abuse:
| Environment | Requests/Second | Requests/Minute |
|---|---|---|
| Sandbox | Unlimited | Unlimited |
| Development | 30 | 1,800 |
| Production | 30 | 1,800 |
Best Practices:
- Cache link tokens (valid for 4 hours)
- Batch transaction syncs
- Implement exponential backoff on rate limit errors
Related Documentation
- Plaid Official Docs
- Plaid API Reference
- Account Management - User-facing account features
- Transactions - Transaction management
- Deployment Guide - Production deployment
Summary
Plaid integration in OneLibro provides:
- ✅ Secure bank account connections
- ✅ Automatic transaction syncing
- ✅ Support for 12,000+ institutions
- ✅ Free sandbox for unlimited testing
- ✅ Webhook support for real-time updates
- ✅ Encrypted access token storage
- ✅ Comprehensive error handling
Follow this guide to successfully integrate Plaid into your OneLibro deployment!