Most open banking tutorials stop at "make a GET request." In production, you need consent flows, token refresh, pagination across thousands of transactions, deduplication, and rate-limit handling. This guide walks through the full sync pipeline — what actually breaks at 3am when your cron job fires.
The sync lifecycle
A real bank-data sync has four phases:
- Onboard: User consents to read-only access → you get a consent token
- Initial fetch: Pull all historical transactions (can be thousands)
- Incremental sync: Poll for new transactions on a schedule
- Token refresh: Consent expires (90 days default under PSD2) → re-authorize
Choosing a provider
| Provider | Cert needed? | Pricing model | Bank coverage |
|---|---|---|---|
| Plaid | Per-region | Per-item ($0.20-0.50/mo) | 12,000+ globally |
| TrueLayer | Yes (eIDAS QWAC) | Volume tiers | 1,500+ EU/UK |
| Tink | Yes (eIDAS QWAC) | Custom | 3,400+ EU/UK |
| GoCardless (Bank Account Data) | No (they hold the cert) | Free tier → paid | 1,500+ EU/UK |
| open-banking.io | No | ~€3/mo flat | 2,500+ EU/UK |
Disclosure: I maintain open-banking.io. The rest of this guide is vendor-neutral — pick whichever fits your stack.
The eIDAS QWAC certificate is the hidden gatekeeper. It costs €3,000-15,000/year, requires an annual audit, and takes weeks to obtain. If you're a solo developer or small team, providers that absorb the certificate (GoCardless, open-banking.io) save you that entire process.
Step 1: The consent flow
All PSD2 Account Information Service (AIS) providers use a similar OAuth-style redirect:
// 1. Redirect user to the bank's consent page
const authUrl = provider.getAuthUrl({
redirect_uri: 'https://yourapp.com/callback',
scope: 'accounts transactions',
access_valid_for_days: 90
});
// User clicks → bank asks "Allow YourApp to read your transactions?"
// User approves → redirects back with a code
The consent token you receive is time-limited (PSD2 mandates max 180 days, most default to 90). Store the expiry date — you'll need it.
Step 2: Fetch accounts
async function getAccounts(consentToken) {
const res = await fetch(`${API_BASE}/accounts`, {
headers: { 'Authorization': `Bearer ${consentToken}` }
});
const data = await res.json();
return data.accounts; // [{ id, iban, name, currency, balance }]
}
Step 3: Sync transactions (the hard part)
This is where tutorials end and production begins:
async function syncTransactions(accountId, consentToken, lastSyncCursor) {
let allTransactions = [];
let cursor = lastSyncCursor;
let hasMore = true;
while (hasMore) {
const url = `${API_BASE}/accounts/${accountId}/transactions?cursor=${cursor}`;
const res = await fetch(url, {
headers: { 'Authorization': `Bearer ${consentToken}` }
});
if (res.status === 429) {
// Rate limited — back off
const retryAfter = res.headers.get('Retry-After') || 60;
await sleep(retryAfter * 1000);
continue;
}
const page = await res.json();
allTransactions.push(...page.transactions);
cursor = page.next_cursor;
hasMore = page.has_more;
// Respect rate limits between pages
await sleep(200);
}
return { transactions: allTransactions, nextCursor: cursor };
}
Deduplication
Banks will return overlapping transactions between syncs. The cursor handles most of this, but edge cases exist:
- A pending transaction settles (bookingDate changes, transactionId may change)
- A transaction is reversed and re-booked
- The bank back-dates a transaction
Store a composite key: {bankTransactionId, bookingDate, amount}. If the bank provides a transactionId or entryReference, prefer that. If not, hash the three fields above.
function dedupeKey(tx) {
const id = tx.transactionId || tx.entryReference;
if (id) return id;
return `${tx.bookingDate}:${tx.amount}:${tx.counterpartyIban || ''}`;
}
Step 4: Token refresh
PSD2 consent expires. You cannot silently refresh it — the user must re-authorize. Design for this:
async function checkConsent(consentToken) {
const res = await fetch(`${API_BASE}/accounts`, {
headers: { 'Authorization': `Bearer ${consentToken}` }
});
if (res.status === 401 || res.status === 403) {
// Consent expired or revoked
notifyUser('Your bank connection needs reauthorization');
return { valid: false, needsReauth: true };
}
return { valid: true };
}
Send the notification early (7 days before expiry). Users who see "reconnect your bank" at the moment of need will do it. Users who see it when they're not using your app will ignore it.
Production checklist
- Rate limits: Most providers allow 4-15 requests/minute per consent. Batch where possible.
-
Idempotency: Use the bank's
transactionIdas your dedup key, not your own generated ID. -
Pagination: Some banks return 100/page, others 500. Always respect the
has_moreflag. - Webhooks: Some providers offer transaction webhooks (push instead of poll). Use them if available — they reduce API calls by 90%+.
- Error handling: Banks go down. The PSD2 network is 6,000+ banks — some will be unavailable. Retry with exponential backoff, but don't block the entire sync on one failing account.
- Data freshness: Transactions can appear 1-3 days after the actual payment (pending → booked). Don't display "real-time" if you're polling daily.
What breaks in production
The three things that caused the most production incidents in my experience:
Consent expiry cascade: Multiple users' consents expire on the same day (if you onboarded them in a batch). Your reauth notification queue overflows. Stagger onboarding or add jitter to expiry notifications.
Transaction ID instability: Some banks change the
transactionIdwhen a pending transaction settles. Your dedup fails and you get duplicates. Always store both pending and booked versions, and reconcile by matching onamount + date + counterparty.Rate limit headers: Some providers return
X-RateLimit-Remainingin headers, not the response body. If you ignore headers, you'll get 429s that seem random.
The open banking APIs are powerful, but the production details — consent lifecycle, deduplication, rate limiting — are where most integrations stall. The providers above all expose similar REST APIs, so the patterns in this guide apply regardless of which one you choose.
If you're building something with bank data and want a certificate-free starting point, open-banking.io (which I maintain) offers a simple server-to-server API at around €3/month — but the architecture patterns here work with any PSD2 provider.
Top comments (0)