Express example

A complete, runnable partner integration in one Express file — popup and redirect modes, the raw token exchange, polling, cancel handling and revocation, with no SDK.

De partnerdocumentatie wordt in het Engels gepubliceerd.

Everything from getting started, the authorization flow and the callback assembled into one file. Unlike the callback page, which leans on the Node client library, this server uses raw fetch only — every request to open-banking.io is visible in the code, so it doubles as a reference implementation for any language.

It supports both modes: popup (the default on desktop — challenge=pin_code keeps the journey inside the popup) and redirect (the fallback when the popup is blocked, and the better choice on mobile). The opening page learns the outcome by polling /api/status, and the close page also broadcasts on a BroadcastChannel for browsers that still allow it.

#The whole server

js

// server.mjs — run: CLIENT_ID=… CLIENT_SECRET=… SELF_URL=https://your.app node server.mjs
import express from 'express';
import { randomBytes, createHash } from 'node:crypto';

const { ISSUER = 'https://staging.open-banking.io', CLIENT_ID, CLIENT_SECRET, SELF_URL } = process.env;
const app = express();
const flows = new Map();   // state → { verifier, mode, created } — the PKCE pair lives server-side
const results = new Map(); // state → { outcome, bundle? } — what the opening page polls
const basic = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`;

const closePage = (outcome) => `<!doctype html><meta charset="utf-8"><p>${outcome}</p><script>
try { new BroadcastChannel('bank-connect').postMessage(${JSON.stringify(outcome)}); } catch {}
setTimeout(() => window.close(), 300);</script>`;

app.get('/connect', (req, res) => {
  const mode = req.query.mode === 'redirect' ? 'redirect' : 'popup';
  const verifier = randomBytes(32).toString('base64url');
  const state = randomBytes(16).toString('base64url');
  flows.set(state, { verifier, mode, created: Date.now() });
  const authorize = new URL('/oauth/authorize', ISSUER);
  authorize.search = new URLSearchParams({
    response_type: 'code', client_id: CLIENT_ID, redirect_uri: `${SELF_URL}/callback`,
    scope: 'accounts.read', state, response_mode: 'form_post',
    code_challenge: createHash('sha256').update(verifier).digest('base64url'),
    code_challenge_method: 'S256',
    ...(mode === 'popup' && { challenge: 'pin_code' }), // keeps the popup journey in the popup
    ...(req.query.lang && { ui_locales: String(req.query.lang) }), // force the flow language (below)
  }).toString();
  res.redirect(authorize);
});

app.post('/callback', express.urlencoded({ extended: false }), async (req, res, next) => {
  try {
    const flow = flows.get(req.body.state);
    flows.delete(req.body.state); // consume exactly once, whatever happens next
    if (!flow || Date.now() - flow.created > 10 * 60_000) return res.status(400).send('unknown or expired state');

    if (req.body.error === 'access_denied') { // the cancel path — check it before touching `code`
      results.set(req.body.state, { outcome: 'cancelled' });
      return flow.mode === 'redirect' ? res.redirect('/?connect=cancelled') : res.send(closePage('cancelled'));
    }

    const token = await fetch(new URL('/oauth/token', ISSUER), {
      method: 'POST',
      headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: basic },
      body: new URLSearchParams({
        grant_type: 'authorization_code', code: req.body.code,
        code_verifier: flow.verifier, redirect_uri: `${SELF_URL}/callback`,
      }),
    });
    if (!token.ok) throw new Error(`token exchange failed: ${token.status}`);
    const bundle = { ...(await token.json()), privateKey: req.body.privateKey }; // access_token, user, key
    results.set(req.body.state, { outcome: 'connected', bundle });
    return flow.mode === 'redirect' ? res.redirect('/?connect=connected') : res.send(closePage('connected'));
  } catch (e) { next(e); }
});

// The popup's opener is severed by Cross-Origin-Opener-Policy, so the opening page polls this.
app.get('/api/status', async (req, res) => {
  const result = results.get(String(req.query.state));
  if (!result) return res.json({ status: 'pending' });
  if (result.outcome !== 'connected') return res.json({ status: result.outcome });
  const accounts = await fetch(new URL('/api/accounts', ISSUER), {
    headers: { authorization: `Bearer ${result.bundle.access_token}` },
  });
  if (accounts.status === 401) return res.json({ status: 'revoked' }); // key revoked or expired
  res.json({ status: 'connected', user: result.bundle.user, accounts: await accounts.json() });
});

app.post('/api/disconnect', express.json(), async (req, res) => { // the "disconnect" button
  await fetch(new URL('/oauth/revoke', ISSUER), {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: basic },
    body: new URLSearchParams({ token: req.body.apiKey }),
  }); // 200 whether or not the key existed — RFC 7009
  for (const [s, r] of results) if (r.bundle?.access_token === req.body.apiKey) results.delete(s);
  res.json({ status: 'disconnected' });
});

app.listen(3000);

Your page opens window.open('/connect?mode=popup', …) from a click handler (or sends mobile users to /connect?mode=redirect), then polls /api/status?state=… — keep the state from the redirect that started the flow — until it flips from pending. The flows and results maps are in-memory for the demo; use Redis with a TTL in production.

#The flow, end to end

mermaid

sequenceDiagram
    participant B as Browser (your page)
    participant P as Your Express server
    participant O as open-banking.io
    B->>P: GET /connect?mode=popup
    P-->>B: 302 to /oauth/authorize (PKCE S256, challenge=pin_code)
    B->>O: GET /oauth/authorize
    O->>O: login (emailed 6-digit code)
    O->>O: onboarding + consent, key derived in browser
    O->>P: form_post /callback (code, state, iss, privateKey)
    P->>O: POST /oauth/token (code + code_verifier, Basic auth)
    O-->>P: access_token ebk_…, user
    B->>P: GET /api/status?state=… (poll)
    P->>O: GET /api/accounts (Bearer ebk_…)
    O-->>P: accounts
    P-->>B: connected

#Forcing the language

Every screen of the flow normally follows the language the user picked or their browser prefers. To pin it per flow — a Danish user seeing Danish even in an English product — pass ui_locales on /oauth/authorize. The example above forwards ?lang= for it:

/connect?mode=popup&lang=da

The server resolves the list to the first locale it publishes (da-DK and da both count as Danish) and threads it to every screen; unsupported values are ignored, and the forced language is never saved as the user's preference. See ui_locales in the reference.

#Connected but empty

/api/status can report connected while accounts is still []: the first sync of a freshly onboarded bank has not finished yet. Don't treat empty as failure — poll GET /api/connections with the same Bearer key and read lastSyncedAt on the connection: it is null until the first sync completes, and an ISO timestamp after that. Show "syncing…" until it flips, then list the accounts.

#Test cancel, test 401

Two failure paths ship in the checklist below; run both against staging before you call the integration done.

  • Cancel before login — on the login screen, press "Back to {your name}". The flow arrives at /callback as error=access_denied with no code. The example consumes the state, marks the flow cancelled, and the poll reports it.
  • Cancel on consent — decline on the consent screen. Same access_denied, and any code that screen was holding is burned first. Both cancels must land in the same branch of your handler.
  • Read after revoke — click disconnect (the /api/disconnect call above), then poll /api/status again. The account read answers 401; the example reports revoked. Drop the stored bundle and offer to reconnect — never retry a 401.
  • Tokens and keys — what the key can read, and how the relayed private key decrypts it.
  • Reading data — the read endpoints behind accounts.read.