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.

RegionToken URLAudience and API base URLDashboard
EUhttps://auth.notabene.id/oauth/tokenhttps://api.eu1.notabene.idapp.eu1.notabene.id
UShttps://us.auth.notabene.id/oauth/tokenhttps://api.us1.notabene.idapp.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 roleIn Notabene V2
ClientYour backend, identified by the client ID of its API credential
Authorization serverauth.notabene.id in the EU, us.auth.notabene.id in the US
Resource serverThe Notabene API, api.eu1.notabene.id or api.us1.notabene.id
Resource ownerYour 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.

ParameterRequiredValue
grant_typeYesclient_credentials
client_idYesThe client ID of your API credential
client_secretYesIts client secret
audienceYesYour region's API base URL, such as https://api.eu1.notabene.id
scopeNoLeave 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.

FieldValue
access_tokenThe token to send on each API call
token_typeBearer
expires_inSeconds until the token expires, at most 86400
scopeThe 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 errorerror_descriptionCause
400 invalid_requestMissing required parameter: grant_typeA required parameter is missing.
403 unauthorized_clientGrant type 'password' not allowed for the client.The grant is not client_credentials.
401 access_deniedUnauthorizedThe client ID or secret is wrong, or the credential belongs to the other region's token URL.
403 access_deniedClient "<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_deniedService 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_TOKEN

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

RegionDiscovery documentSigning keys
EUhttps://auth.notabene.id/.well-known/openid-configurationhttps://auth.notabene.id/.well-known/jwks.json
UShttps://us.auth.notabene.id/.well-known/openid-configurationhttps://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.

ClaimValue
isshttps://auth.notabene.id/ in the EU, https://us.auth.notabene.id/ in the US
audThe audience you requested, such as https://api.eu1.notabene.id
subYour client ID followed by @clients
azpYour client ID
gtyclient-credentials
iat, expWhen the token was issued and when it expires, in seconds since 1 January 1970
scope, permissionsThe 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.

AreaPermissions
Your entitycreate:vasp, read:vasp, update:vasp, delete:vasp
Delegate tokenscreate:delegatetoken
Transferscreate:transfer, read:transfer, append:transfer, authorize:transfer, reject:transfer, settle:transfer, flag:transfer, freeze:transfer, clear:transfer, return:transfer, override:transfer
Policiescreate:policy, read:policy, update:policy, delete:policy
Addressescreate:address, read:address, delete:address
Credentialscreate:credential, read:credential, delete:credential
Agentscreate:agent, update:agent
Customerscreate:customer, read:customer, update:customer, delete:customer, read:customer:pii
Pay-ins and pay-outscreate:payin, read:payin, update:payin, delete:payin, create:payout, read:payout, update:payout, delete:payout
Connectionscreate:connection, read:connection, update:connection, cancel:connection, delete:connection
Membersread:organization-member, update:organization-member, delete:organization-member, create:organization-member-invite, read:organization-member-invites, cancel:organization-member-invite
API credentialscreate: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:

CallUsed for
GET /network, GET /network/{entityDID}Finding the counterparty and showing its details
POST /entities/{entityDID}/address-ownership/discoverFinding 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.

AnswerCause
401, UnauthorizedThe request has no Authorization: Bearer header.
401, Invalid Compact JWSThe bearer value is not a whole JWT, for example cut short or wrapped in quotes.
401, "exp" claim timestamp check failedThe token has expired. Request a new one.
401, Unexpected 'aud' valueThe token was issued for another platform, such as V1.
401, signature verification failedThe token was altered.
401, no applicable key found in the JSON Web Key SetA 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 EntityThe 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.

Did this page help you?