Authentication
The Notabene API uses OAuth 2.0 with the client credentials grant, defined in RFC 6749, section 4.4. Your backend exchanges an API credential, a client ID and secret, for an access token at Notabene's authorization server, and sends that token as a bearer token on every call. Each access token is a signed JWT, so the API can check it on every request before it looks at your entity and at the permission the call needs.
Your own frontend never holds the credential or the access token. When a browser or app has to call Notabene, for example through the SafeConnect components, your backend gives it a short-lived delegate token instead.
sequenceDiagram
participant B as Your backend
participant A as Notabene authorization server
participant N as Notabene API
participant F as Your frontend
B->>A: POST /oauth/token with client ID, secret and audience
A-->>B: Access token (JWT, 24 hours)
B->>N: API call with Authorization: Bearer
N->>N: Signature, issuer, audience and expiry
N->>N: Your entity, then the permission
N-->>B: Response
B->>N: POST /entities/{entityDID}/delegateToken
N-->>B: Delegate token (1 hour)
B->>F: Delegate token
F->>N: Component calls with the delegate token
Regions
Notabene runs V2 in two regions, each with its own authorization server, API and dashboard. Your institution lives in one of them, and an API credential works only in the region where you created it.
| Region | Token URL | Audience and API base URL | Dashboard |
|---|---|---|---|
| EU | https://auth.notabene.id/oauth/token | https://api.eu1.notabene.id | app.eu1.notabene.id |
| US | https://us.auth.notabene.id/oauth/token | https://api.us1.notabene.id | app.us1.notabene.id |
Sandbox and production share the same API, audience and token URL in V2. What makes an entity a sandbox entity is its DID, such as did:web:yourcompany-1a2b3.sandbox.notabene.id:pt, so a sandbox credential and a production credential differ only in the entity they belong to.
The V1 API (api.notabene.id and api.notabene.dev) is a separate platform with its own credentials. A V2 credential cannot get a token for it, and a V1 token is refused by the V2 API.
API credentials
Create a credential in the dashboard under Settings › API credentials. The dashboard shows the client ID and the client secret once, so copy the secret into your secrets manager before you click Done, because Notabene cannot show it again.
A credential belongs to the entity it was created for, and each entity can hold up to 3 credentials at once. Your entity must be verified on the Notabene network before you can create one. Members with the Group Admin, Entity MLRO or Entity Admin role can manage credentials, as listed in Member Roles and Permissions.
Keep the secret on your backend only, never in a browser, a mobile app, a code repository or a log line. Give each of your environments its own credential, so a test system never holds a production secret.
Rotation
To rotate a credential without downtime, create a second one, move your backend to it, then delete the old one in the dashboard. Deleting a credential removes its access to your entity at once, including the access tokens already issued for it.
If a secret leaks, delete that credential straight away and create a new one. Then look in the audit log for actions taken with the leaked credential's client ID, as described under Audit log.
OAuth 2.0
In OAuth 2.0 terms, your backend is the client, Notabene's authorization server issues the tokens, and the Notabene API is the resource server that accepts them. The client credentials grant is meant for one server calling another, so no person signs in and nothing goes through a browser.
| OAuth 2.0 role | In Notabene V2 |
|---|---|
| Client | Your backend, identified by the client ID of its API credential |
| Authorization server | auth.notabene.id in the EU, us.auth.notabene.id in the US |
| Resource server | The Notabene API, api.eu1.notabene.id or api.us1.notabene.id |
| Resource owner | Your institution, which the client acts for directly |
API credentials can use only the client credentials grant. Asking for another grant, such as password, answers 403 with unauthorized_client.
Token request
Send a POST to your region's token URL with these parameters, as JSON or as the standard application/x-www-form-urlencoded form.
| Parameter | Required | Value |
|---|---|---|
grant_type | Yes | client_credentials |
client_id | Yes | The client ID of your API credential |
client_secret | Yes | Its client secret |
audience | Yes | Your region's API base URL, such as https://api.eu1.notabene.id |
scope | No | Leave it out. The token carries every permission of your credential, whatever you ask for. |
audience is not part of the core OAuth 2.0 standard, but Notabene requires it because it names the API the token is for. Without it, the token URL answers 403.
Your backend can send the client ID and secret in the body, or as HTTP Basic authentication, which RFC 6749, section 2.3.1 also allows. Any OAuth 2.0 client library works, provided it lets you add the audience parameter.
curl --request POST \
--url https://auth.notabene.id/oauth/token \
--header 'content-type: application/json' \
--data '{
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"grant_type": "client_credentials",
"audience": "https://api.eu1.notabene.id"
}'curl --request POST \
--url https://auth.notabene.id/oauth/token \
--user 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \
--data grant_type=client_credentials \
--data audience=https://api.eu1.notabene.id{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9",
"scope": "create:vasp read:vasp update:vasp delete:vasp create:delegatetoken create:transfer",
"expires_in": 86400,
"token_type": "Bearer"
}Token response
A successful request answers 200 with the fields of RFC 6749, section 5.1. The scope above is shortened.
| Field | Value |
|---|---|
access_token | The token to send on each API call |
token_type | Bearer |
expires_in | Seconds until the token expires, at most 86400 |
scope | The permissions the token carries, listed under Permissions |
There is no refresh_token, because the client credentials grant does not use one. When the token expires, your backend requests a new one with the same credential.
Token errors
A failed request answers with error and error_description, the error format of RFC 6749, section 5.2.
{
"error": "access_denied",
"error_description": "Unauthorized"
}Status and error | error_description | Cause |
|---|---|---|
400 invalid_request | Missing required parameter: grant_type | A required parameter is missing. |
403 unauthorized_client | Grant type 'password' not allowed for the client. | The grant is not client_credentials. |
401 access_denied | Unauthorized | The client ID or secret is wrong, or the credential belongs to the other region's token URL. |
403 access_denied | Client "<client_id>" is not authorized to access resource server "<audience>" | The audience belongs to another platform, such as V1, or the request has no audience. |
403 access_denied | Service not enabled within domain: <audience> | The audience is mistyped, for example without https://, or belongs to the other region. |
Sending the access token
Every API call needs the access token in the Authorization header, including creating transfers, address discovery and the beneficiary name check. A call without it answers 401. The token is sent as a bearer token, as RFC 6750, section 2.1 describes.
The only exception is your frontend, which uses a delegate token for the few calls an embedded component makes. Never put either token in a URL, where proxies and logs can keep it.
Authorization: Bearer YOUR_ACCESS_TOKENWhen the API refuses a token, it answers 401 with a WWW-Authenticate header, as in RFC 6750, section 3. Without a token the header is Bearer realm="api". For a token it rejects, the header adds error="invalid_token" and an error_description with the reason.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="api", error="invalid_token", error_description="Invalid Compact JWS"When a call answers 401, request a new token once and retry the call. If it fails again, the cause is in the request or the credential, and Errors lists the messages.
Token lifetime
An access token lasts 24 hours. Reuse it across calls and across your backend's workers, and request a new one about 5 minutes before it expires rather than for every call. Notabene may answer a repeat request with the token you already have, with expires_in counting the time it has left. Plan the refresh from expires_in, not from the time you received the token.
A small cache in your backend is enough, as in these two versions.
import time
import requests
TOKEN_URL = "https://auth.notabene.id/oauth/token"
AUDIENCE = "https://api.eu1.notabene.id"
_token = {"value": None, "expires_at": 0}
def access_token(client_id, client_secret):
# Reuse the token until 5 minutes before it expires
if _token["value"] and time.time() < _token["expires_at"] - 300:
return _token["value"]
response = requests.post(TOKEN_URL, json={
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "client_credentials",
"audience": AUDIENCE,
}, timeout=10)
response.raise_for_status()
body = response.json()
_token["value"] = body["access_token"]
_token["expires_at"] = time.time() + body["expires_in"]
return _token["value"]const TOKEN_URL = 'https://auth.notabene.id/oauth/token';
const AUDIENCE = 'https://api.eu1.notabene.id';
let token = { value: null, expiresAt: 0 };
export async function accessToken(clientId, clientSecret) {
// Reuse the token until 5 minutes before it expires
if (token.value && Date.now() < token.expiresAt - 300_000) return token.value;
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
grant_type: 'client_credentials',
audience: AUDIENCE,
}),
});
if (!response.ok) throw new Error(`Token request failed: ${response.status} ${await response.text()}`);
const body = await response.json();
token = { value: body.access_token, expiresAt: Date.now() + body.expires_in * 1000 };
return token.value;
}Token format
The access token is a JWT (RFC 7519) signed with RS256. Each authorization server publishes its signing keys as a JSON Web Key Set. Its endpoints are in an OpenID Connect discovery document, which OAuth libraries can read to find the token URL.
| Region | Discovery document | Signing keys |
|---|---|---|
| EU | https://auth.notabene.id/.well-known/openid-configuration | https://auth.notabene.id/.well-known/jwks.json |
| US | https://us.auth.notabene.id/.well-known/openid-configuration | https://us.auth.notabene.id/.well-known/jwks.json |
Your code does not need to validate the token, but decoding it is the quickest way to see which region and credential it belongs to.
| Claim | Value |
|---|---|
iss | https://auth.notabene.id/ in the EU, https://us.auth.notabene.id/ in the US |
aud | The audience you requested, such as https://api.eu1.notabene.id |
sub | Your client ID followed by @clients |
azp | Your client ID |
gty | client-credentials |
iat, exp | When the token was issued and when it expires, in seconds since 1 January 1970 |
scope, permissions | The permissions of your credential |
Not supported
For API access, Notabene uses only the client credentials grant, so there are no refresh tokens, no sign-in through a browser and no consent screen. There is no endpoint to introspect or revoke a single access token. To cut off a credential's tokens, delete the credential, as described under Rotation.
Delegate tokens are not OAuth 2.0 tokens. The API issues them, signed by your entity, and the token URL plays no part, as described under Delegate tokens.
Requests
Call the API over HTTPS only. It accepts TLS 1.2 and 1.3 and sends HSTS, and a plain http:// request is redirected, which would already have sent your token unencrypted.
On every request, the API first checks the token's signature, issuer, audience and expiry. It then checks that the DID in the path is one of your entities, and finally that your token has the permission the call needs.
To check a new credential end to end, list the entities it can act for. Add sandbox=true to see sandbox entities, since the list shows production entities by default.
curl --request GET \
--url 'https://api.eu1.notabene.id/entities?sandbox=true' \
--header 'authorization: Bearer YOUR_ACCESS_TOKEN'{
"entities": [
{
"did": "did:web:yourcompany-1a2b3.sandbox.notabene.id:pt",
"name": "Your Company",
"jurisdiction": "PT"
}
]
}Entity access
An access token acts for the entities its credential belongs to: the entity you created the credential for, and that entity's subsidiaries. An entity you create through the API with the credential is added to it as well. A call with any other DID in the path answers 403 with Caller does not have permission to access this specific Entity.
To use one credential for several entities that are not subsidiaries of each other, write to [email protected] with the entities' names and DIDs.
Permissions
The API checks a permission on each call, such as create:transfer to create a transfer. An API credential has every permission on its entities, which the token carries in scope and permissions. Dashboard members are limited by their role instead, as listed in Member Roles and Permissions.
| Area | Permissions |
|---|---|
| Your entity | create:vasp, read:vasp, update:vasp, delete:vasp |
| Delegate tokens | create:delegatetoken |
| Transfers | create:transfer, read:transfer, append:transfer, authorize:transfer, reject:transfer, settle:transfer, flag:transfer, freeze:transfer, clear:transfer, return:transfer, override:transfer |
| Policies | create:policy, read:policy, update:policy, delete:policy |
| Addresses | create:address, read:address, delete:address |
| Credentials | create:credential, read:credential, delete:credential |
| Agents | create:agent, update:agent |
| Customers | create:customer, read:customer, update:customer, delete:customer, read:customer:pii |
| Pay-ins and pay-outs | create:payin, read:payin, update:payin, delete:payin, create:payout, read:payout, update:payout, delete:payout |
| Connections | create:connection, read:connection, update:connection, cancel:connection, delete:connection |
| Members | read:organization-member, update:organization-member, delete:organization-member, create:organization-member-invite, read:organization-member-invites, cancel:organization-member-invite |
| API credentials | create:apicredential, read:apicredential, update:apicredential |
Delegate tokens
A delegate token lets your frontend make a few calls on behalf of one of your users without ever holding your access token. The SafeConnect components take it as their authToken, as in Configuring the SafeConnect Components, and your backend requests a new one for each session with Create a delegate token.
curl --request POST \
--url https://api.eu1.notabene.id/entities/YOUR_ENTITY_DID/delegateToken \
--header 'authorization: Bearer YOUR_ACCESS_TOKEN' \
--header 'content-type: application/json' \
--data '{
"entityDID": "YOUR_ENTITY_DID",
"delegateId": "urn:uuid:0c8f2c3e-5b7d-4c90-a1f2-3e4d5c6b7a80"
}'{
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJFZERTQSJ9",
"token_type": "Bearer",
"delegateDid": "did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH"
}Set entityDID to the same DID as in the path, and delegateId to an id of your own for the user, written as a urn:uuid: or a DID. Any other form answers 400 with delegateId must be a valid IRI. The same delegateId always gets the same delegateDid, so your user keeps one identity across sessions.
A delegate token is a JWT that your entity signs itself, with EdDSA and the Ed25519 signing key in your DID document. Its iss is your DID, its sub is the delegateDid, its scope is delegate, and it expires 1 hour after it was issued. The answer has no expires_in, so request a new token when your user starts a session, or read exp from the token. There is no call to revoke a delegate token, so the short lifetime is what limits it.
The API checks a delegate token against the signing key in your DID document, which it reads from the domain in your DID. If you host your DID document yourself, it must stay reachable and keep Notabene's signing key. Otherwise every delegate token answers 401 with no applicable key found in the JSON Web Key Set. DIDdoc setup explains how to host it.
A delegate token works only for your own entity and only on the calls a component needs:
| Call | Used for |
|---|---|
GET /network, GET /network/{entityDID} | Finding the counterparty and showing its details |
POST /entities/{entityDID}/address-ownership/discover | Finding who holds an address |
| The Flow payment calls (signal, authorization, agent, status, fund) | Pay-in and pay-out pages |
Any other call answers 401 or 403, as listed under Errors. Creating transfers and adding PII stay with your backend. It takes what the component collected and calls the API with its own access token, as in Embedded Components + V2 API.
Dashboard members
People sign in to the dashboard with their own account, by email and password or with Google, and Account linking explains how to use both for one person. Each member belongs to your organization with a role that sets what they can do. You can manage members in the dashboard or through the API, starting from List organization members. Remove a person's access when they leave your team, since their account is separate from your API credentials.
Audit log
Notabene records each action on your entity with who did it. An action taken with an API credential has actorType API and the credential's client ID as actorId, so you can tell your integration apart from people in the dashboard. Read it with List audit log events.
curl --request GET \
--url 'https://api.eu1.notabene.id/entities/YOUR_ENTITY_DID/audit-log?limit=5' \
--header 'authorization: Bearer YOUR_ACCESS_TOKEN'{
"data": [
{
"id": "968f349d-e78c-4c13-becf-60606e111796",
"timestamp": "2026-10-28T15:42:15.467Z",
"domain": "user",
"action": "update",
"actorType": "API",
"actorId": "YOUR_CLIENT_ID",
"entityDid": "did:web:yourcompany-1a2b3.sandbox.notabene.id:pt",
"result": { "status": "success" }
}
]
}Related security
Webhooks reach you from Notabene's webhook provider, Svix, which signs every message. Check that signature before you act on a webhook, as described in Webhooks. If your firewall allows only known addresses, Notabene IPs lists them, and Rate limiting gives the call limits. How Travel Rule PII is encrypted, by Notabene or by you, is explained in the PII Encryption Guide.
Errors
The API answers authentication problems with these messages in error. The token URL's own errors are listed under Token errors.
| Answer | Cause |
|---|---|
401, Unauthorized | The request has no Authorization: Bearer header. |
401, Invalid Compact JWS | The bearer value is not a whole JWT, for example cut short or wrapped in quotes. |
401, "exp" claim timestamp check failed | The token has expired. Request a new one. |
401, Unexpected 'aud' value | The token was issued for another platform, such as V1. |
401, signature verification failed | The token was altered. |
401, no applicable key found in the JSON Web Key Set | A delegate token was used on a call that needs an access token, or your DID document could not be read. |
403, Caller does not have permission to access this specific Entity | The DID in the path is not one of your credential's entities, or a delegate token was used on a call it cannot make. |
Checklist
- Secrets live in a secrets manager on your backend, with a separate credential for each environment.
- One access token is shared by your backend and refreshed from
expires_in. - Your frontend uses delegate tokens only, requested for each session.
- If you host your own DID document, it stays reachable with Notabene's signing key.
- Webhook signatures are checked before you act on a webhook.
- Credentials are rotated on a schedule, and deleted at once if a secret leaks.
- People who leave your team are removed from the dashboard.
Updated 5 days ago
