Callback
Receive the form_post relay, validate state and iss, and exchange the code on your server.
Kumppanidokumentaatio julkaistaan englanniksi.
#Relay fields
On consent the user's browser submits a cross-origin POST with Content-Type: application/x-www-form-urlencoded to your registered redirect URI. It is OAuth's response_mode=form_post, extended with the zero-knowledge key relay:
| Field | Contents |
|---|---|
code | one-time authorization code, valid five minutes |
state | your state, echoed when you sent one |
iss | the issuer (RFC 9207); compare with discovery |
privateKey | the user's base64 PKCS#8 private key |
publicKey | the matching public key; may be empty |
The same list, in this order, is published in discovery as open_banking_io.key_relay.fields and is pinned by tests on both sides.
Your callback must accept POST; a GET never arrives. Two framework defaults break it: a CSRF middleware that rejects cross-site POSTs without your own token (Rails, Django, Laravel, csurf) must exempt this route, and the body parser must accept application/x-www-form-urlencoded (Express: express.urlencoded({ extended: false }) on the route). publicKey is informational and may be empty on older devices; nothing needs it. It can be reached from a browser context that does not carry your session cookie (below), so validate with the state you were handed, not with a cookie.
#The callback, whole
One handler does everything: consume the flow by state, branch on error, validate iss, exchange the code on your server, store the bundle, and finish the page for the mode the flow started in. With the Node client 1.1.0:
flows.take(state)binds the POST to the session that started the flow and consumes it; a second POST with the samestatefinds nothing. Do this before branching onerror, so a cancel consumes the flow too.parseRelaycomparesstateandissin constant time and throws a typedRelayError;access_deniedis the user pressing "Back to {your name}" or declining — a normal outcome, not a failure. Any othererroris.exchangeCodeposts the form-encoded token request with HTTP Basic client authentication and a 30-second timeout, and throws anOAuthErrorcarrying the RFC 6749error. Never exchange from the browser; never retry aninvalid_grant— the code is consumed before the verifier is checked, so a wrong verifier burns it. Every response and error is in tokens and keys.bundlesis your store of{ token, privateKey }per user session — encrypted at rest or memory-only — andOpenBankingClient.fromTokenResponse(token, privateKey)reads with exactly that pair. Never take the redirect target, the issuer or the token endpoint from the request.
Without the client, the exchange is POST {issuer}/oauth/token with Content-Type: application/x-www-form-urlencoded, grant_type=authorization_code&code=…&code_verifier=…&redirect_uri=… and Authorization: Basic base64(client_id:client_secret); the body comes back with access_token, apiKey (the same key), apiBaseUrl and user, and errors as { "error", "error_description" }.
#Finishing the page
finish above answers a redirect-mode flow with a 302 to your own page (the flow's sessionId tells you whose) and a popup-mode flow with a small page that signals your opener and closes itself — window.opener is severed (below), so it uses a BroadcastChannel (any name; it is private to your origin) and your page polls your own status endpoint as the source of truth:
A 401 from any read means the key is gone (revoked or expired): drop the bundle and offer to connect again. Until the Node client throws typed errors, that is the status on the thrown error's message.
Your page opens the channel before it opens the popup, and on any message (or on a timer) calls /api/status until connected is true and syncing is false.
#Your session cookie is not sent on the relay POST
A cross-site POST navigation does not carry SameSite=Lax cookies (Lax covers top-level navigation with safe methods only), and Strict is worse. The failure is confusing: code and privateKey arrive fine, but the session holding your state looks empty, so your own check rejects a perfectly good authorization.
Two fixes, in increasing order of robustness:
SameSite=None; Secureon the session cookie. One line; now dependent on third-party-cookie behaviour, which browsers keep tightening.- Do not need the cookie at all (recommended, and what the examples above do): make
statea random key into a short-lived server-side record holding the verifier and the id of the session that started the flow. The callback looks everything up bystate, attaches the resulting bundle to that session, and never reads a cookie. Your page still polls your own origin — a same-originGET, whereLaxis fine.
#The popup cannot talk to your page
open-banking.io sends Cross-Origin-Opener-Policy: same-origin. The moment the popup navigates there its window.opener is severed for good, so it cannot postMessage your page. A naive integration reads that silence as failure and shows an error on success.
Detect the outcome from shared server state: the callback marks the flow connected, your page polls your own endpoint until it flips. Add a same-origin BroadcastChannel nudge from the close page for an instant response, and keep window.opener?.postMessage only as a harmless fallback.
If your close page renders a payload for a script to read, emit the payload before the script tag, or the script reads null and defaults to an error on the success path.
#Cache skew after a deploy
Serve your popup's static assets fingerprinted (app.js?v=<hash>) with the HTML no-store. Unversioned assets behind a CDN make a returning browser run yesterday's JavaScript against today's HTML — the classic "popup fails, manual refresh works" report.
#Sandbox
Register a client on https://staging.open-banking.io, use its discovery document, and connect Mock ASPSP in the sandbox; no real bank is involved. The reference implementation at partner-demo.open-banking.io runs against exactly this; its source is at github.com/open-banking-io/partner-demo.