Skip to main content

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:

  1. Go to https://dashboard.plaid.com/signup
  2. Create account with email
  3. Verify email address
  4. Access Plaid Dashboard

2. Get API Credentials

From Plaid Dashboard:

  1. Click Team SettingsKeys
  2. Copy your credentials:
    • client_id: Your unique client identifier
    • sandbox secret: For testing (free, unlimited)
    • development secret: For testing with real banks (100 free connections)
    • production secret: For live deployment (paid)
Free Sandbox

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

ModeUse CaseCostReal Banks
sandboxDevelopment, testingFree❌ Fake banks only
developmentPre-production testingFree (100 items)✅ Real banks
productionLive usersPaid 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:

UsernamePasswordResult
user_goodpass_good✅ Successful connection
user_badpass_good❌ Invalid credentials error
user_custompass_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
MFA Testing

Use user_custom / pass_good to test multi-factor authentication. Plaid will prompt for a code - enter any 4 digits.


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"
}

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 CodeDescriptionAction
SYNC_UPDATES_AVAILABLENew transactions availableTrigger sync
DEFAULT_UPDATERegular transaction updateTrigger sync
INITIAL_UPDATEInitial historical transactions readyFetch all
ITEM_LOGIN_REQUIREDUser needs to re-authenticateNotify user
ERRORPlaid error occurredLog 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

  1. Go to Plaid Dashboard
  2. Click Go Live or Request Production Access
  3. Fill out application:
    • Company information
    • Use case description
    • Compliance questionnaire
  4. Wait for approval (typically 1-3 business days)

3. Configure Webhook

In Plaid Dashboard:

  1. Go to Team SettingsWebhooks
  2. Add webhook URL: https://finance.yatheeshnagella.com/api/plaid/webhook
  3. Select events to receive
  4. Save configuration

4. Test in Production

Use your own real bank account to test:

  1. Connect your bank through Plaid Link
  2. Verify accounts appear correctly
  3. Check transactions sync properly
  4. Test webhook delivery
  5. Verify re-authentication flow
Production Testing

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

Symptoms: linkTokenCreate() returns error

Possible Causes:

  1. Invalid PLAID_CLIENT_ID or PLAID_SECRET
  2. Wrong PLAID_ENV setting
  3. Network/firewall issue

Solutions:

  • Verify credentials in Plaid Dashboard
  • Check PLAID_ENV matches 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:

  1. Public token exchange failed
  2. Accounts not being saved to database
  3. User selected no accounts in Plaid Link

Solutions:

  • Check server logs for token exchange errors
  • Verify database insert succeeded
  • Log metadata in onSuccess to see selected accounts

Transactions Not Syncing

Symptoms: No new transactions after initial sync

Possible Causes:

  1. Cursor not being saved
  2. Webhook not configured
  3. 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:

  1. URL not publicly accessible
  2. SSL certificate issues
  3. 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:

EnvironmentRequests/SecondRequests/Minute
SandboxUnlimitedUnlimited
Development301,800
Production301,800

Best Practices:

  • Cache link tokens (valid for 4 hours)
  • Batch transaction syncs
  • Implement exponential backoff on rate limit errors


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!