> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://developer.jelliu.co/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://developer.jelliu.co/_mcp/server.

# API keys

A workspace API key is the credential your servers, scripts and integrations use to call the Jelliu REST API and the [MCP server](/mcp). This page is the operational deep dive: how a key is born, what each scope can and cannot reach route by route, how revocation propagates, how to rotate with zero downtime, and how to audit who did what with which key.

For the header format and the basic error responses, start with [Authentication](/authentication).

## How it works

```mermaid
sequenceDiagram
    autonumber
    participant Owner as Workspace owner (dashboard)
    participant API as api.jelliu.co
    participant App as Your integration
    Owner->>API: Create key (name, scopes, optional expiry)
    API-->>Owner: Plaintext jl_... shown once
    Owner->>App: Store key in a secret manager
    App->>API: Authorization: Bearer jl_...
    API->>API: SHA-256 lookup, expiry and workspace checks (cached a few seconds)
    API->>API: Scope check by HTTP method, then role gate per route
    API-->>App: Response, audited under apikey:KEY_ID
    Owner->>API: Revoke key
    API-->>App: 401 within about 10 seconds on every instance
```

Every request passes three independent gates, in this order:

1. **Authentication.** The bearer value must be `jl_` followed by exactly 64 lowercase hex characters. Jelliu hashes it with SHA-256 and looks it up. Unknown, revoked or expired keys, and keys whose workspace was deleted, are refused with `401`.
2. **Scope by HTTP method.** Applied to every `/api` request. `GET`, `HEAD` and `OPTIONS` need `read`, `write` or `full`. `POST`, `PUT`, `PATCH` and `DELETE` need `write` or `full`.
3. **Role gate per route.** Routes restricted to workspace roles check the key again. On routes that no ordinary member can use (admin, owner or billing only), only a `full` key passes.

Some credential-management routes add a fourth gate that refuses **every** API key, including `full`. See [Dashboard-only operations](#dashboard-only-operations).

## Three kinds of credential

Jelliu issues three unrelated credentials. They are not interchangeable, and each one is accepted only where listed.

| Credential        | Format                        | Sent as                        | Accepted by                                               | Created by                                                                  |
| ----------------- | ----------------------------- | ------------------------------ | --------------------------------------------------------- | --------------------------------------------------------------------------- |
| Workspace API key | `jl_` + 64 hex                | `Authorization: Bearer jl_...` | `https://api.jelliu.co/api/*` and `https://mcp.jelliu.co` | Workspace owner, in **Settings → API Keys**                                 |
| Zapier key        | `zk_` + 64 hex                | `x-zapier-api-key: zk_...`     | Only the Zapier endpoints under `/zapier`                 | Workspace owner, from the dashboard. See [Zapier](/platform/zapier)         |
| Widget API key    | Opaque secret, one per widget | `x-widget-api-key`             | Only the public `/widget/*` endpoints of that widget      | Returned once when the widget is created. See [Web chat](/channels/webchat) |

None of these belongs in a browser, a mobile app bundle or a public repository. The only identifier that is safe to publish is a widget's **ID** (a UUID), which the embed snippet uses.

## The key object

`GET /api/api-keys` returns the workspace's non-revoked keys, newest first. The secret is never included; only its public prefix.

```json
{
  "data": [
    {
      "id": "3f6c2a8e-1b7d-4e5f-9a0c-2d4e6f8a1b3c",
      "tenant_id": "9b2e4d6f-8a1c-4e3b-a5d7-c9e1f3a5b7d9",
      "agent_id": null,
      "name": "CRM sync (production)",
      "prefix": "jl_0f9e8d7c",
      "scopes": ["read", "write"],
      "created_by": "user_2abcDEFghiJKLmnoPQR",
      "last_used_at": "2026-09-14T15:41:02.118Z",
      "expires_at": null,
      "revoked_at": null,
      "created_at": "2026-09-01T12:00:00.000Z",
      "usable": true
    }
  ]
}
```

| Field          | Type              | Description                                                                                                                        |
| -------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `id`           | uuid              | Key identifier. Used to revoke, and appears in the audit log as `apikey:` followed by this id.                                     |
| `name`         | string            | Label chosen at creation, 1 to 200 characters.                                                                                     |
| `prefix`       | string            | `jl_` plus the first 8 hex characters of the secret. Enough to recognize a key in your secret store; not enough to use it.         |
| `scopes`       | string\[]         | Any of `read`, `write`, `full`.                                                                                                    |
| `agent_id`     | uuid or null      | Always `null` for keys you create. See [Keys that belong to an agent](#keys-that-belong-to-an-agent).                              |
| `created_by`   | string or null    | The dashboard user who created the key.                                                                                            |
| `last_used_at` | timestamp or null | Last successful authentication. Updated at most **once per minute** per key, so treat it as approximate.                           |
| `expires_at`   | timestamp or null | `null` means the key never expires.                                                                                                |
| `revoked_at`   | timestamp or null | Always `null` in this list: revoked keys are not returned.                                                                         |
| `usable`       | boolean           | Whether the key can authenticate right now. An expired key is still listed, with `usable: false`, so you can find and clean it up. |

Listing keys is an owner-level route. A `full` key can call it; `read` and `write` keys receive `403` with `This operation requires an API key with the 'full' scope`.

## Lifecycle

#### Create

The workspace **owner** creates the key in the dashboard under **Settings → API Keys**. Admins cannot, and no API key can create another key.

| Setting | Rules                                                                                                                                                  |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Name    | Required, 1 to 200 characters after trimming. Cannot start with `internal:`, which is reserved for keys Jelliu issues itself.                          |
| Scopes  | At least one of `read`, `write`, `full`. If none is chosen the default is `read` + `write`. `full` is never a default: it must be selected explicitly. |
| Expiry  | Optional. Without one, the key **never expires**. A date you choose is honored exactly, with no maximum, and must be in the future.                    |

The plaintext key is displayed **once**. Jelliu stores only its SHA-256 hash and cannot show it again. If you lose it, create a new key and revoke the old one.

#### Use

Send it on every request as `Authorization: Bearer jl_...`. Every key of the workspace shares the same [rate limits](/rate-limits), because limits are counted per workspace.

#### Expire (optional)

A key with an `expires_at` stops authenticating at that moment. Expiry is re-checked even when the validation result is cached, so a key does not keep working past its date. It stays in the list with `usable: false` until you revoke it.

#### Revoke

The owner revokes the key from the dashboard. Revocation is a soft delete (the record is kept for audit history) and frees a slot under the 25-key limit. The instance that handles the revocation evicts the key from its caches immediately; other instances stop accepting it within about **10 seconds**. Revoking a key that is already revoked or does not exist returns `404` with `API key not found or already revoked`.

Keys also stop working when the workspace is deleted.

### Limits on keys

| Limit                     | Value                                           | Error                                                                                                         |
| ------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| Active keys per workspace | 25                                              | `409 PLAN_LIMIT_EXCEEDED`: `Maximum 25 active API keys per tenant. Revoke old keys before creating new ones.` |
| Scopes per key            | 1 to 20 entries, each `read`, `write` or `full` | `400 VALIDATION_FAILED`                                                                                       |
| Name length               | 1 to 200 characters                             | `400 VALIDATION_FAILED`                                                                                       |
| Expiry in the past        | Refused                                         | `400 VALIDATION_FAILED`: `expires_at must be in the future — a key that is born expired cannot authenticate.` |

Expired keys that have not been revoked still count toward the 25. Revoke them to free the slot.

## What each scope can reach

The matrix below is derived from the gates applied to each route. "Member-level" routes are those a workspace member can use in the dashboard; "admin-exclusive" routes are those only an admin, owner or billing user can use.

| Operation                                                                                                                                                                 | `read` | `write` | `full` |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | ------- | ------ |
| Read agents, campaigns, contacts, calls, conversations, phone numbers, widgets, webhooks, analytics and other member-level resources                                      | Yes    | Yes     | Yes    |
| Create and update agents, add contacts (single, bulk, CSV), place calls, send WhatsApp and email, upload knowledge documents, manage agent tools, widgets and MCP servers | No     | Yes     | Yes    |
| Create, update, delete, activate and pause **campaigns**                                                                                                                  | No     | No      | Yes    |
| Create, update and delete **outbound webhooks**                                                                                                                           | No     | No      | Yes    |
| Delete agents                                                                                                                                                             | No     | No      | Yes    |
| Provision phone numbers or connect a SIP trunk                                                                                                                            | No     | No      | Yes    |
| Create, submit, import and delete WhatsApp templates; manage WhatsApp senders                                                                                             | No     | No      | Yes    |
| Create, update, test and delete integrations; everything under `/api/composio`                                                                                            | No     | No      | Yes    |
| Live call transfer (`/api/calls/{callId}/transfer`)                                                                                                                       | No     | No      | Yes    |
| Billing reads: usage, limits, invoices, charges, payment methods                                                                                                          | No     | No      | Yes    |
| Billing purchases: checkout, plan change, minute packs, agent add-ons, phone numbers                                                                                      | No     | No      | Yes    |
| Audit log (`/api/audit`) and the agent action ledger (`/api/agent-actions`)                                                                                               | No     | No      | Yes    |
| Compliance changes: record or withdraw consent, import, list or delete suppressions, set retention, erase a contact, change country rules                                 | No     | No      | Yes    |
| Email sending settings (`PATCH /api/email/settings`, `PUT /api/email/settings/sending`)                                                                                   | No     | No      | Yes    |
| List API keys                                                                                                                                                             | No     | No      | Yes    |
| Create or revoke API keys, Zapier keys; rotate a webhook secret                                                                                                           | No     | No      | No     |

A `full` key is treated as a workspace **administrator** and also passes owner-only routes: it can spend money on the saved card, read the audit log and erase contact data. Give it only to automations that genuinely manage the workspace, and store it like an admin password.

### How a refusal looks

A key that lacks the method's scope:

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "API key lacks the 'write' scope required for this operation"
  }
}
```

A `read` or `write` key on an admin-exclusive route:

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "This operation requires an API key with the 'full' scope"
  }
}
```

### Dashboard-only operations

These routes refuse every API key, whatever its scope, so a leaked key can never mint new credentials or switch off the controls meant to contain a leak:

| Operation                                                                     | Route                                                                                                                                                                      |
| ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Create or revoke an API key                                                   | `POST /api/api-keys`, `DELETE /api/api-keys/{id}`                                                                                                                          |
| Create or revoke a Zapier key                                                 | `POST /api/zapier/api-key`, `DELETE /api/zapier/api-key/{id}`                                                                                                              |
| Rotate a webhook signing secret                                               | `POST /api/webhooks/{webhookId}/rotate-secret`                                                                                                                             |
| Change the workspace sign-in policy, role mappings or SSO; transfer ownership | `PUT /api/identity/policy`, `PUT /api/identity/role-mappings`, `DELETE /api/identity/role-mappings/{id}`, `PUT /api/identity/sso`, `POST /api/identity/ownership/transfer` |
| Change agent change-approval or agent action-approval policy                  | `PUT /api/settings/agent-change-approval`, `PUT /api/agent-approvals/policy`                                                                                               |
| Pin the data residency region                                                 | `PUT /api/settings/data-residency`                                                                                                                                         |

The response is `403 FORBIDDEN`:

```json
{
  "error": {
    "code": "FORBIDDEN",
    "message": "This action requires a signed-in admin session — API keys cannot perform credential-management operations."
  }
}
```

### Workspace sign-in policies do not apply to keys

If the workspace requires MFA or SSO for its members, that requirement applies to people signed in to the dashboard. API keys are machine credentials and pass through it, so hardening your members' sign-in never breaks integrations.

## Keys that belong to an agent

Jelliu also issues credentials for its own use: the tool gateway your voice and chat agents call during a conversation, and the short-lived access tokens of [MCP OAuth](/mcp#oauth) connections. These credentials:

* are named with the reserved `internal:` prefix, are **hidden** from `GET /api/api-keys` and do **not** count toward the 25-key limit;
* when bound to a specific agent, can **never** carry the `full` scope, and stop authenticating as soon as that agent is deleted;
* for MCP OAuth, expire after one hour and never exceed the role of the person who approved the connection.

You cannot create, list or revoke them through the API. That is why `agent_id` is always `null` on the keys you manage.

## Rotating a key without downtime

A workspace can hold up to 25 keys at once, and every key works independently, so rotation is a matter of overlapping two keys.

#### Create the replacement

In **Settings → API Keys**, create a new key with the same scopes. Give it a name that identifies the rotation, such as `CRM sync (2026-09)`.

#### Deploy it everywhere

Update the secret in your secret manager and roll out every process that uses it. Both keys authenticate during this window.

#### Confirm the old key is idle

List the keys with a `full` key and check that the old key's `last_used_at` has stopped moving. Because the timestamp is updated at most once a minute, wait a few minutes after the last deploy before trusting it.

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/api-keys" \
  -H "Authorization: Bearer $JELLIU_ADMIN_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const res = await fetch('https://api.jelliu.co/api/api-keys', {
  headers: { Authorization: `Bearer ${process.env.JELLIU_ADMIN_API_KEY}` },
});
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
const { data } = await res.json();

const oldPrefix = 'jl_0f9e8d7c';
const oldKey = data.find((k) => k.prefix === oldPrefix);
const idleMinutes = oldKey?.last_used_at
  ? (Date.now() - Date.parse(oldKey.last_used_at)) / 60000
  : Infinity;
console.log(`${oldPrefix} idle for ${idleMinutes.toFixed(1)} minutes`);
```

**`Python`**

```python title="Python"
import os
from datetime import datetime, timezone

import requests

res = requests.get(
    "https://api.jelliu.co/api/api-keys",
    headers={"Authorization": f"Bearer {os.environ['JELLIU_ADMIN_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
keys = res.json()["data"]

old_prefix = "jl_0f9e8d7c"
old_key = next((k for k in keys if k["prefix"] == old_prefix), None)
if old_key and old_key["last_used_at"]:
    last = datetime.fromisoformat(old_key["last_used_at"].replace("Z", "+00:00"))
    idle = (datetime.now(timezone.utc) - last).total_seconds() / 60
    print(f"{old_prefix} idle for {idle:.1f} minutes")
else:
    print(f"{old_prefix} has never been used")
```

Expected output:

```text
jl_0f9e8d7c idle for 14.2 minutes
```

#### Revoke the old key

Revoke it from the dashboard. Allow about 10 seconds for every instance to refuse it. Any process you missed will start receiving `401 UNAUTHORIZED` with `Invalid or revoked API key`.

Jelliu never forces rotation: a key without an expiry lives until you revoke it. If your security policy requires periodic rotation, set `expires_at` when you create the key and put the date in your calendar, then rotate before it arrives. An expired key fails immediately with `401`, with no grace period.

### If a key leaks

1. **Revoke it first**, from **Settings → API Keys**. Do not wait until the replacement is deployed if the key was exposed publicly.
2. Create a replacement and deploy it.
3. Review what the key did. Every audited request records the key as `apikey:KEY_ID` (see below).
4. If the key had `full` scope, also review outbound webhooks, WhatsApp templates, integrations and billing activity, which only `full` keys can change.

## Auditing keys

### Creation and revocation events

Creating and revoking a key each write an audit record with `resource_type` `api_key` and action `api_key.create` or `api_key.revoke`. The record carries the key's **public** identity only: its id, prefix, scopes and expiry, who issued or revoked it, and, on creation, `admin_equivalent: true` when the key has the `full` scope. The secret and its hash are never written to the audit log.

**`cURL`**

```bash title="cURL"
curl -sS "https://api.jelliu.co/api/audit?resourceType=api_key&limit=20" \
  -H "Authorization: Bearer $JELLIU_ADMIN_API_KEY"
```

**`Node.js`**

```javascript title="Node.js"
const url = new URL('https://api.jelliu.co/api/audit');
url.searchParams.set('resourceType', 'api_key');
url.searchParams.set('limit', '20');

const res = await fetch(url, {
  headers: { Authorization: `Bearer ${process.env.JELLIU_ADMIN_API_KEY}` },
});
const { data } = await res.json();
for (const entry of data) {
  console.log(entry.created_at, entry.action, entry.resource_id, entry.user_id);
}
```

**`Python`**

```python title="Python"
import os

import requests

res = requests.get(
    "https://api.jelliu.co/api/audit",
    params={"resourceType": "api_key", "limit": 20},
    headers={"Authorization": f"Bearer {os.environ['JELLIU_ADMIN_API_KEY']}"},
    timeout=30,
)
res.raise_for_status()
for entry in res.json()["data"]:
    print(entry["created_at"], entry["action"], entry["resource_id"], entry["user_id"])
```

```json
{
  "data": [
    {
      "id": "c1d2e3f4-a5b6-4c7d-8e9f-0a1b2c3d4e5f",
      "tenant_id": "9b2e4d6f-8a1c-4e3b-a5d7-c9e1f3a5b7d9",
      "user_id": "user_2abcDEFghiJKLmnoPQR",
      "action": "api_key.create",
      "resource_type": "api_key",
      "resource_id": "3f6c2a8e-1b7d-4e5f-9a0c-2d4e6f8a1b3c",
      "ip_address": "203.0.113.24",
      "user_agent": { "browser": "Chrome", "os": "macOS", "device": "desktop" },
      "created_at": "2026-09-01T12:00:00.000Z"
    }
  ]
}
```

The list omits the `changes` payload. Fetch one entry with `GET /api/audit/{id}` to see the prefix, scopes and expiry that were recorded. `limit` accepts 1 to 100 (default 50) and `offset` pages through older entries.

### What a key did

Audited requests made with an API key are recorded with `user_id` set to `apikey:` followed by the key's `id`. Filter on it to see a single key's activity:

```bash
curl -sS "https://api.jelliu.co/api/audit?userId=apikey:3f6c2a8e-1b7d-4e5f-9a0c-2d4e6f8a1b3c" \
  -H "Authorization: Bearer $JELLIU_ADMIN_API_KEY"
```

### Alert on new keys in real time

Subscribe a webhook to `audit.log_recorded` with the `auditActions` filter set to `api_key.` to receive a signed event whenever a key is created or revoked. Remember that `audit.log_recorded` is not included in `"*"` and must be named explicitly. See [Webhooks](/webhooks#filters).

```bash
curl -sS -X POST "https://api.jelliu.co/api/webhooks" \
  -H "Authorization: Bearer $JELLIU_ADMIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://siem.example.com/hooks/jelliu",
    "events": ["audit.log_recorded"],
    "filters": { "auditActions": ["api_key."] },
    "description": "Credential changes"
  }'
```

## Errors

| Status | Code                  | Message                                                                                                      | Cause                                                                                |
| ------ | --------------------- | ------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ |
| `401`  | `UNAUTHORIZED`        | `Invalid or revoked API key`                                                                                 | Well-formed key that is unknown, revoked or expired, or whose workspace was deleted. |
| `401`  | `UNAUTHORIZED`        | `Authentication required — sign in with Clerk or provide a Bearer API key`                                   | Missing header, or a value that is not `jl_` plus 64 lowercase hex characters.       |
| `403`  | `FORBIDDEN`           | `API key lacks the 'write' scope required for this operation`                                                | Mutation with a `read`-only key.                                                     |
| `403`  | `FORBIDDEN`           | `This operation requires an API key with the 'full' scope`                                                   | Admin-exclusive route with a `read` or `write` key.                                  |
| `403`  | `FORBIDDEN`           | `This action requires a signed-in admin session — API keys cannot perform credential-management operations.` | A [dashboard-only operation](#dashboard-only-operations).                            |
| `403`  | `TENANT_SUSPENDED`    |                                                                                                              | The workspace is suspended.                                                          |
| `404`  | `NOT_FOUND`           | `API key not found or already revoked`                                                                       | Revoking a key that does not exist in the workspace or was already revoked.          |
| `409`  | `PLAN_LIMIT_EXCEEDED` | `Maximum 25 active API keys per tenant. Revoke old keys before creating new ones.`                           | Creating a 26th key.                                                                 |

## Security best practices

* **Least privilege.** `read` for reporting and BI, `write` for integrations that add contacts or send messages, `full` only for automation that manages campaigns, webhooks, billing or compliance.
* **One key per integration and environment.** Separate keys make `last_used_at` meaningful, let you revoke one integration without touching the others, and make the audit trail attributable.
* **Keep keys server-side.** Never ship a `jl_` key to a browser or mobile client. For a website chat, use the [widget](/widget) and its public widget ID.
* **Store keys in a secret manager**, inject them as environment variables, and keep them out of logs. The 8-character `prefix` is enough to identify a key in logs and tickets.
* **Watch for idle and expired keys.** Periodically list keys and revoke any whose `last_used_at` is old or whose `usable` is `false`.
* **Alert on credential changes** with an `audit.log_recorded` webhook filtered to `api_key.`.
* **For AI assistants, prefer OAuth.** A connector that signs in through [MCP OAuth](/mcp#oauth) receives one-hour tokens tied to the approving person, instead of a long-lived key pasted into a config file.

## Related

#### [Authentication](/authentication)

Header format and basic error responses.

#### [Rate limits](/rate-limits)

Per-workspace budgets shared by every key.

#### [Security](/security)

Transport, tenant isolation and webhook signing.

#### [AI assistant via MCP](/recipes/ai-assistant-via-mcp)

Choose the right scope for an assistant.