Authentication
Audience: 🔌 Integration Client
This page covers the API key, the data-plane credential your integration uses. The admin session (login, 2FA, and how a key is first issued) is in Signing in & 2FA.
BankConnector has two credentials:
| Credential | Header | Plane | What it's for |
|---|---|---|---|
| API key | X-API-Key: key_… | data plane | submitting payments, reading journal / reconciliation / statements, webhooks |
| Admin session | X-Session-Token: … | control plane | creating/managing users, bank connections, approval policies |
Building a third-party add-on a customer installs into their own account, rather than an integration you run with a key you hold yourself? See "Application installs (OAuth 2.0, for add-on developers)" further down this page instead. It is a different, narrower credential the customer authorizes and can revoke on their own.
An API key cannot create admin users or set up connections. Those are admin-session actions and return 403.
If you provisioned a sandbox (POST /api/sandbox/provision, then /verify with the emailed code), the response hands you the apiKey only. For an admin session, sign up at /signup with an email you verify. (Sandbox tenants and their credentials are deleted after 30 days of inactivity.)
The API key (data plane)
Your integration's credential, sent as a single header:
X-API-Key: key_<64 hex characters>
- The key is bound to one company (the organisation it was issued for) and is
key_followed by 64 hex characters. - There is no tenant header. You pass
companyId, in the body or query, and it must be the key's own company. A request naming any other company is rejected (403 forbidden). - No expiry, no TTL, no refresh. A key is valid until revoked. Rotation is manual: create a new key, cut over, revoke the old one.
- The key is shown once, at creation, and cannot be recovered. If you lose it, rotate.
A key cannot reach another company's data, even one you also administer. An integration that serves several companies holds one key per company.
What an API key cannot do
API keys drive machine tasks: payments, webhooks, exports. They get 403 on control-plane routes that need an authenticated user:
- Naming a bank account (
PATCH /api/accounts/<id>) - Bank connection setup and activation
- Approval-policy management
They also can't call session-only identity routes (/auth/me, /me/sessions, /2fa/*): those return 401 for a key.
User provisioning (capped): an API key can headlessly invite
viewerusers (POST /api/users), remove roles, and grantviewer, so an ERP can give its own staff read access without a User in the dashboard. But a key is deliberately capped so it can't escalate: it cannot create or grantadminorapprover(either needs an admin's two-factor code), change an admin's roles, reset a password, or erase a user. Those need an admin session. Still treat production keys as privileged: store them in a secret manager and rotate on staff changes.
To do those, an admin logs in with a session: Signing in & 2FA.
An admin of the company mints the API key, and only its metadata is listable afterwards. A provisioned sandbox returns the key in the
POST /api/sandbox/provision/verifyresponse.
Required headers for a machine client
For a normal data-plane JSON request you need only:
X-API-Key: key_…
Content-Type: application/json # for JSON bodies (some routes take raw XML)
Idempotency-Key: <key> # on POST /api/journal/payments, see Idempotency
You do not send cookies, CSRF tokens or an Origin. CSRF applies only to cookie-based browser sessions.
Optional headers worth knowing
| Header | Purpose |
|---|---|
BankConnector-Version: YYYY-MM-DD | pin the API version (baseline 2026-06-01). Echoed back on every response, including errors. A malformed date is a 400. |
X-Request-ID | set by the server on every response. Log it; support can look up any request by it. |
Idempotency-Replayed: true | response header telling you a payment was a replay, not a fresh execution. |
Auth failures
| Situation | Status | code |
|---|---|---|
| Missing / invalid credential | 401 | unauthorized |
Credential valid but wrong scope (e.g. a companyId other than the key's own company) | 403 | forbidden |
API key on most control-plane routes (e.g. PATCH /api/accounts/<id>) | 403 | forbidden |
API key on the bank-connection routes (/connections, /connections/<id>/…) | 403 | admin_required |
API key on the approval-policy mutation routes (POST /approvals/policies, .../<id>/…) | 403 | admin_required |
API key on a session-only identity route (/auth/me, /me/sessions, /2fa/*) | 401 | unauthorized |
⚠️ Don't branch on admin_required alone. Some control-plane denials come back as plain 403 forbidden, and routes that require an admin session (bank connections, approval-policy mutations, user administration, among others) answer 403 admin_required instead; this table gives examples, not a full list. Branch on the 403 status and treat both codes the same way: you need an admin session, and retrying with the API key will never succeed.
A 401 means re-authenticate (check the key or log in again). A 403 means this request will not succeed as written: switch to an admin session.
Application installs (OAuth 2.0, for add-on developers)
Audience: add-on / ERP-plugin developers. If you run your own integration with your own API key, skip this section. It's for a third-party add-on a customer installs into their BankConnector account. A stock OAuth 2.0 client library works.
The flow is standard (RFC 6749 §4.1, RFC 7636 PKCE, RFC 8252 for native apps). Read the scope section below: it decides what your add-on can touch, and it is narrower than an API key.
The authoritative source: RFC 8414 discovery
Point your OAuth client library at:
GET /.well-known/oauth-authorization-server
This RFC 8414 metadata document is generated from what the server enforces. scopes_supported lists what an application install may be granted; identity_scopes_supported lists what a person may carry into an ERP add-on on their own sign-in. Configure your library from it rather than hardcoding the endpoint URLs below.
Its issuer and endpoint URLs are the deployment's own public origin. Fetch it from the origin you intend to use and, as RFC 8414 §3.3 requires, refuse a document whose issuer is not that origin.
The flow
GET /app/authorize: your add-on opens this in the system browser (RFC 8252: never an embedded webview) with the standard RFC 6749 §4.1.1 query parameters:response_type=code,client_id,redirect_uri,scope(space-delimited, drawn from the vocabulary below),state,code_challenge,code_challenge_method=S256. PKCE is mandatory. A desktop add-on is a public native-app client with no client secret: it authenticates by proving it holds thecode_verifiermatching thecode_challenge.- The signed-in Admin who owns the account sees a consent screen and approves or declines. On approval the browser is redirected to your registered loopback address (
http://127.0.0.1:<any port>/<your path>, exact match;localhostis never accepted) carrying a single-use authorizationcode(10-minute TTL) and yourstate. A web or mobile app registers exact redirect URIs instead; see From a web or mobile app. POST /api/oauth/token(application/x-www-form-urlencoded) exchanges the code +code_verifierfor a token pair:access_token(bc_oat_…): 1 hour lifetime, sent asAuthorization: Bearer <access_token>against the product API exactly like an API key.refresh_token(bc_ort_…): 180 days, and rotates on every use:grant_type=refresh_tokenconsumes the presented token and mints a new pair. The just-consumed token can be retried for 30 seconds, as long as the pair it produced hasn't been used yet, so a client that crashes (or loses the response) before saving the rotation can retry and gets a fresh pair. Replaying it after that window, or once the new pair is in use, is treated as theft and revokes the entire token family. The user then has to re-consent.POST /api/oauth/revoke(RFC 7009) revokes a token your add-on no longer needs (on uninstall, for example). It always answers200, including for an already-revoked, expired, or unknown token (RFC 7009 §2.2). Revoking the refresh token also ends the install it belongs to: the customer's Connected apps screen then lists it as Disconnected. Revoke the old refresh token when a new connection replaces it, and when the user disconnects.
⚠️
/api/oauth/tokenand/api/oauth/revokedo not use this API's usual error envelope (see Errors & retries). They answer the flat RFC 6749 §5.2 shape,{ "error": "invalid_grant", "error_description": "…" }. Don't reuse yourerror.codehandling here.
The device flow (RFC 8628) is not supported: /api/oauth/token refuses the device grant. Use the authorization code flow above.
Scopes: what your add-on can reach
An install's authority is its grant: the set of scopes the authorizing Admin approved. It is never the identity of the human who clicked "Allow". The API Reference states this per operation: every endpoint an install can call carries an OAuth2 security option listing the exact scope it needs, right beside the ApiKeyAuth / SessionToken options a first-party integration would use instead. If an operation's reference entry carries no OAuth2 option, no grant reaches it. See the two carve-outs below before assuming that's an oversight.
The full grantable (delegable) vocabulary:
| Scope | Covers |
|---|---|
payments:submit | Submitting payments (POST /journal/payments and previews). |
payments:read | Reading journal documents / reconciliation data. |
statements:read | Reading account statements and balances. |
engine:use | The stateless conversion/validation engine (/convert, /validate, bank + payment-type discovery). |
banks:read | Reading bank connectivity/profile metadata. |
webhooks:manage | Registering and managing webhook endpoints. |
users:read | Reading the user list (GET /users) with each person's approverBanks; never inviting or re-roling. |
onboarding:read | Reading setup progress (GET /onboarding/status) and the partner (GET /onboarding/partner). |
partner:propose | Proposing the partner's Uniconta number (POST /onboarding/partner/uniconta-pid-proposals); sets nothing. |
Request only what your add-on needs. The consent screen shows the Admin this list by name.
Some actions can never be delegated to any add-on, whatever the authorizing Admin holds: bank connection setup/activation, approval-policy management, approval decisions themselves, and managing other add-ons' grants. These require a live, signed-in human Admin session every time. So a compromised add-on cannot redirect where payments go or weaken the approval control. The one path to an approval decision inside an add-on is a person's own sign-in, never the install; see identity grants below.
⚠️
webhooks:manageis in the grantable vocabulary, but the webhook management endpoints are not install-callable yet. Today those routes admit a signed-in Admin or the account's own API key, not an OAuth-authorized install. Requestingwebhooks:manageis accepted at consent time, but calls to/webhooks/*with that token will still403. Provision webhook endpoints with an API key or an Admin session for now; ask your BankConnector contact for the current status if your add-on needs to self-serve this.
A person's own sign-in inside the add-on (identity grants)
Everything above is the install: a machine actor, authorized by an Admin for the organisation, that can never approve a payment. Separately, a person can sign in to your add-on as themselves, so they can approve inside the ERP (or, as an Admin, invite colleagues or follow setup) without switching to the browser.
- Request it through the same
GET /app/authorize→POST /api/oauth/tokenflow, with ascopemade only of identity scopes. A request that mixes identity scopes with the install scopes above is refused (invalid_scope), except from the Uniconta add-on, which may ask for both in one consent: the loopback then carriesidentity_codebesidecode, orerror_reasonwhen the person may not hold the sign-in, and each code redeems with the samecode_verifier. Your application must be registered for the identity scopes it asks for.
| Identity scope | Who may hold it | Covers |
|---|---|---|
approvals:read | an approver | their own queue (GET /approvals/pending) and GET /approvals/{id} |
approvals:decide | an approver | POST /approvals/{id}/approve and /reject |
users:invite | an Admin | POST /users: invite-only, non-admin users (approver or viewer) |
setup:read | an Admin | GET /onboarding/status (see Guiding setup) |
- The person consents for themselves, in the browser, with a fresh two-factor code. Nobody can grant it on their behalf. The person sees and ends their own add-on sign-ins in their settings (
GET /me/erp-grants,POST /me/erp-grants/{grantId}/revoke), and an Admin sees and revokes anyone's withGET /users/{id}/erp-grantsandPOST /users/{id}/erp-grants/{grantId}/revoke. A sign-in also ends when the person's password is changed or reset, or their two-factor authentication is turned off or reset; the add-on then signs them in again. - The organisation must switch it on. An Admin turns it on in the browser (
PATCH /company/erp-approval, read withGET /company/erp-approval). It is off by default; for an organisation that signed up from an ERP add-on it turns on by itself once both preconditions below hold, unless an Admin has set it first. Turning it on is refused (409) unless the organisation requires two-factor authentication (erp_approval_requires_two_factor) and every active approval policy names its approvers (erp_approval_requires_named_approvers). Until then the consent screen refuses identity scopes (403 erp_approval_unavailable). A grant holding onlysetup:readis exempt from these preconditions. - Every decision is checked again.
GET /approvals/{id}returns the request's payment lines, totals and acontentDigest. The approve or reject call must echo thatcontentDigestand carry a fresh two-factor code (code, orbackupCode) every time. A digest that differs from the current content is409 content_changed; a request the add-on may not decide (for example a batch above the organisation's optionalmaxBatchAmountceiling) is403 decide_in_browser, and the approver decides it in the browser instead.GET /approvals/{id}answers404for a request that does not name the signed-in person as an approver. - Nothing about what may be released changes: the approval policy, its approvers, limits, ordered tiers and the two-person minimum all apply exactly as in the browser. Each invite from the add-on needs the Admin's fresh code and is emailed to every Admin in the organisation; an invited approver can approve nothing until an Admin names them in a policy in the browser.
From a web or mobile app
The same identity grant works from a platform's own web app or phone app, so a person can approve payments there. The flow, the scopes and every rule above are the same; only the redirect and the client's authentication differ.
- Registration. BankConnector registers each app as its own application (its own
client_id), with the exact redirect URIs it may use and the identity scopes it may ask for. Register the web app and the mobile app separately: a person has one sign-in per application, and a new sign-in to the same application ends their earlier one. - Web app. The redirect is an
httpsURL you registered, for examplehttps://app.example.com/oauth/callback. It must match exactly: the same host, path and port, with no query or fragment. A web app may also be given a client secret (bc_ocs_…, shown once). Keep it on your server. A client with a secret must send it on everyPOST /api/oauth/tokenandPOST /api/oauth/revoke, either as HTTP Basic (Authorization: Basic base64(client_id:client_secret)) or asclient_secretin the form, but not both. A missing or wrong secret is401 invalid_client. PKCE is still required. - Mobile app. Open the authorize URL in the system browser (
ASWebAuthenticationSessionon iOS, Custom Tabs on Android), never an embedded web view (RFC 8252). The redirect is either anhttpslink your app has claimed (a universal link or app link) or a private-use scheme in reverse-domain form with one slash, such ascom.example.app:/oauth2redirect(RFC 8252 §7.1). A mobile app gets no client secret, because one shipped inside an app can be extracted. PKCE alone authenticates it, and a request that sends a secret is refused. - The person signs in to BankConnector in that browser if they are not already signed in, and approves the sign-in with a fresh two-factor code. The browser then goes to your redirect with
codeand yourstate. Checkstatebefore you redeem the code.
Guiding setup from an add-on
An add-on's setup wizard can send the customer to the browser-only steps and follow their progress:
- Signup, pre-filled. Open
/signupwith the values in the URL fragment (after#, never the query string):companyName,registrationNumber,country,adminName,adminEmailandsource=erp:<slug>. Every value stays editable and is validated as if typed.sourceis recorded for attribution only; a malformed one is refused with422. - Deep links into the app land on the step:
/app/banks/<bankKey>,/app/policy/new,/app/policy/edit/<policyId>(#add=<userId>preselects a person to name) and/app/company. Add#erp=<slug>and the page says where the Admin came from and when they can return to the ERP. Add&org=<organisation id>(organisation.idfromGET /oauth/userinfo) and the link acts only in that organisation: a browser signed in to another one fills nothing in and is told so. - Progress.
GET /onboarding/statusreturns what the organisation has set up: whether it requires two-factor authentication and has approving in the ERP on, each bank connection's bank key, name and status, each active approval policy and whether it names its approvers, and each user's name, email, roles, status and two-factor enrolment. It carries states, never secrets. It answers an Admin's own add-on sign-in holdingsetup:read, the add-on's company connection holdingonboarding:read, or an Admin in the browser; an install without that scope, an API key and a non-Admin get403.
Your add-on's version
Name your add-on's version as the first product token of the User-Agent on every request: User-Agent: <product>/<major>.<minor>.<patch>, for example BankConnector-Uniconta/1.4.2. Three whole numbers; anything else counts as no version.
BankConnector can set a minimum version for your application, below which it stops serving the add-on. A request authenticated by your application's access token that names an older version, or none, is then refused with 403 addon_update_required. Its message tells the user how to update, and details carries clientVersion (null when the request named none) and minimumVersion. Show the message and stop; retrying or signing in again gets the same answer until the add-on is updated. While your application has no minimum, every version is served, including a request that names none.