A complete, runnable partner integration in one Express file — the redirect journey, the raw token exchange, cancel handling and revocation, with no SDK.
La documentación para partners se publica en inglés.
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 runs the redirect journey, which is the default and the shape the reference implementation ships: the browser leaves for open-banking.io, the user signs in and consents on screens wearing your branding, and the consent page sends them back to your redirect URI. One window, nothing to poll. The popup variant is described in the authorization flow; it needs challenge=pin_code and a polled status endpoint, and is left out here to keep the example to the journey most integrations should ship. (challenge=pin_code is worth sending in a redirect journey too — see below.)
#The whole server
// server.mjs — npm i express cookie-parser
// run: CLIENT_ID=… CLIENT_SECRET=… SELF_URL=https://your.app node server.mjs
import express from 'express';
import cookieParser from 'cookie-parser';
import { randomBytes, createHash } from 'node:crypto';
const { ISSUER = 'https://staging.open-banking.io', CLIENT_ID, CLIENT_SECRET, SELF_URL } = process.env;
const app = express();
app.use(cookieParser());
const flows = new Map(); // state → { verifier, created } — the PKCE pair lives server-side
const results = new Map(); // session id → { outcome, bundle? } — keyed by SESSION, never by state
const basic = `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`;
app.get('/connect', (req, res) => {
const verifier = randomBytes(32).toString('base64url');
const state = randomBytes(16).toString('base64url');
flows.set(state, { verifier, 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',
// Without `challenge` the login screen sends a magic link, which opens in a new tab that then
// owns the rest of the journey. Add `challenge: 'pin_code'` for a 6-digit code typed on the
// login screen instead — a popup needs it, and a redirect journey stays in one window with it.
...(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 {
// A genuine relay is a cross-site POST navigation from the issuer, so it carries that Origin.
if (req.headers.origin && req.headers.origin !== ISSUER) return res.sendStatus(403);
// RFC 9207: the response must say which issuer produced it.
if (req.body.iss !== ISSUER) return res.status(400).send('unexpected issuer');
const flow = flows.get(req.body.state);
flows.delete(req.body.state); // consume exactly once, whatever happens next
// 45 minutes, not ten: the user signs in, consents, picks a bank and passes its strong customer
// authentication before this runs. See the authorization flow for why the record is single-use.
if (!flow || Date.now() - flow.created > 45 * 60_000) return res.status(400).send('unknown or expired state');
// A NEW session, always: the browser finishing the flow is not necessarily the one that
// started it, and reusing the starter's session hands an attacker your key (see below).
const sid = randomBytes(18).toString('base64url');
res.cookie('__Host-sid', sid, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
if (req.body.error === 'access_denied') { // the cancel path — check it before touching `code`
results.set(sid, { outcome: 'cancelled' });
return res.redirect('/?connect=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(sid, { outcome: 'connected', bundle });
return res.redirect('/?connect=connected');
} catch (e) { next(e); }
});
// What the page reads after the browser lands back on it. Keyed on the caller's own cookie:
// keying it on `state` would let whoever minted the state — an attacker who started the flow and
// sent you the link — read your accounts.
app.get('/api/status', async (req, res) => {
const result = results.get(req.cookies['__Host-sid']);
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) { results.delete(req.cookies['__Host-sid']); return res.json({ status: 'revoked' }); }
res.json({ status: 'connected', user: result.bundle.user, accounts: await accounts.json() });
});
app.post('/api/disconnect', async (req, res) => { // the "disconnect" button
// Keyed on the caller's own session, never on a token from the request body: the page never sees
// the access key, and an unauthenticated revoke would let anyone kill a key they happened to learn.
const sid = req.cookies['__Host-sid'];
const result = results.get(sid);
if (!result?.bundle) return res.sendStatus(401);
await fetch(new URL('/oauth/revoke', ISSUER), {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded', authorization: basic },
body: new URLSearchParams({ token: result.bundle.access_token }),
}); // 200 whether or not the key existed — RFC 7009
results.delete(sid);
res.json({ status: 'disconnected' });
});
app.listen(3000);
Your page sends the browser to /connect and renders whatever /api/status reports when it comes back to /?connect=…. The status call carries no state: the callback sets a session cookie on the browser that finished the flow, and the endpoint answers for that session — the reason is in the callback page. The flows and results maps are in-memory for the demo; use Redis with a TTL in production.
A connection is not populated the moment it is made — see connected but empty below — so the page that renders on return should poll /api/status for a while rather than reading it once.
#The flow, end to end
sequenceDiagram
participant B as Browser (your page)
participant P as Your Express server
participant O as open-banking.io
B->>P: GET /connect
P-->>B: 302 to /oauth/authorize (PKCE S256)
B->>O: GET /oauth/authorize
O->>O: login (magic link, or a 6-digit code — see above), if there is no session
O->>O: onboarding and consent (order depends on the key), on YOUR branded screens
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
P-->>B: 302 /?connect=connected + Set-Cookie for this browser
B->>P: GET /api/status (own session)
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:
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.
#You must ask for the first sync
Transactions do not arrive on their own. The service stores the account identifier encrypted, so
only a client holding the user's key can pull them — call POST /api/sync (or the client's
syncAll()) once, right after the exchange, or you will poll an empty ledger forever. See
reading data.
#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.
#Read next
- Tokens and keys — what the key can read, and how the relayed private key decrypts it.
- Reading data — the read endpoints behind
accounts.read.