DEV Community

John Frandsen
John Frandsen

Posted on

How to Sync Bank Transactions with Open Banking APIs (2026 Guide)

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:

  1. Onboard: User consents to read-only access → you get a consent token
  2. Initial fetch: Pull all historical transactions (can be thousands)
  3. Incremental sync: Poll for new transactions on a schedule
  4. 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
Enter fullscreen mode Exit fullscreen mode

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 }]
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 || ''}`;
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

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 transactionId as your dedup key, not your own generated ID.
  • Pagination: Some banks return 100/page, others 500. Always respect the has_more flag.
  • 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:

  1. 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.

  2. Transaction ID instability: Some banks change the transactionId when a pending transaction settles. Your dedup fails and you get duplicates. Always store both pending and booked versions, and reconcile by matching on amount + date + counterparty.

  3. Rate limit headers: Some providers return X-RateLimit-Remaining in 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)