Is Plaid Down? Complete Status Check Guide + Developer Fixes
Plaid Link flow stuck?
Bank connections timing out?
Item logins failing unexpectedly?
Before blaming Plaid, verify if it's actually an outageβor a configuration, institution, or API credential issue. Here's your complete guide to checking Plaid status and resolving common problems fast.
Quick Check: Is Plaid Actually Down?
Don't assume it's Plaid. 70% of "Plaid down" reports are actually:
- Institution-specific outages (bank maintenance)
- Expired or revoked Item logins
- API credential issues (wrong environment keys)
- Rate limiting or quota problems
- Webhook configuration errors
1. Check Official Sources
Plaid Status Page:
π status.plaid.com
What to look for:
- β "All Systems Operational" = Plaid is fine
- β οΈ "Degraded Performance" = Some services affected
- π΄ "Partial Outage" or "Major Outage" = Plaid is down
Real-time updates:
- Link flow status (user authentication)
- Transactions API availability
- Auth/Balance/Identity product status
- Institution-specific issues
- Regional outages (US, Canada, UK, Europe)
Subscribe to updates:
- Email notifications for outages
- Webhook status alerts
- RSS feed for monitoring
Twitter/X Search:
π Search "Plaid down" on Twitter
Why it works:
- Developers report API issues instantly
- See if specific institutions affected
- Plaid support team responds here: @PlaidSupport
Pro tip: If 100+ tweets in the last hour mention "Plaid down" or "Plaid Link broken," it's likely a real outage (not just your integration).
Plaid Developer Community:
π community.plaid.com
Check for:
- Recent outage reports
- Institution-specific issues
- API changes or deprecations
- Known bugs affecting multiple developers
2. Check Service-Specific Status
Plaid has multiple products that can fail independently:
| Product | What It Does | Status Check |
|---|---|---|
| Link | User authentication flow | status.plaid.com |
| Transactions | Transaction history | Check status page under "Products" |
| Auth | Bank account/routing verification | Check status page |
| Balance | Real-time account balances | Check status page |
| Identity | Account holder information | Check status page |
| Investments | Holdings, securities, positions | Check status page |
| Liabilities | Loan, credit card debt data | Check status page |
| Assets | Income verification, assets | Check status page |
| Payment Initiation | Direct bank payments (UK/EU) | Check status page |
Your product might be down while Plaid globally is up.
How to check which product is affected:
- Visit status.plaid.com
- Look for specific product status
- Check "Incident History" for recent issues
- Review "Scheduled Maintenance" (usually Sunday nights)
- Subscribe to product-specific alerts
3. Test Different Environments
If Sandbox works but Production fails, it's likely an institution or credential issue.
| Environment | Purpose | Test Method |
|---|---|---|
| Sandbox | Development/testing | Use Sandbox credentials, test Link flow |
| Development | Pre-production testing | Test with real institutions (limited access) |
| Production | Live user data | Monitor real user connections |
Decision tree:
Sandbox works + Production fails β Institution issue or API key problem
Sandbox fails + Production fails β Plaid system outage
Specific institution fails β Bank maintenance or credentials issue
All institutions fail β Plaid Link infrastructure problem
Common Plaid Error Codes (And What They Mean)
ITEM_LOGIN_REQUIRED
What it means: User needs to re-authenticate via Link.
Causes:
- User changed bank password
- Bank rotated credentials for security
- MFA expired (multi-factor authentication)
- Bank locked account due to suspicious activity
- Item connection revoked by user
How to fix:
- Trigger Link in update mode:
const linkToken = await plaidClient.linkTokenCreate({
user: { client_user_id: userId },
access_token: accessToken, // Pass existing access token
// ... other config
});
- Have user complete Link flow to re-authenticate
- Item status will return to "good" after successful auth
- Resume data fetching
Prevention:
- Set up webhooks for
ITEM_LOGIN_REQUIREDevents - Prompt users proactively when this error occurs
- Implement graceful UI for re-authentication
INSTITUTION_DOWN
What it means: The bank/institution is temporarily unavailable.
Causes:
- Scheduled bank maintenance (common Sunday nights)
- Bank website/API outage
- Bank security updates
- DDoS attacks on bank infrastructure
How to fix:
- Check if institution-specific: status.plaid.com
- Wait and retry with exponential backoff:
// Example retry logic
const retryDelays = [5000, 15000, 60000, 300000]; // 5s, 15s, 1m, 5m
for (const delay of retryDelays) {
await sleep(delay);
try {
const response = await plaidClient.transactionsGet({...});
break; // Success
} catch (err) {
if (err.error_code !== 'INSTITUTION_DOWN') throw err;
}
}
- Show user-friendly message: "Bank maintenance in progress. Trying again in X minutes."
- Set up webhook monitoring for
INSTITUTION_STATUSupdates
Expected duration: 15 minutes to 6 hours (most resolve within 2 hours)
Pro tip: Banks often schedule maintenance Sunday nights 11 PM - 2 AM ET. Avoid high-priority jobs during this window.
INSTITUTION_NOT_RESPONDING
What it means: Bank servers not responding to Plaid's requests.
Similar to: INSTITUTION_DOWN but usually more transient.
Causes:
- Bank server overload
- Network connectivity issues between Plaid and bank
- Bank API rate limiting Plaid
- Temporary bank infrastructure problems
How to fix:
- Retry with exponential backoff (see above)
- Check if widespread: status.plaid.com
- Try alternative products if available (e.g., Balance instead of Transactions)
- Monitor webhooks for status changes
Expected duration: 5 minutes to 1 hour (usually resolves quickly)
INVALID_CREDENTIALS / INVALID_API_KEYS
What it means: Your Plaid API credentials are wrong or expired.
Causes:
- Using wrong environment keys (Sandbox keys in Production)
- Copied keys incorrectly (extra spaces, truncated)
- API keys rotated or regenerated
- Client ID mismatch
How to fix:
- Verify API keys: dashboard.plaid.com/team/keys
- Check environment match:
// Sandbox
const PLAID_CLIENT_ID = 'your_sandbox_client_id';
const PLAID_SECRET = 'your_sandbox_secret';
const PLAID_ENV = 'sandbox';
// Production
const PLAID_CLIENT_ID = 'your_production_client_id';
const PLAID_SECRET = 'your_production_secret';
const PLAID_ENV = 'production';
- Regenerate keys if compromised
- Update environment variables and redeploy
- Test with
/item/getendpoint to verify credentials
Pro tip: Store keys in secure secret management (AWS Secrets Manager, Vault, etc.), not in code.
RATE_LIMIT_EXCEEDED
What it means: You've exceeded Plaid's API rate limits.
Rate limits (as of 2026):
- Sandbox: 1,000 requests/minute (per product)
- Development: 100 requests/minute
- Production: 400 requests/second across all endpoints
Causes:
- Polling Transactions too frequently (use webhooks instead)
- Parallel requests without rate limiting
- Misconfigured retry logic (infinite loops)
- Large batch operations without throttling
How to fix:
- Implement rate limiting in your app:
const Bottleneck = require('bottleneck');
const limiter = new Bottleneck({
maxConcurrent: 20, // Max parallel requests
minTime: 10 // Min 10ms between requests (100 req/sec)
});
const rateLimitedGet = limiter.wrap(plaidClient.transactionsGet);
- Use webhooks instead of polling:
DEFAULT_UPDATEfor TransactionsINITIAL_UPDATEfor first data fetchHISTORICAL_UPDATEfor backfill
- Implement exponential backoff on 429 errors
- Cache responses where appropriate
- Batch operations and spread over time
Expected duration: Immediate (limit resets per minute)
ITEM_NOT_FOUND
What it means: The access_token doesn't exist or was deleted.
Causes:
- Typo in access token
- Item was removed via
item/removeendpoint - User revoked access through bank
- Access token from wrong environment (Sandbox vs Production)
How to fix:
- Verify access token is correct and not truncated
- Check your database for token integrity
- Ensure using correct environment
- If legitimately deleted, prompt user to re-connect via Link
- Handle gracefully in UI (don't show errors to user)
Prevention:
- Store access tokens securely and immutably
- Log all
item/removecalls for debugging - Implement soft deletes in your database
PRODUCTS_NOT_READY
What it means: Data not yet available after initial Link connection.
Causes:
- Transactions still being fetched (initial sync can take 5-30 minutes)
- Historical data backfill in progress
- Institution delay in providing data
- Webhooks not yet fired
How to fix:
- Wait for webhooks before fetching data:
INITIAL_UPDATE(Transactions available)HISTORICAL_UPDATE(Historical data available)DEFAULT_UPDATE(New transactions available)
- Show loading state to users: "Syncing your data... (usually takes 2-5 minutes)"
- Implement polling with timeout:
const maxWaitTime = 300000; // 5 minutes
const pollInterval = 10000; // 10 seconds
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
try {
const response = await plaidClient.transactionsGet({...});
return response; // Success
} catch (err) {
if (err.error_code !== 'PRODUCTS_NOT_READY') throw err;
await sleep(pollInterval);
}
}
throw new Error('Timeout waiting for transactions');
- Don't call Transactions API immediately after Link success
Expected duration: 2-10 minutes (average 5 minutes)
ASSET_REPORT_ERROR / ASSET_PRODUCT_NOT_READY
What it means: Asset Report generation failed or still in progress.
Causes:
- Asset Report still generating (can take 1-5 minutes)
- Insufficient transaction history at institution
- Institution doesn't support Assets product
- User denied permission for income verification
How to fix:
- Poll
/asset_report/getwith delays:
const checkAssetReport = async (assetReportToken) => {
const maxRetries = 20;
const retryDelay = 15000; // 15 seconds
for (let i = 0; i < maxRetries; i++) {
try {
const report = await plaidClient.assetReportGet({
asset_report_token: assetReportToken,
});
return report; // Success
} catch (err) {
if (err.error_code !== 'ASSET_PRODUCT_NOT_READY') throw err;
await sleep(retryDelay);
}
}
throw new Error('Asset Report generation timeout');
};
- Set up webhook for
ASSET_REPORT_READY - Show progress indicator to users
- Handle edge case where Assets not supported
Expected duration: 1-5 minutes (average 2 minutes)
PAYMENT_ERROR (Payment Initiation)
What it means: Payment Initiation request failed (UK/EU only).
Causes:
- Insufficient funds in user account
- Payment exceeds bank limits
- Bank requires additional authentication (SCA)
- Payment blocked by bank fraud detection
- Incorrect payment details
How to fix:
- Check error sub-code for specific reason:
INSUFFICIENT_FUNDSβ Ask user to add fundsPAYMENT_LIMIT_EXCEEDEDβ Reduce amount or split paymentSCA_REQUIREDβ Re-authenticate via LinkPAYMENT_REJECTEDβ Contact bank or try different account
- Implement payment status polling:
const payment = await plaidClient.paymentInitiationPaymentGet({
payment_id: paymentId,
});
// Status: INITIATED, PENDING, EXECUTED, REJECTED
- Show user-friendly error messages
- Offer retry or alternative payment methods
Expected duration: Immediate (payment either succeeds or fails)
Quick Fixes: Plaid API Not Working?
Fix #1: Verify API Credentials & Environment
Why it works: 30% of issues are wrong keys or environment mismatch.
How to check:
1. Log into Plaid Dashboard:
π dashboard.plaid.com/team/keys
2. Verify keys match your environment:
// Check your .env or config
console.log('Client ID:', process.env.PLAID_CLIENT_ID);
console.log('Environment:', process.env.PLAID_ENV);
// Don't log PLAID_SECRET in production!
// Verify client initialization
const client = new plaid.PlaidApi(
new plaid.Configuration({
basePath: plaid.PlaidEnvironments[process.env.PLAID_ENV],
baseOptions: {
headers: {
'PLAID-CLIENT-ID': process.env.PLAID_CLIENT_ID,
'PLAID-SECRET': process.env.PLAID_SECRET,
},
},
})
);
3. Test credentials:
curl -X POST https://sandbox.plaid.com/item/get \
-H 'Content-Type: application/json' \
-d '{
"client_id": "your_client_id",
"secret": "your_secret",
"access_token": "access-sandbox-test-token"
}'
Common mistakes:
- Using Sandbox keys in Production (won't work)
- Using Production keys in Development (expensive)
- Extra spaces when copying keys
- Expired or rotated secrets
- Wrong
basePathURL
Fix #2: Check Institution Status
Why it works: Institution outages cause 40% of "Plaid down" reports.
How to check:
1. Visit Plaid Status Page:
π status.plaid.com
2. Search for your institution:
- Click "Institutions" tab
- Search by name (e.g., "Chase", "Bank of America")
- Check if maintenance or outage reported
3. Test specific institution via API:
const response = await plaidClient.institutionsGetById({
institution_id: 'ins_3',
country_codes: ['US'],
});
console.log('Status:', response.data.institution.status);
// Check status.item_logins, status.transactions_updates, etc.
4. Check institution health:
const response = await plaidClient.institutionsGet({
count: 500,
offset: 0,
country_codes: ['US'],
});
// Filter to your institution and check status
const institution = response.data.institutions.find(
inst => inst.institution_id === 'ins_3'
);
console.log('Health status:', institution.status);
Bank maintenance schedule (common):
- Sunday nights: 11 PM - 2 AM ET (most US banks)
- First Saturday of month: 12 AM - 6 AM (some banks)
Pro tip: If user connection fails, check institution status first before debugging your code.
Fix #3: Regenerate Link Token (Link Flow Issues)
Why it works: Link tokens expire after 4 hours and must be regenerated.
Common Link flow issues:
- "Something went wrong" error
- Link modal won't load
- Institution search not working
- White screen after clicking bank
How to fix:
1. Generate fresh Link token:
const createLinkToken = async (userId) => {
try {
const response = await plaidClient.linkTokenCreate({
user: {
client_user_id: userId,
},
client_name: 'Your App Name',
products: ['transactions', 'auth'],
country_codes: ['US'],
language: 'en',
webhook: 'https://your-domain.com/webhooks/plaid',
redirect_uri: 'https://your-app.com/oauth-redirect', // For OAuth institutions
});
return response.data.link_token;
} catch (error) {
console.error('Link token creation failed:', error);
throw error;
}
};
2. Common Link token mistakes:
- Reusing expired token (tokens expire in 4 hours)
- Missing redirect_uri for OAuth institutions (Chase, TD Bank, etc.)
- Wrong webhook URL (must be HTTPS, publicly accessible)
- Missing country_codes (required field)
- Products not enabled (enable in Dashboard β Products)
3. Debug Link with console logging:
Plaid.create({
token: linkToken,
onSuccess: (public_token, metadata) => {
console.log('Success! Public token:', public_token);
console.log('Institution:', metadata.institution);
console.log('Accounts:', metadata.accounts);
// Exchange public_token for access_token
},
onExit: (err, metadata) => {
console.log('Exit:', err, metadata);
if (err) {
console.error('Link error:', err.error_code, err.error_message);
}
},
onEvent: (eventName, metadata) => {
console.log('Event:', eventName, metadata);
},
});
4. Test Link in Sandbox first:
- Use Sandbox credentials
- Test institutions:
ins_109508(Sandbox Test Bank) - Username:
user_good, Password:pass_good
Fix #4: Handle Webhooks Properly
Why it works: Webhooks prevent polling and tell you exactly when data is ready.
Common webhook issues:
- Not receiving webhooks
- Webhooks timing out
- Duplicate webhook processing
- Webhook signature verification failures
How to fix:
1. Verify webhook URL is correct:
// Check Item's webhook
const response = await plaidClient.itemGet({
access_token: accessToken,
});
console.log('Webhook URL:', response.data.item.webhook);
2. Update webhook URL if wrong:
await plaidClient.itemWebhookUpdate({
access_token: accessToken,
webhook: 'https://your-domain.com/webhooks/plaid',
});
3. Test webhook delivery:
- Dashboard β Webhooks β Send Test Webhook
- Check your server logs
- Verify endpoint returns 200 OK within 10 seconds
4. Implement webhook endpoint correctly:
app.post('/webhooks/plaid', async (req, res) => {
const webhook = req.body;
// Respond immediately (don't wait for processing)
res.status(200).send('OK');
// Process asynchronously
try {
switch (webhook.webhook_code) {
case 'DEFAULT_UPDATE':
// New transactions available
await fetchTransactions(webhook.item_id);
break;
case 'INITIAL_UPDATE':
// Initial transactions ready (first time)
await fetchTransactions(webhook.item_id);
break;
case 'HISTORICAL_UPDATE':
// Historical transactions ready
await fetchTransactions(webhook.item_id);
break;
case 'ITEM_LOGIN_REQUIRED':
// User needs to re-authenticate
await notifyUserToReconnect(webhook.item_id);
break;
case 'WEBHOOK_UPDATE_ACKNOWLEDGED':
// Webhook URL updated successfully
console.log('Webhook URL updated');
break;
default:
console.log('Unhandled webhook:', webhook.webhook_code);
}
} catch (error) {
console.error('Webhook processing error:', error);
// Don't throw - we already responded 200
}
});
5. Verify webhook signature (security best practice):
const crypto = require('crypto');
const verifyWebhookSignature = (body, signature) => {
const webhookSecret = process.env.PLAID_WEBHOOK_VERIFICATION_KEY;
const hash = crypto
.createHmac('sha256', webhookSecret)
.update(JSON.stringify(body))
.digest('hex');
return hash === signature;
};
app.post('/webhooks/plaid', (req, res) => {
const signature = req.headers['plaid-verification'];
if (!verifyWebhookSignature(req.body, signature)) {
return res.status(401).send('Invalid signature');
}
// Process webhook...
res.status(200).send('OK');
});
Webhook requirements:
- Must be HTTPS (not HTTP)
- Must respond with 200 OK within 10 seconds
- Must be publicly accessible (no localhost)
- Should return 200 even if processing fails
Fix #5: Implement Proper Error Handling & Retries
Why it works: Transient errors resolve with smart retry logic.
Retry strategy:
const retryPlaidRequest = async (requestFn, maxRetries = 3) => {
const retryableErrors = [
'INSTITUTION_DOWN',
'INSTITUTION_NOT_RESPONDING',
'INTERNAL_SERVER_ERROR',
'PLANNED_MAINTENANCE',
];
const delays = [1000, 5000, 15000]; // 1s, 5s, 15s
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await requestFn();
} catch (error) {
const isRetryable = retryableErrors.includes(error.error_code);
const isLastAttempt = attempt === maxRetries - 1;
if (!isRetryable || isLastAttempt) {
throw error; // Don't retry
}
console.log(`Retry ${attempt + 1}/${maxRetries} after ${delays[attempt]}ms`);
await sleep(delays[attempt]);
}
}
};
// Usage
const transactions = await retryPlaidRequest(async () => {
return await plaidClient.transactionsGet({
access_token: accessToken,
start_date: '2026-01-01',
end_date: '2026-02-11',
});
});
Error handling best practices:
const handlePlaidError = (error) => {
const errorCode = error.error_code;
const errorMessage = error.error_message;
switch (errorCode) {
case 'ITEM_LOGIN_REQUIRED':
// Prompt user to re-authenticate
return {
userAction: 'reauth',
message: 'Please reconnect your bank account.',
};
case 'INSTITUTION_DOWN':
// Show maintenance message
return {
userAction: 'wait',
message: 'Your bank is temporarily unavailable. Please try again in 15 minutes.',
};
case 'RATE_LIMIT_EXCEEDED':
// Internal issue, don't expose to user
return {
userAction: 'retry_later',
message: 'Service temporarily busy. Please try again shortly.',
};
case 'INVALID_ACCESS_TOKEN':
case 'ITEM_NOT_FOUND':
// Item deleted or invalid
return {
userAction: 'reconnect',
message: 'Bank connection lost. Please reconnect your account.',
};
default:
console.error('Unhandled Plaid error:', errorCode, errorMessage);
return {
userAction: 'contact_support',
message: 'Something went wrong. Please contact support.',
};
}
};
Fix #6: Use Sandbox for Testing & Development
Why it works: Isolates issues between your code and production data.
Sandbox benefits:
- Instant responses (no bank delays)
- Predictable test accounts
- No rate limits
- Free transactions
- Test all error scenarios
How to use Sandbox:
1. Switch to Sandbox environment:
// .env.development
PLAID_CLIENT_ID=your_sandbox_client_id
PLAID_SECRET=your_sandbox_secret
PLAID_ENV=sandbox
2. Use Sandbox test institutions:
// Sandbox Test Bank (all products)
const SANDBOX_INSTITUTION_ID = 'ins_109508';
// Test credentials
const TEST_ACCOUNTS = {
good: { username: 'user_good', password: 'pass_good' },
locked: { username: 'user_locked', password: 'pass_locked' },
custom: { username: 'user_custom', password: 'pass_custom' },
};
3. Test error scenarios:
// Trigger specific errors in Sandbox
const createSandboxItem = async (scenario) => {
const response = await plaidClient.sandboxPublicTokenCreate({
institution_id: 'ins_109508',
initial_products: ['transactions'],
options: {
webhook: 'https://your-domain.com/webhooks/plaid',
override_username: scenario, // 'user_good', 'user_locked', etc.
},
});
return response.data.public_token;
};
// Test different scenarios
await createSandboxItem('user_good'); // Success
await createSandboxItem('user_locked'); // ITEM_LOGIN_REQUIRED
await createSandboxItem('user_custom'); // Custom MFA
4. Simulate webhooks in Sandbox:
await plaidClient.sandboxItemFireWebhook({
access_token: accessToken,
webhook_code: 'DEFAULT_UPDATE',
});
5. Reset Sandbox Item login:
await plaidClient.sandboxItemResetLogin({
access_token: accessToken,
});
// Triggers ITEM_LOGIN_REQUIRED
Pro tip: Test your entire flow in Sandbox before touching Production. Common issues (webhook handling, error states, Link flow) appear the same way.
Fix #7: Monitor Plaid API Requests & Responses
Why it works: Logging reveals patterns in failures (specific institutions, times, error codes).
Implement request logging:
const logPlaidRequest = (endpoint, request, response, error) => {
const log = {
timestamp: new Date().toISOString(),
endpoint,
request: {
// Don't log secrets
client_id: request.client_id,
access_token: request.access_token ? '[REDACTED]' : undefined,
},
response: response ? {
status: 'success',
data: response.data,
} : undefined,
error: error ? {
error_code: error.error_code,
error_message: error.error_message,
error_type: error.error_type,
display_message: error.display_message,
} : undefined,
};
console.log(JSON.stringify(log));
// Send to monitoring (Datadog, Sentry, etc.)
if (error) {
Sentry.captureException(error, {
tags: {
plaid_endpoint: endpoint,
plaid_error_code: error.error_code,
},
});
}
};
// Wrap Plaid calls
const transactionsGet = async (accessToken, startDate, endDate) => {
const request = { access_token: accessToken, start_date: startDate, end_date: endDate };
try {
const response = await plaidClient.transactionsGet(request);
logPlaidRequest('/transactions/get', request, response, null);
return response;
} catch (error) {
logPlaidRequest('/transactions/get', request, null, error);
throw error;
}
};
Monitoring dashboards (recommended):
- Datadog: Track error rates by error_code
- Sentry: Alert on new error types
- CloudWatch: Graph request volume and latency
- Custom dashboard: Institution success rates
Key metrics to track:
- Error rate by error_code
- Average response time per endpoint
- Institution success rate (login success %)
- Webhook delivery success rate
- Items requiring re-authentication (ITEM_LOGIN_REQUIRED)
Plaid Link Issues
Issue: Link Modal Won't Load
Symptoms:
- White screen after clicking bank
- "Something went wrong" error
- Link spinner indefinitely
- Institution search not working
Troubleshoot:
1. Check Link token:
// Token must be fresh (< 4 hours old)
const tokenCreatedAt = Date.now();
const tokenAge = Date.now() - tokenCreatedAt;
const isExpired = tokenAge > (4 * 60 * 60 * 1000); // 4 hours
if (isExpired) {
console.error('Link token expired, generating new one');
linkToken = await createLinkToken(userId);
}
2. Check browser console:
- Open DevTools (F12)
- Look for JavaScript errors
- Check Network tab for failed requests
- Look for CORS errors (webhook/redirect_uri issues)
3. Verify Link initialization:
const linkHandler = Plaid.create({
token: linkToken,
onLoad: () => {
console.log('Link loaded successfully');
},
onSuccess: (public_token, metadata) => {
console.log('Link success:', public_token);
},
onExit: (err, metadata) => {
if (err) {
console.error('Link error:', err);
}
},
onEvent: (eventName, metadata) => {
console.log('Link event:', eventName, metadata);
},
});
linkHandler.open();
4. Common Link issues:
- Ad blockers blocking Plaid CDN
- Privacy extensions blocking third-party scripts
- Corporate firewalls blocking cdn.plaid.com
- Content Security Policy too restrictive
- Popup blockers preventing Link modal
5. Test in incognito mode:
- Disables most extensions
- Isolates browser-specific issues
Issue: OAuth Redirect Failing (Chase, TD Bank, etc.)
Symptoms:
- Redirected to bank website
- After login, stuck or returns to error page
- "redirect_uri mismatch" error
Causes:
redirect_urinot configured in Link token- Redirect URI not whitelisted in Plaid Dashboard
- HTTPS required (HTTP won't work)
- Redirect URI must match exactly (including trailing slash)
How to fix:
1. Configure redirect_uri in Link token:
const response = await plaidClient.linkTokenCreate({
user: { client_user_id: userId },
client_name: 'Your App',
products: ['transactions'],
country_codes: ['US'],
language: 'en',
redirect_uri: 'https://your-app.com/oauth-redirect', // Required for OAuth
});
2. Whitelist redirect URI in Dashboard:
- Login to dashboard.plaid.com
- Team Settings β API β Allowed redirect URIs
- Add your redirect URI:
https://your-app.com/oauth-redirect - Must match exactly (case-sensitive, trailing slash matters)
3. Implement redirect handler:
// /oauth-redirect page
const urlParams = new URLSearchParams(window.location.search);
const oauthStateId = urlParams.get('oauth_state_id');
if (oauthStateId) {
// Continue Link flow
Plaid.create({
token: linkToken,
receivedRedirectUri: window.location.href,
}).open();
} else {
console.error('Missing oauth_state_id');
}
OAuth institutions (require redirect_uri):
- Chase
- TD Bank
- BB&T / Truist
- Capital One 360
- HSBC
- Citibank
Issue: User Stuck on MFA (Multi-Factor Authentication)
Symptoms:
- Link asks for security code
- Code sent to phone/email
- User enters code but Link doesn't proceed
Causes:
- Institution delay sending code (30s - 2min)
- User entering wrong code
- Code expired (usually 5-10 min expiry)
- Institution MFA system issues
How to fix:
1. Wait for code delivery:
- SMS codes: 30 seconds to 2 minutes
- Email codes: 30 seconds to 5 minutes
- Voice call: 1-2 minutes
2. Resend code option:
- Most institutions allow "Resend code" after 60 seconds
- Click "Resend" in Link flow
3. Try different MFA method:
- If SMS fails, try email or voice call
- Some institutions offer app-based MFA (faster)
4. Check institution status:
- MFA issues often correlate with institution outages
- Check status.plaid.com
5. Let user retry login:
- Link allows going back to credential entry
- User can re-enter username/password
Pro tip: Educate users about MFA in onboarding: "Your bank may send you a security code. This is normal."
Plaid Transactions Issues
Issue: Missing or Incomplete Transactions
Symptoms:
- Transaction count lower than expected
- Recent transactions not appearing
- Historical transactions missing
- Gaps in transaction history
Causes:
- Still fetching initial transactions (wait for webhook)
- Institution provides limited history (30-90 days typical)
- Pending transactions not included (depending on settings)
- Account type not supported (some accounts excluded)
How to fix:
1. Wait for INITIAL_UPDATE webhook:
// After Link success, wait for webhook before fetching
app.post('/webhooks/plaid', (req, res) => {
if (req.body.webhook_code === 'INITIAL_UPDATE') {
// Now safe to fetch transactions
fetchTransactions(req.body.item_id);
}
res.status(200).send('OK');
});
2. Check transaction date range:
// Plaid returns up to 24 months of history
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: '2024-01-01', // Up to 2 years ago
end_date: '2026-02-11',
options: {
include_personal_finance_category: true,
},
});
console.log('Total transactions:', response.data.total_transactions);
console.log('Returned:', response.data.transactions.length);
3. Handle pagination:
const getAllTransactions = async (accessToken, startDate, endDate) => {
let allTransactions = [];
let hasMore = true;
let offset = 0;
while (hasMore) {
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: startDate,
end_date: endDate,
options: {
offset: offset,
count: 500, // Max per request
},
});
allTransactions = allTransactions.concat(response.data.transactions);
offset += response.data.transactions.length;
hasMore = response.data.transactions.length === 500;
}
return allTransactions;
};
4. Check if pending transactions included:
// Pending transactions are included by default
// To exclude pending:
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: startDate,
end_date: endDate,
options: {
include_pending: false, // Exclude pending
},
});
5. Verify institution limitations:
- Some banks only provide 30-90 days of history
- Credit card accounts may have different limits than checking
- Investment accounts may not support Transactions product
Issue: Duplicate Transactions
Symptoms:
- Same transaction appearing multiple times
- Different
transaction_idbut same details - Duplicate charges from merchant
Causes:
- Bank correcting/updating transaction (expected behavior)
- Pending transaction posted (creates new transaction_id)
- Multiple webhook deliveries processed
- Institution data quality issues
How to fix:
1. Use Plaid's transaction_id as unique identifier:
// Store transactions with transaction_id as primary key
const upsertTransaction = async (transaction) => {
await db.query(
`INSERT INTO transactions (transaction_id, account_id, amount, date, name)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (transaction_id) DO UPDATE SET
amount = EXCLUDED.amount,
date = EXCLUDED.date,
name = EXCLUDED.name`,
[transaction.transaction_id, transaction.account_id, transaction.amount,
transaction.date, transaction.name]
);
};
2. Handle removed transactions (from TRANSACTIONS_REMOVED webhook):
app.post('/webhooks/plaid', async (req, res) => {
const webhook = req.body;
if (webhook.webhook_code === 'TRANSACTIONS_REMOVED') {
// Remove transactions that were corrected/reversed
const removedIds = webhook.removed_transactions;
await db.query(
'DELETE FROM transactions WHERE transaction_id = ANY($1)',
[removedIds]
);
}
res.status(200).send('OK');
});
3. Deduplicate by merchant name + amount + date:
// Secondary deduplication (user-facing)
const deduplicateTransactions = (transactions) => {
const seen = new Set();
return transactions.filter(txn => {
const key = `${txn.name}_${txn.amount}_${txn.date}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
4. Monitor pending field:
// Track pending β posted transitions
transactions.forEach(txn => {
if (txn.pending) {
console.log('Pending transaction:', txn.transaction_id);
// Show as "pending" in UI
} else {
console.log('Posted transaction:', txn.transaction_id);
}
});
Issue: Transaction Categorization Wrong
Symptoms:
- Grocery store categorized as "Travel"
- Rent payment categorized as "Transfer"
- Business expense showing as personal
Causes:
- Plaid's ML categorization not perfect
- Merchant name ambiguous
- Internal transfers hard to categorize
- User's use case differs from typical
How to fix:
1. Use Personal Finance Categories (PFC):
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: startDate,
end_date: endDate,
options: {
include_personal_finance_category: true,
},
});
transactions.forEach(txn => {
const category = txn.personal_finance_category;
console.log('Primary:', category.primary); // e.g., "FOOD_AND_DRINK"
console.log('Detailed:', category.detailed); // e.g., "FOOD_AND_DRINK_GROCERIES"
console.log('Confidence:', category.confidence_level); // "HIGH", "MEDIUM", "LOW"
});
2. Allow user overrides:
// Let users recategorize transactions
const updateTransactionCategory = async (transactionId, newCategory) => {
await db.query(
'UPDATE transactions SET user_category = $1 WHERE transaction_id = $2',
[newCategory, transactionId]
);
// Learn from user's override (ML model improvement)
// Store for future similar transactions
};
3. Use merchant name patterns:
const improveCategories = (transactions) => {
return transactions.map(txn => {
let category = txn.personal_finance_category?.detailed;
// Custom rules
if (txn.name.includes('RENT') || txn.name.includes('LANDLORD')) {
category = 'RENT_AND_UTILITIES_RENT';
} else if (txn.name.includes('VENMO') || txn.name.includes('PAYPAL')) {
category = 'TRANSFER_OUT_PEER_TO_PEER';
}
return { ...txn, improved_category: category };
});
};
4. Filter internal transfers:
// Exclude transfers between user's own accounts
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: startDate,
end_date: endDate,
options: {
include_personal_finance_category: true,
},
});
const externalTransactions = response.data.transactions.filter(txn => {
const category = txn.personal_finance_category?.primary;
return category !== 'TRANSFER_IN' && category !== 'TRANSFER_OUT';
});
Plaid Auth & Balance Issues
Issue: Balance Not Updating
Symptoms:
- Balance shows old/stale value
- Real-time balance doesn't match bank
- Balance only updates once per day
Causes:
- Institution delay (some banks update once daily)
- Using Transactions balance instead of Balance product
- Webhook not triggering balance refresh
- Cached balance data
How to fix:
1. Use Balance product for real-time balances:
// Balance product (real-time)
const response = await plaidClient.accountsBalanceGet({
access_token: accessToken,
});
response.data.accounts.forEach(account => {
console.log('Account:', account.name);
console.log('Current balance:', account.balances.current);
console.log('Available balance:', account.balances.available);
console.log('Limit:', account.balances.limit); // Credit cards
console.log('Last updated:', account.balances.last_updated_datetime);
});
2. Understand balance types:
current: Current balance (includes pending)available: Available for spending (excludes pending/holds)limit: Credit limit (credit cards only)
3. Poll balance before displaying:
// Fetch fresh balance on user request
const getFreshBalance = async (accessToken) => {
const response = await plaidClient.accountsBalanceGet({
access_token: accessToken,
});
return response.data.accounts;
};
4. Check institution update frequency:
- Most banks: Real-time or hourly
- Some smaller banks: Once daily
- Credit unions: Often daily only
Issue: Account/Routing Number Wrong (Auth Product)
Symptoms:
- Account number doesn't match bank statement
- Routing number incorrect
- ACH payments failing
Causes:
- Tokenized account numbers (privacy feature)
- Wire routing vs ACH routing different
- Account number format varies by institution
- Wrong account selected
How to fix:
1. Use Auth product correctly:
const response = await plaidClient.authGet({
access_token: accessToken,
});
response.data.numbers.ach.forEach(account => {
console.log('Account ID:', account.account_id);
console.log('Account number:', account.account);
console.log('Routing number:', account.routing);
console.log('Wire routing:', account.wire_routing); // May differ
});
2. Verify account type supports ACH:
- Checking: β Supported
- Savings: β Supported
- Credit cards: β Not supported
- Investment: β οΈ Limited support
3. Use correct routing for transfer type:
const getRoutingNumber = (account, transferType) => {
if (transferType === 'wire') {
return account.wire_routing || account.routing;
} else {
return account.routing; // ACH
}
};
When Plaid Actually Goes Down
What Happens
Recent major outages:
- November 2025: 4-hour partial outage (Link authentication issues)
- July 2025: 2-hour Transactions API degradation
- March 2025: 1-hour webhook delivery delays
- December 2024: 3-hour Chase-specific outage
Typical causes:
- AWS/cloud provider outages
- Institution-side API changes
- DDoS attacks (rare)
- Deployment bugs
- Database issues
- Third-party dependency failures
How Plaid Responds
Communication channels:
- status.plaid.com - Primary source (updated every 15-30 min)
- @PlaidSupport on Twitter/X
- Email alerts (if subscribed to status page)
- Webhook status updates (for critical failures)
- Dashboard notifications
Timeline:
- 0-15 min: Developers report issues on Twitter
- 15-30 min: Plaid acknowledges on status page ("Investigating")
- 30-90 min: Root cause identified ("Identified")
- 1-4 hours: Fix deployed ("Monitoring")
- Resolution: Post-mortem published within 48 hours
What to Do During Outages
1. Check scope:
- All products affected, or just one?
- All institutions, or specific banks?
- All regions, or US-only?
2. Switch to fallback strategy:
// Graceful degradation
const getTransactions = async (accessToken) => {
try {
return await plaidClient.transactionsGet({...});
} catch (error) {
if (error.error_code === 'INTERNAL_SERVER_ERROR') {
// Plaid down, use cached data
return await db.getCachedTransactions(accessToken);
}
throw error;
}
};
3. Communicate with users:
// Show user-friendly message
if (plaidIsDown) {
return {
message: "We're experiencing issues connecting to your bank. Your data is safe and we're working to restore service. Please check back in 30 minutes.",
showCachedData: true,
retryAfter: Date.now() + (30 * 60 * 1000),
};
}
4. Queue failed requests:
// Retry automatically when service restored
const queuedRequests = new Queue();
const fetchWithQueue = async (accessToken) => {
try {
return await plaidClient.transactionsGet({...});
} catch (error) {
if (isPlaidOutage(error)) {
queuedRequests.add({ accessToken, retryAfter: Date.now() + 300000 });
throw new Error('Queued for retry');
}
throw error;
}
};
5. Monitor status page:
- Subscribe to status.plaid.com updates
- Set up API Status Check monitoring
- Get alerts via Slack/Discord/email when service restored
Plaid Down Checklist
Follow these steps in order:
Step 1: Verify it's actually down
- Check Plaid Status
- Check API Status Check
- Search Twitter: "Plaid down" or "Plaid API"
- Test in Sandbox (isolate production vs Plaid issue)
- Check specific institution status
Step 2: Verify credentials & configuration
- API credentials correct (client_id, secret, environment)
- Using correct environment (Sandbox vs Production)
- Webhook URL accessible and returning 200 OK
- Products enabled in Dashboard
- Rate limits not exceeded
Step 3: Debug specific error
- Log full error response (error_code, error_message)
- Check error code documentation
- Implement appropriate retry logic
- Verify access_token is valid and not expired
- Check Item status with
/item/get
Step 4: Test in isolation
- Test with Sandbox credentials
- Test with different institution
- Test different API endpoint (e.g., Balance instead of Transactions)
- Test from different network (mobile hotspot)
- Test with freshly generated Link token
Step 5: Link-specific debugging
- Generate fresh Link token (< 4 hours old)
- Check browser console for errors
- Test in incognito mode (disable extensions)
- Verify redirect_uri configured (for OAuth institutions)
- Test with Sandbox institution first
Step 6: Contact support
- Gather logs (request_id, error_code, timestamp)
- Document steps to reproduce
- Include environment (Sandbox vs Production)
- Submit ticket: dashboard.plaid.com/support
- Post in community: community.plaid.com
Prevent Future Issues
1. Implement Robust Webhook Handling
Why it matters: Webhooks prevent polling and catch issues early.
Best practices:
1. Set up webhook endpoint:
app.post('/webhooks/plaid', async (req, res) => {
// Respond immediately
res.status(200).send('OK');
// Process asynchronously
processWebhook(req.body).catch(err => {
console.error('Webhook processing error:', err);
});
});
const processWebhook = async (webhook) => {
const { webhook_code, item_id, error } = webhook;
switch (webhook_code) {
case 'DEFAULT_UPDATE':
await syncTransactions(item_id);
break;
case 'ITEM_LOGIN_REQUIRED':
await notifyUserReauth(item_id);
break;
case 'ERROR':
await handleItemError(item_id, error);
break;
}
};
2. Monitor webhook delivery:
// Track webhook metrics
const webhookMetrics = {
received: 0,
processed: 0,
failed: 0,
averageProcessingTime: 0,
};
// Alert if delivery drops
if (webhookMetrics.failed / webhookMetrics.received > 0.05) {
alertOps('High webhook failure rate');
}
2. Monitor Item Health
Why it matters: Catch authentication issues before users complain.
Health check script:
const checkItemHealth = async () => {
const items = await db.getAllItems();
const unhealthyItems = [];
for (const item of items) {
try {
const response = await plaidClient.itemGet({
access_token: item.access_token,
});
const status = response.data.status;
if (status.item_logins.status !== 'HEALTHY') {
unhealthyItems.push({
item_id: item.item_id,
user_id: item.user_id,
status: status.item_logins.status,
last_successful_update: status.item_logins.last_successful_update,
});
}
} catch (error) {
unhealthyItems.push({
item_id: item.item_id,
user_id: item.user_id,
error: error.error_code,
});
}
}
// Notify users or admins
if (unhealthyItems.length > 0) {
console.log('Unhealthy items:', unhealthyItems.length);
await notifyUsersToReconnect(unhealthyItems);
}
};
// Run daily
schedule.daily('02:00', checkItemHealth);
3. Cache Data Strategically
Why it matters: Graceful degradation during outages or rate limits.
Caching strategy:
const getCachedTransactions = async (accessToken, startDate, endDate) => {
const cacheKey = `txns:${accessToken}:${startDate}:${endDate}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) {
const data = JSON.parse(cached);
const age = Date.now() - data.timestamp;
// Cache valid for 1 hour
if (age < 3600000) {
return { data: data.transactions, cached: true };
}
}
// Fetch fresh data
try {
const response = await plaidClient.transactionsGet({
access_token: accessToken,
start_date: startDate,
end_date: endDate,
});
// Update cache
await redis.set(
cacheKey,
JSON.stringify({
transactions: response.data.transactions,
timestamp: Date.now(),
}),
'EX',
3600 // 1 hour expiry
);
return { data: response.data.transactions, cached: false };
} catch (error) {
// If Plaid down, return stale cache
if (cached) {
return { data: JSON.parse(cached).transactions, cached: true, stale: true };
}
throw error;
}
};
4. Set Up Monitoring & Alerts
Why it matters: Detect issues before users do.
Monitoring checklist:
1. API response times:
const monitorAPILatency = async (endpoint, requestFn) => {
const start = Date.now();
try {
const response = await requestFn();
const latency = Date.now() - start;
// Alert if slow
if (latency > 5000) {
alertOps(`Slow Plaid response: ${endpoint} took ${latency}ms`);
}
return response;
} catch (error) {
const latency = Date.now() - start;
console.error(`${endpoint} failed after ${latency}ms:`, error.error_code);
throw error;
}
};
2. Error rate tracking:
// Track error rates by code
const errorCounts = new Map();
const trackError = (errorCode) => {
const count = errorCounts.get(errorCode) || 0;
errorCounts.set(errorCode, count + 1);
// Alert if spike
if (count > 10 && count % 10 === 0) {
alertOps(`Spike in ${errorCode} errors: ${count} occurrences`);
}
};
3. Use API Status Check for proactive alerts:
- apistatuscheck.com/api/plaid
- Set up Slack/Discord alerts
- Monitor historical uptime
- Get notified of outages before users complain
5. Document Runbooks
Why it matters: Faster incident response during outages.
Incident runbook template:
# Plaid Outage Runbook
## 1. Detection
- Alert fired: Plaid API error rate > 10%
- Check status.plaid.com
- Check @PlaidSupport Twitter
- Verify in Sandbox (isolate issue)
## 2. Immediate Actions
- Enable cached data fallback
- Update status page: "Investigating bank sync issues"
- Queue failed requests for retry
- Notify team in Slack
## 3. User Communication
- If downtime > 15 min: Email users
- Show banner: "Bank sync temporarily unavailable"
- Provide ETA if Plaid status page has one
## 4. Monitoring
- Track error rates every 5 minutes
- Monitor Plaid status page for updates
- Check when errors drop below 5%
## 5. Recovery
- Re-enable live data fetching
- Process queued requests
- Sync missed webhooks
- Update status page: "All systems operational"
- Post-mortem: Document root cause and improvements
## 6. Contacts
- Plaid Support: support@plaid.com
- Plaid Dashboard: dashboard.plaid.com/support
- On-call engineer: [phone number]
Key Takeaways
Before assuming Plaid is down:
- β Check Plaid Status
- β Test in Sandbox (isolate production issues)
- β Verify API credentials and environment
- β Check specific institution status
- β Search Twitter for "Plaid down"
Common fixes:
- Regenerate Link token (expires after 4 hours)
- Verify webhook URL is accessible
- Implement proper retry logic for transient errors
- Use webhooks instead of polling (prevents rate limits)
- Check institution status (not Plaid-wide outage)
Error handling best practices:
- Log all API calls (request_id, error_code, timestamp)
- Retry transient errors (
INSTITUTION_DOWN, etc.) - Prompt users for re-auth on
ITEM_LOGIN_REQUIRED - Cache data for graceful degradation
- Monitor error rates and alert on spikes
If Plaid is actually down:
- Monitor status.plaid.com
- Show cached data to users
- Queue failed requests for retry
- Communicate expected downtime
- Usually resolved within 1-4 hours
Prevent future issues:
- Set up robust webhook handling
- Monitor Item health daily
- Implement caching strategy
- Use API Status Check for proactive alerts
- Test thoroughly in Sandbox before production
- Document runbooks for incidents
Remember: Most "Plaid down" issues are actually:
- Institution-specific outages (check status.plaid.com)
- Authentication expired (
ITEM_LOGIN_REQUIRED) - Wrong API credentials or environment
- Rate limiting or configuration errors
Try the debugging steps in this guide before assuming Plaid is down.
Need real-time Plaid status monitoring? Track Plaid uptime with API Status Check - Get instant alerts when Plaid goes down or when specific institutions have issues.
Related Resources
- Is Plaid Down Right Now? β Live status check
- Plaid Outage History β Past incidents and downtime analytics
- Plaid vs Teller vs MX Comparison β Which provider is most reliable?
- Financial API Integration Guide β Best practices for Plaid implementation
- API Outage Response Plan β How to handle third-party API downtime
Monitor Your APIs
Check the real-time status of 100+ popular APIs used by developers.
View API Status β