> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bland.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# OAuth client credentials

> Send server-to-server requests to the Bland API with access tokens that expire after one hour, instead of a long-lived API key.

An OAuth client lets your server request an access token from Bland and use that token to call the API. The token lasts one hour. The client's credentials stay on your server and go only to the token endpoint, never with an API request.

Use OAuth clients when your security policy asks for short-lived tokens, or when you want a long-lived secret to stay out of every request. Use an API key when you don't need that.

| | API key | OAuth client |
| - | - | - |
| What you send to the API | The key, with every request | An access token that expires after 1 hour |
| How long it works | Until you delete the key | The token for 1 hour. The client until you disable or delete it |
| What it can reach | Everything, or the permissions you pick for a [restricted key](/api-v1/post/keys-create.md#restricted-keys) | The permissions you pick for the client |

A token reaches the same routes as a restricted API key with the same permissions.

## Before you start

* You must be an owner or admin of your organization.
* You need access to OAuth clients. If you don't see **OAuth clients** on Settings > API keys, contact Bland support.
* You create and manage clients in the dashboard only. API keys and access tokens can't create or manage them.
* OAuth clients are in the V2 dashboard, at the addresses in the table below. Create the client in the dashboard for your organization's region, and use that region's API host. A client works only in the region where you created it.

| Region | Dashboard | API host | Token endpoint |
| - | - | - | - |
| US | `https://v2.app.bland.ai` | `https://api.bland.ai` | `https://api.bland.ai/authorization/oauth2/token` |
| EU | `https://v2.eu.app.bland.ai` | `https://eu.api.bland.ai` | `https://eu.api.bland.ai/authorization/oauth2/token` |
| Canada | `https://v2.ca.app.bland.ai` | `https://ca.api.bland.ai` | `https://ca.api.bland.ai/authorization/oauth2/token` |
| Asia | `https://v2.asia.app.bland.ai` | `https://asia.api.bland.ai` | `https://asia.api.bland.ai/authorization/oauth2/token` |

The examples below use the EU host. Replace it with yours.

## Create a client

**1. Open the page**

Go to Settings > API keys and find **OAuth clients**. Select **Create OAuth client**.

**2. Name the client and pick permissions**

Enter a name of up to 64 characters. Under **Permissions**, pick at least one. A token can never reach more than the client's permissions. See [Permissions](#permissions).

**3. Choose how the client signs in**

Pick **Client secret** or **Private key JWT**.

With **Client secret**, Bland generates a secret and shows it once. With **Private key JWT**, you sign each token request with your own private key, and you paste the matching public keys as a JWK Set. Each key needs a `kid`. Use RSA keys of at least 2048 bits, or EC keys on P-256, P-384, or P-521, with no private parameters.

**4. Save the credentials**

Select **Create OAuth client**. Copy the client ID and, for a client secret, the secret. Bland shows the secret only once. If you lose it, rotate the secret to get a new one.

You can't change a client's permissions or keys in the dashboard. To change them, create a new client, switch your integration to it, and delete the old one.

## Get an access token

Send a `POST` request to your region's token endpoint with `grant_type=client_credentials`. The endpoint accepts form-encoded bodies.

| Parameter | Required | Value |
| - | - | - |
| `grant_type` | Yes | `client_credentials` |
| `scope` | No | Space-separated scopes, each one of the client's permissions. If you leave it out, the token carries all of the client's permissions. |
| `resource` | No | Your region's API host with no path and no trailing slash, for example `https://eu.api.bland.ai`. If you leave it out, it defaults to that value. Any other value is refused. |

Authenticate the request in one of two ways, matching how you created the client. A client can use only the method it was created with.

### Client secret

Send the client ID and secret in the HTTP `Authorization` header, using Basic authentication. Don't put the secret in the request body.

```bash theme={null}
curl -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials \
  --data-urlencode 'scope=calls:read agents:write' \
  https://eu.api.bland.ai/authorization/oauth2/token
```

The response looks like this. There is no refresh token.

```json theme={null}
{
  "access_token": "eyJhbGciOiJSUzI1NiIs...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "calls:read agents:write"
}
```

### Private key JWT

Sign a short-lived JWT (a client assertion) with your private key, and send it instead of a secret. The assertion needs these claims:

| Claim or header | Value |
| - | - |
| `alg` (header) | For an RSA key, `RS256`, `RS384`, `RS512`, `PS256`, `PS384`, or `PS512`. For an EC key, `ES256`, `ES384`, or `ES512`, matching the key's curve |
| `kid` (header) | The `kid` of the matching public key in your JWK Set |
| `iss` | Your client ID |
| `sub` | Your client ID |
| `aud` | The token endpoint you are posting to, for example `https://eu.api.bland.ai/authorization/oauth2/token` |
| `jti` | A unique ID for this assertion. Bland refuses an assertion it has already seen |
| `exp` | No more than 5 minutes in the future |

Build a new assertion for each token request. Send these fields, and don't send an `Authorization` header:

* `grant_type=client_credentials`
* `client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer`
* `client_assertion=<your signed JWT>`
* `scope`, if you want fewer than all of the client's permissions

To make a key pair and a JWK Set to paste into the dashboard, run this once. It writes the private key to `private-key.pem` and prints the public JWK Set.

```js theme={null}
import { writeFile } from "node:fs/promises";
import { exportJWK, exportPKCS8, generateKeyPair } from "jose";

const { publicKey, privateKey } = await generateKeyPair("RS256", {
  extractable: true,
});
const jwk = {
  ...(await exportJWK(publicKey)),
  kid: "my-key-1",
  alg: "RS256",
  use: "sig",
};
await writeFile("private-key.pem", await exportPKCS8(privateKey));
console.log(JSON.stringify({ keys: [jwk] }, null, 2));
```

```js Node.js theme={null}
import { randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { importPKCS8, SignJWT } from "jose";

const tokenEndpoint = "https://eu.api.bland.ai/authorization/oauth2/token";
const clientId = process.env.BLAND_CLIENT_ID;
const keyId = process.env.BLAND_KEY_ID; // the kid in your JWK Set

const privateKey = await importPKCS8(
  readFileSync("private-key.pem", "utf8"),
  "RS256",
);

const assertion = await new SignJWT({})
  .setProtectedHeader({ alg: "RS256", kid: keyId })
  .setIssuer(clientId)
  .setSubject(clientId)
  .setAudience(tokenEndpoint)
  .setJti(randomUUID())
  .setIssuedAt()
  .setExpirationTime("2m")
  .sign(privateKey);

const response = await fetch(tokenEndpoint, {
  method: "POST",
  body: new URLSearchParams({
    grant_type: "client_credentials",
    client_assertion_type:
      "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion: assertion,
    scope: "calls:read",
  }),
});
if (!response.ok) {
  throw new Error(
    `Token request failed: ${response.status} ${await response.text()}`,
  );
}
const { access_token, expires_in } = await response.json();
```

```python Python theme={null}
import os
import time
import uuid

import jwt  # PyJWT, with the cryptography package installed
import requests

TOKEN_ENDPOINT = "https://eu.api.bland.ai/authorization/oauth2/token"
CLIENT_ID = os.environ["BLAND_CLIENT_ID"]
KEY_ID = os.environ["BLAND_KEY_ID"]  # the kid in your JWK Set

with open("private-key.pem") as key_file:
    private_key = key_file.read()

now = int(time.time())
assertion = jwt.encode(
    {
        "iss": CLIENT_ID,
        "sub": CLIENT_ID,
        "aud": TOKEN_ENDPOINT,
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": now + 120,
    },
    private_key,
    algorithm="RS256",
    headers={"kid": KEY_ID},
)

response = requests.post(
    TOKEN_ENDPOINT,
    data={
        "grant_type": "client_credentials",
        "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
        "client_assertion": assertion,
        "scope": "calls:read",
    },
    timeout=10,
)
response.raise_for_status()
token = response.json()
access_token = token["access_token"]
expires_in = token["expires_in"]
```

## Call the API

Send the access token in the `Authorization` header as a bearer token.

```bash theme={null}
curl 'https://eu.api.bland.ai/v1/calls?limit=1' \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

> [!WARNING]
>
> Don't send the `x-bland-org-id` header. The token already names your
> organization, and a request with that header is refused with a 400.

### Token lifetime

A token lasts 1 hour (`expires_in` is in seconds), and there are no refresh tokens. Cache the token and request a new one a few minutes before it expires. Don't request a token for every API call: the token endpoint allows 60 requests a minute from one IP address.

If an API call returns `401`, request a new token and retry once. If the token request fails with `invalid_client`, the client was disabled or deleted, or its secret was rotated.

## Permissions

A client's permissions are scopes in the form `resource:action`, the same scopes a [restricted API key](/api-v1/post/keys-create.md#restricted-keys) uses. In the dashboard, a resource and an action together make a scope. For example, **Calls** and **Read** is `calls:read`, and **Evals** and **Manage** is `evals:run`. Use the scope names in the `scope` parameter.

A scope can include others. If you pick `agents:write`, the client also holds `agents:read`, and you can request either. A token reaches only the routes its scopes allow. A route outside them returns `403` with the error `INSUFFICIENT_SCOPE`. When some scope opens the route, the response's `required_scope` names the scopes the token is missing, separated by spaces.

| Scope | What it allows | Also includes |
| - | - | - |
| `agents:read` | Read agent and pathway definitions, versions, folders, and branches | None |
| `agents:write` | Create, edit, publish, and roll back agents and pathways. Can use your organization's stored secrets and connected credentials in webhooks, tools, and dynamic data | `agents:read` |
| `calls:read` | Read call details, transcripts, recordings, analysis, and batch logs | None |
| `calls:create` | Place phone calls (billed to this org). Can use your organization's stored secrets and connected credentials in webhooks, tools, and dynamic data | `calls:stop` |
| `knowledge:read` | Read knowledge base sources, versions, gaps, and caller questions | None |
| `knowledge:write` | Add, edit, sync, and delete knowledge bases, and answer gaps | `knowledge:read` |
| `dispositions:read` | Read dispositions, extractors, versions, and test results | None |
| `dispositions:write` | Create, edit, publish, and delete dispositions and extractors. Published dispositions run on every call, billed to this org, and their custom code can use your organization's stored secrets and connected credentials | `dispositions:read` |
| `evals:read` | Read evaluation call sets, setups, templates, and status. Reading judges, runs, and results needs a full-access API key | None |
| `evals:run` | Create, edit, and delete evaluation call sets, setups, and templates. Running evaluations and editing judges need a full-access API key | `evals:read` |
| `voices:read` | Read voices, clone transcripts and source audio, generation history, and usage | None |
| `voices:generate` | Generate speech from text (billed to this org) | `voices:read` |
| `sms:read` | List SMS conversations, and read each number's messaging channel settings | None |
| `sms:send` | Send SMS messages, start conversations, and hand them to or back from a human (billed to this org). Sending as a persona can change the number's persona binding | `sms:read` |
| `analytics:read` | Read call summaries, caller geography, pathway flow, citations, saved boards, and conversation selections. Some analytics routes need a full-access API key | None |
| `tests:read` | Read agent test scenarios, runs, and results | None |
| `tests:run` | Create, edit, and run agent tests (simulated conversations that run the agent's webhook and tool nodes for real, billed to this org) | `tests:read` |

For the endpoints each scope opens, and what stays closed to every scope, see [Restricted keys](/api-v1/post/keys-create.md#restricted-keys).

> [!NOTE]
>
> Some permissions let a token use credentials stored in your organization. A token with `agents:write`, `calls:create`, or `dispositions:write` can use stored secrets and connected credentials, the same as a restricted API key with those permissions. Pick these permissions only for a client you trust with those credentials.

## Rotate, disable, and delete

A client belongs to your organization, not to the person who created it. It keeps working if that person leaves. An owner or admin disables or deletes it.

Open the menu on the client's row in Settings > API keys > OAuth clients.

* **Rotate secret.** Available for Client secret clients. The old secret stops working at once. Tokens you already received keep working until they expire, up to 1 hour. Private key JWT clients have no secret to rotate. To replace a key, create a new client with the new key set and delete the old one.
* **Disable client.** New token requests fail with `invalid_client` right away, and tokens the client already holds stop working on their next request. Select **Enable client** to turn it back on.
* **Delete client.** The same effect as disabling, and it can't be undone.

The **Last used** column shows when the client last received a token. It doesn't show API calls made with a token you already hold.

## Limits

* A token lasts 1 hour, and there are no refresh tokens.
* The token endpoint allows 60 requests a minute from one IP address, shared by every client behind it.
* A client assertion can't expire more than 5 minutes in the future.
* A client name has 1 to 64 characters.

## Errors

The token endpoint returns JSON with an `error` code and an `error_description`.

| Error | Meaning | What to do |
| - | - | - |
| `invalid_client` | The client ID is unknown, the client is disabled or deleted, the secret is wrong or was rotated, the secret was sent in the request body, or the assertion is expired, replayed, or has a bad signature. A wrong secret returns `401`. | Check the client in Settings > API keys. Send a secret with Basic authentication, or build a new assertion. |
| `unauthorized_client` | The client ID isn't one of your OAuth clients. It belongs to an application that can't use this grant. | Use the client ID shown in Settings > API keys > OAuth clients. |
| `invalid_scope` | A scope is outside the client's permissions, or isn't an API scope (for example `openid`). | Request only scopes the client holds, or leave `scope` out. |
| `invalid_target` | `resource` isn't exactly your region's API host, or you sent more than one `resource`. | Leave `resource` out, or send the host with no path and no trailing slash. |
| `unsupported_grant_type` | OAuth clients are not turned on for your region. | Contact Bland support. |

A token request over the rate limit returns `429` in the standard Bland error format, with the error `TOO_MANY_REQUESTS` in the `errors` array. Wait a minute, and cache your token.

The API returns these errors when you use a token:

| Status | Error | Meaning |
| - | - | - |
| 400 | `AUTH_FAILURE` | You sent the `x-bland-org-id` header. |
| 401 | `AUTH_FAILURE` | The token is expired or invalid, or the client was disabled or deleted. Request a new token. |
| 403 | `INSUFFICIENT_SCOPE` | The token's scopes don't allow this route. When some scope opens the route, `required_scope` names the scopes the token is missing. |

***

Docs for agents: [llms.txt](/llms.txt)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.