Reference

Every endpoint, parameter, error and limit of Partner Connect, pinned to the server by tests.

Partnerdokumentationen udgives på engelsk.

#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.

FieldValue
issuerhttps://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_supportedtrue — the relay carries iss (RFC 9207); compare it
service_documentationthis site
open_banking_ioextension 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:

ParameterRequiredNotes
client_idyesyour Connect client id
redirect_uriyesone of the client's registered URIs, matched character for character
staterecommendedopaque, echoed on the callback; compare it in constant time
code_challengeyesPKCE, base64url(SHA-256(verifier))
code_challenge_methodnoonly S256; defaults to it
scopenoonly accounts.read; defaults to it
response_typenoonly code; defaults to it
response_modenoonly 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_hintnoprefills the email on the login page; never authenticates
challengenopin_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_id or redirect_uri is invalid → 400 with a JSON body { "error", "error_description" }. Nothing is sent to the redirect URI.
  • Anything else is invalid → a form_post to the validated redirect_uri with error, error_description, iss and, 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_post with error=access_denied. Expect it as a normal outcome, not a failure; consume the state and show the user they cancelled.

#POST /oauth/token

Content-Type: application/x-www-form-urlencoded, response application/json with Cache-Control: no-store.

ParameterRequiredNotes
grant_typeyesauthorization_code
codeyesfrom the relay; single use, valid five minutes
code_verifieryesthe PKCE verifier
redirect_urinoif 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_secretyesin the body (client_secret_post) or as HTTP Basic (client_secret_basic), not both

Response:

FieldValue
access_tokenthe API key, ebk_…
token_typeBearer
expires_inseconds until the key expires (365 days)
scopeaccounts.read
apiKeysame as access_token (first-generation clients)
apiBaseUrlalways equal to issuer; kept for first-generation clients
userthe 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" }:

StatuserrorWhen
400invalid_requesta parameter is missing, both authentication methods were used, or the content type is neither form nor JSON
400unsupported_grant_typegrant_type is not authorization_code
401invalid_clientunknown client or wrong secret; WWW-Authenticate: Basic realm="oauth" when Basic was used
400invalid_grantthe 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:

FieldValue
subthe tenant subject the key reads for
emailthe email the user signed in with
partner_idyour partner id, or null for a house client
client_idthe client the key was issued to
scopethe granted scope
expires_atISO 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:

reasonStatusMeaning
reconnect_needed409the bank consent expired; the user must reconnect that bank
consent_withdrawn409the user withdrew consent for the account; reconnecting the same connection will not help
rate_limited429the bank or the aggregator throttled the request; retry later
bank_error502the bank answered with an error we cannot act on
transient503network or upstream blip; retry
partner_app_inactive503your Enable Banking application is not activated; nothing the user can do

#Rate limits

Per source IP, per minute:

PathLimit
/oauth/authorize, /oauth/cancel50
/oauth/token, /oauth/revoke, /oauth/userinfono 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

WhatLifetime
Authorization code5 minutes, single use
API key from the token endpoint365 days
Connect flow cookie (the popup session)1 hour
PIN code (challenge=pin_code)15 minutes, 5 attempts, 6 digits
Rotated client secretprevious 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.

json

{
  "syncFailureReasons": {
    "reconnect_needed": 409,
    "consent_withdrawn": 409,
    "rate_limited": 429,
    "bank_error": 502,
    "transient": 503,
    "partner_app_inactive": 503
  },
  "relayFields": ["code", "state", "iss", "privateKey", "publicKey"],
  "tokenResponseFields": ["apiKey", "apiBaseUrl", "user", "access_token", "token_type", "expires_in", "scope"],
  "discoveryFields": {
    "issuer": true,
    "authorization_endpoint": true,
    "token_endpoint": true,
    "revocation_endpoint": true,
    "scopes_supported": true,
    "response_types_supported": true,
    "response_modes_supported": true,
    "grant_types_supported": true,
    "token_endpoint_auth_methods_supported": true,
    "revocation_endpoint_auth_methods_supported": true,
    "code_challenge_methods_supported": true,
    "authorization_response_iss_parameter_supported": true,
    "service_documentation": true,
    "open_banking_io": {
      "userinfo_endpoint": true,
      "api_base_url": true,
      "api_key_header": true,
      "bearer_supported": true,
      "login_challenges_supported": true,
      "key_relay": true,
      "documentation": true
    }
  },
  "authorizeErrors": {
    "unknown client_id": "invalid_request",
    "redirect_uri is not allowlisted for this client": "invalid_request",
    "only the code response_type is supported": "unsupported_response_type",
    "only form_post response_mode is supported": "invalid_request",
    "code_challenge is required": "invalid_request",
    "only S256 code_challenge_method is supported": "invalid_request",
    "unsupported scope": "invalid_scope",
    "the user declined the authorization": "access_denied"
  },
  "rateLimitsPerMinute": {
    "/oauth/authorize": 50,
    "/oauth/cancel": 50,
    "/oauth/token": 0,
    "/oauth/revoke": 0,
    "/oauth/userinfo": 0,
    "/api/*": 300
  },
  "lifetimes": {
    "authorizationCodeSeconds": 300,
    "accessTokenSeconds": 31536000
  }
}