Reference
Every endpoint, parameter, error and limit of Partner Connect, pinned to the server by tests.
#Discovery
GET /.well-known/oauth-authorization-server (RFC 8414), cacheable for an hour. Use issuer as the value to compare against iss on the callback.
| Field | Value |
|---|---|
issuer | https://open-banking.io (https://staging.open-banking.io on staging) |
authorization_endpoint | {issuer}/oauth/authorize |
token_endpoint | {issuer}/oauth/token |
revocation_endpoint | {issuer}/oauth/revoke |
scopes_supported | ["accounts.read"] |
response_types_supported | ["code"] |
response_modes_supported | ["form_post"] |
grant_types_supported | ["authorization_code"] |
token_endpoint_auth_methods_supported | ["client_secret_post", "client_secret_basic"] |
revocation_endpoint_auth_methods_supported | ["client_secret_post", "client_secret_basic"] |
code_challenge_methods_supported | ["S256"] |
authorization_response_iss_parameter_supported | true — the relay carries iss (RFC 9207); compare it |
service_documentation | this site |
open_banking_io | extension block, below |
The open_banking_io extension carries userinfo_endpoint, api_base_url, api_key_header (X-Api-Key), bearer_supported (true), login_challenges_supported (["pin_code"], the values challenge accepts), key_relay (response_mode and the ordered fields of the callback relay) and documentation.
#GET /oauth/authorize
A top-level browser navigation. Parameters:
| Parameter | Required | Notes |
|---|---|---|
client_id | yes | your Connect client id |
redirect_uri | yes | one of the client's registered URIs, matched character for character |
state | recommended | opaque, echoed on the callback; compare it in constant time |
code_challenge | yes | PKCE, base64url(SHA-256(verifier)) |
code_challenge_method | no | only S256; defaults to it |
scope | no | only accounts.read; defaults to it |
response_type | no | only code; defaults to it |
response_mode | no | only form_post, and the default even when omitted — a deliberate deviation from the spec's query default, because the relay carries the user's key and must never ride a query string. Configure your library to request and accept form_post explicitly |
login_hint | no | prefills the email on the login page; never authenticates |
challenge | no | pin_code asks the login page for a typed 6-digit emailed code instead of a magic link (use it in popups); any other value is ignored |
Outcomes:
- The user is signed in → a one-time code is minted and the consent page opens. On approval the browser form-posts the relay to
redirect_uri. - The user is not signed in → a redirect to the login page carrying the full authorize URL as
returnUrl; the flow resumes after login. client_idorredirect_uriis invalid →400with a JSON body{ "error", "error_description" }. Nothing is sent to the redirect URI.- Anything else is invalid → a
form_postto the validatedredirect_uriwitherror,error_description,issand, when supplied,state. The page auto-submits and shows a return button for browsers without JavaScript. - The user cancels at any step (the "Back to {partner}" button on login, key setup, the bank picker or consent) → the same
form_postwitherror=access_denied. Expect it as a normal outcome, not a failure; consume thestateand show the user they cancelled.
#POST /oauth/token
Content-Type: application/x-www-form-urlencoded, response application/json with Cache-Control: no-store.
| Parameter | Required | Notes |
|---|---|---|
grant_type | yes | authorization_code |
code | yes | from the relay; single use, valid five minutes |
code_verifier | yes | the PKCE verifier |
redirect_uri | no | if sent, must equal the one used on /oauth/authorize. RFC 6749 makes it required; we accept its absence because the code is already bound to that URI and PKCE is mandatory |
client_id, client_secret | yes | in the body (client_secret_post) or as HTTP Basic (client_secret_basic), not both |
Response:
| Field | Value |
|---|---|
access_token | the API key, ebk_… |
token_type | Bearer |
expires_in | seconds until the key expires (365 days) |
scope | accounts.read |
apiKey | same as access_token (first-generation clients) |
apiBaseUrl | always equal to issuer; kept for first-generation clients |
user | the tenant subject the key reads for |
A JSON body { "code", "codeVerifier", "clientId", "clientSecret" } with Content-Type: application/json is accepted for first-generation clients and answers the same response.
Errors follow RFC 6749 §5.2 as { "error", "error_description" }:
| Status | error | When |
|---|---|---|
400 | invalid_request | a parameter is missing, both 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. The code is consumed before the verifier is checked, so a wrong verifier burns it |
#POST /oauth/revoke
RFC 7009. Form-encoded token (the API key) plus client authentication as for the token endpoint; token_type_hint is accepted and ignored. Only a key issued to the authenticated client is touched; the response is 200 with an empty body whether the key existed or not. A wrong secret answers 401 invalid_client.
#GET /oauth/userinfo
Authenticated with the key (Authorization: Bearer ebk_… or X-Api-Key). Returns:
| Field | Value |
|---|---|
sub | the tenant subject the key reads for |
email | the email the user signed in with |
partner_id | your partner id, or null for a house client |
client_id | the client the key was issued to |
scope | the granted scope |
expires_at | ISO 8601, or null |
#Reading data
Every key reads with Authorization: Bearer ebk_… or X-Api-Key: ebk_…, identically. The scope accounts.read permits GET /api/accounts, GET /api/accounts/{id}/transactions, GET /api/connections, POST /api/accounts/{id}/sync and POST /api/sync. Everything else answers 403. A revoked or expired key answers 401.
Both sync calls need the account's bank-side uid, which is an encrypted field: POST /api/accounts/{id}/sync takes { "uid": "…" } and POST /api/sync takes { "items": [{ "accountId": "…", "uid": "…" }] }. The SDKs decrypt it and post it for you (sync(accountId), syncAll()).
#Sync failures
A failed POST /api/accounts/{id}/sync answers with a machine-readable reason and a matching status; POST /api/sync answers 200 and lists the same reasons per account in failures[], except that it leaves withdrawn accounts out silently:
reason | Status | Meaning |
|---|---|---|
reconnect_needed | 409 | the bank consent expired; the user must reconnect that bank |
consent_withdrawn | 409 | the user withdrew consent for the account; reconnecting the same connection will not help |
rate_limited | 429 | the bank or the aggregator throttled the request; retry later |
bank_error | 502 | the bank answered with an error we cannot act on |
transient | 503 | network or upstream blip; retry |
partner_app_inactive | 503 | your Enable Banking application is not activated; nothing the user can do |
#Rate limits
Per source IP, per minute:
| Path | Limit |
|---|---|
/oauth/authorize, /oauth/cancel | 50 |
/oauth/token, /oauth/revoke, /oauth/userinfo | no limit — server-to-server, every merchant behind you shares your egress |
/api/* | 300 |
A 429 from these buckets carries no reason body; a 429 with reason: rate_limited is the bank's throttle surfacing through a sync.
#Branding
PUT /api/partner/branding (the partner owner's interactive session) with { "displayName", "logoUrl", "accentColor", "supportUrl", "termsUrl" }; null or blank clears a field. displayName ≤ 80 characters; accentColor is #rrggbb; URLs are https://, ≤ 2048 characters, without credentials or a fragment, and supportUrl/termsUrl must live under the registered domain when one is pinned. GET /api/partner/me returns it under branding, with logoUrl as the path our host serves the logo from.
The screens read it anonymously through GET /api/connect/clients/{client_id}/branding; you never call that yourself.
#Redirect URIs
Up to 10 per client, each at most 2048 characters, absolute, https:// (plain http:// only on loopback), without a fragment or credentials. Matching on /oauth/authorize is exact.
#Lifetimes
| What | Lifetime |
|---|---|
| Authorization code | 5 minutes, single use |
| API key from the token endpoint | 365 days |
| Connect flow cookie (the popup session) | 1 hour |
PIN code (challenge=pin_code) | 15 minutes, 5 attempts, 6 digits |
| Rotated client secret | previous secret valid 24 hours |
#Contract
The block below is read by the server's and the web app's test suites; a value here that drifts from the code fails the build.