Tokens and keys
The token endpoint, the key you get back, how to present it, and how to revoke it.
La documentazione per i partner è pubblicata in inglese.
#The token request
POST {issuer}/oauth/token with Content-Type: application/x-www-form-urlencoded:
Authenticate the client either with HTTP Basic (client_secret_basic) or with client_id and client_secret in the body (client_secret_post). Using both is invalid_request. redirect_uri is optional; if you send it, it must equal the one you used on /oauth/authorize.
First-generation clients send { "code", "codeVerifier", "clientId", "clientSecret" } as application/json instead; that shape keeps working and answers the same response.
#The token response
access_token and apiKey are the same key; apiBaseUrl is the issuer; user is the tenant subject the key reads for. The response is Cache-Control: no-store.
The key lives 365 days or until revoked. There is no refresh token: when it expires, send the user through Connect again.
#Errors
RFC 6749 §5.2 bodies, { "error", "error_description" }:
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_request | a parameter is missing, both client-authentication methods were used, or the content type is neither form nor JSON |
| 400 | unsupported_grant_type | grant_type is not authorization_code |
| 401 | invalid_client | unknown client or wrong secret; WWW-Authenticate: Basic realm="oauth" when Basic was used |
| 400 | invalid_grant | the code is unknown, expired, already used, bound to another client or redirect URI, or the verifier does not match |
Every invalid_grant is terminal for that code — it was consumed before the check. Restart the flow.
#Presenting the key
Both are accepted everywhere and behave identically:
The SDKs and the CLI send X-Api-Key; generic OAuth2 client libraries send Bearer. The key is scoped to accounts.read and to the user who granted it.
#Why there is no id_token
Connect delegates access to a user's data; it does not assert who the user is to you. An OpenID id_token would add a signing key for you to manage and tell you nothing the API does not already: GET /oauth/userinfo with the key returns the subject, the email the user signed in with, your partner id, the client id, the scope and the expiry.
#Bring your own decryption key
This is required. Until the key is installed you cannot register a Connect client, and every authorization is refused. You hold the decryption key rather than receiving one per user — which is also what spares your users a passphrase and lets consent come before the bank step. Generate a P-256 pair, keep the private half in your deployment, and register the public half on Partner → Decryption key:
From then on, for every user who connects through any of your clients for the first time:
- their accounts, balances and transactions are encrypted to your key from the first sync;
- the consent screen asks for no passphrase and has no key-setup step — a person with no open-banking.io account of their own can finish the flow in one visit;
- the relay's
privateKeyandpublicKeyarrive empty. Build the client with your own key:OpenBankingClient.fromTokenResponse(token, MY_PRIVATE_KEY).
Users who connected before you registered the key keep the key their browser made, and keep relaying it on every visit. Nothing is taken away from them, and your callback keeps receiving a privateKey for those users — handle both: a key in the relay is the user's, an empty one means yours.
We never see the private half. Lose it and the data is unreadable, by you and by us.
The mistake to guard against is registering one key while your deployment holds another — the two halves live on independently deployable sides, and nothing detects the mismatch until a read fails to decrypt, by which time the data is written under a key you do not have. The partner page shows a fingerprint of the key we hold (the first 16 hex characters of SHA-256 over the raw 65-byte public point). Print the same value from the private key your app is actually running with and compare the two after every deploy:
Replacing the key is not a revocation, and it is not instant. A user moves onto the new key at their next Connect; the data itself is only re-encrypted when a later sync re-fetches that account's full history. At a bank that will not serve a full history — some cap it at 90 days — that re-encrypting sync is refused and retried rather than importing a partial one, so an account can sit under the old key indefinitely.
Keep the old key. Reconnecting every user is not the finish line, and today we expose no signal for when the re-encryption has actually completed, so there is no moment at which discarding the old key is provably safe. Decrypt with whichever of your keys opens the envelope, and roll only when you mean it. The key cannot be removed, only replaced.
The discovery document advertises this mode as open_banking_io.recipient_key_supported.
#The private key is a credential
privateKey from the relay decrypts everything the API returns for this user's data in your tenant — a user who connects through two partners has two independent keys, and revoking the API key does not rotate it. Treat it exactly like the client secret: no logs, no URLs, no analytics, no third parties — and exclude your callback path from any reverse-proxy, WAF or APM request-body capture, which is where it actually leaks. Store it encrypted at rest, one row per user connection, or hold it in memory only; losing it means the user connects again.
The key lives 365 days; the bank consent behind it lives at most 180 days (many banks: 90). Expect a reconnect_needed well before the key expires and send the user through Connect again — that renews the bank consent and, on the same visit, the key.
Accounts, balances and transactions arrive as envelopes: ephemeral ECDH P-256 → HKDF-SHA256 → AES-256-GCM, decrypted locally with that key. Envelope formats are versioned and readers support every writer version; the SDKs decrypt for you, and the Node client builds straight from the token response and the relayed key:
#Revoking
POST {issuer}/oauth/revoke (RFC 7009), form-encoded token=ebk_… with the same client authentication as the token endpoint. Only a key issued to your client is touched; the answer is 200 whether or not the key existed. Call it when a user disconnects from your side; drop the private key with it.
If you hold your own decryption key, this call is also how the bank's sharing permission ends. Revoking the token stops you reading. The permission at the user's bank is separate, and closing it needs the Enable Banking session id — which is sealed to your key, so we cannot do it and neither can their browser. Send it with the revoke:
Two things to get right, and both are easy to get wrong:
Read GET /api/connections/open-consents, not GET /api/connections. The connections list hides revoked rows, and the largest source of stranded permissions is a reconnect: it revokes the replaced consent on our side and leaves it open at the bank. Draining only the live list leaves those behind for the rest of their validity. The open-consents list is exactly the set still open at the bank, each with its sealed sessionIdEnc, and your delegated key can already read it.
Send them all in one revoke. Both fields repeat and pair positionally — the nth connection_id with the nth eb_session_id. The revocation this rides on is what proves whose consents these are and it happens once, so a loop of one revoke per connection kills its own token on the first call and every close after it is refused. A single pair is just the one-element case.
The request is all-or-nothing: if any listed connection is not one of this token's own — or the same connection is listed twice — nothing is closed and the answer is invalid_request, the same answer an unknown connection gets, so ids cannot be probed. Your token survives a rejection: everything is validated before it is revoked, so you can correct the list and send the request again. Repeating a request that already succeeded answers 200 and changes nothing.
Without this the permission stays live at the person's bank until it expires — up to 180 days — and the only other way to end it is the person doing it themselves in their bank's app.
#Installing or replacing your decryption key
Every install is two steps — the first as well as a replacement. A mistyped key is a perfectly valid P-256 point, so the only thing that can tell your key from a typo is the private half:
POST /api/partner/recipient-key/challengewith thepublicKey. You get back achallengeTokenand anenvelopesealed to that key.- Open the envelope with that key's private half — the same
decryptTo()your callback uses, giving you{ nonce }— andPUT /api/partner/recipient-keywithpublicKey, thechallengeToken, and thatnonceaschallengeAnswer.
The challenge is valid for 15 minutes and is bound to that exact key, so an answer for one key cannot install another. Re-sending the key you already have is a no-op and needs no proof.
Replacing is not instant and cannot be undone. Tenants move to the new key at their next Connect; their ciphertext follows only when a later full-history sync re-fetches it, and nothing sealed to the old key is removed — so keep the old key, and decrypt with whichever of the two opens an envelope. A key whose private half you do not hold makes your users' data unreadable to everyone — you, them, and us.
A key can also end without you: the user revokes it under Connected apps on their open-banking.io developers page (a grant made through you is listed there, under your client's name), or a partner suspension turns it off. Either way the next request answers 401 — drop the bundle and offer to connect again.
#Inspecting a key
GET {issuer}/oauth/userinfo with the key: