Authentication
Authentication
The Agent API accepts two credentials: the API key you generate in the app (bc_sk_…), and a
short-lived session token (bcs_…) that your server mints from that key. Both are sent as a
bearer token.
Sending an API key
Send the key in the Authorization header on every request:
curl https://api.betterclaw.io/workspaces \
-H "Authorization: Bearer bc_sk_…"
const res = await fetch('https://api.betterclaw.io/workspaces', {
headers: { Authorization: `Bearer ${process.env.BETTERCLAW_API_KEY}` },
});
The API enforces two rules for raw keys, because a leaked key works until someone revokes it:
- Header only. A key in a query string (
?token=bc_sk_…) is rejected with401. URLs end up in server logs, proxy logs, and browser history. - Server only. A key on a request that carries an
Originheader, which browsers add to cross-origin requests, is rejected with403. If a server-side HTTP client you use addsOriginon its own, remove the header.
Session tokens
A browser, mobile app, or anything else you don't control should never hold an API key. Instead, your server exchanges the key for a session token and passes that to the client. A session token:
- expires after 10 minutes by default (at most 15),
- can be narrowed to fewer permissions than the key has,
- may be sent from a browser, and in a query string (
?token=bcs_…), which is how browsers authenticate a WebSocket.
Exchange a key for a token
curl -s https://api.betterclaw.io/auth/sdk-token \
-H "Authorization: Bearer $BETTERCLAW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"ttlSeconds": 600, "scopes": ["chats:read", "chats:write"]}'
| Field | Type | Required | Description |
|---|---|---|---|
ttlSeconds | integer | No | Lifetime in seconds, from 60 to 900. Defaults to 600. Values outside the range are rejected, not clamped. |
scopes | string | No | Permissions for the token. Defaults to all of the key's permissions. Can only narrow them. |
Response (201):
{
"token": "bcs_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"expiresIn": 600,
"expiresAt": "2026-10-08T10:25:00.000Z",
"workspaceId": "6f1c2d4e-8a90-4b1c-9d2e-3f4a5b6c7d8e",
"userId": "2d3e4f5a-6b7c-4d8e-9f0a-1b2c3d4e5f6a",
"agentId": null,
"scopes": ["chats:read", "chats:write"]
}
agentId is the agent the key is pinned to, or null for Any agent. Asking for a permission
the key doesn't have returns 403.
Hand tokens to a browser
Add an endpoint to your own backend that checks your user is signed in, mints a token, and
returns it. The browser calls the Agent API with that token and asks your endpoint for a new one
before expiresAt.
// Your server (Express). The API key never leaves it.
app.post('/betterclaw-token', requireSignedInUser, async (req, res) => {
const r = await fetch('https://api.betterclaw.io/auth/sdk-token', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.BETTERCLAW_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ scopes: ['chats:read', 'chats:write'] }),
});
if (!r.ok) return res.status(502).json({ error: 'Could not get a BetterClaw token' });
const { token, expiresAt, workspaceId } = await r.json();
res.json({ token, expiresAt, workspaceId });
});
// The browser.
const { token, workspaceId } = await (await fetch('/betterclaw-token', { method: 'POST' })).json();
const chats = await fetch(`https://api.betterclaw.io/chats?workspaceId=${workspaceId}`, {
headers: { Authorization: `Bearer ${token}` },
}).then((r) => r.json());
mintSessionToken
from @better-claw/sdk/server on your backend, and SessionTokenAuth in the browser, which
fetches a new token from your endpoint when the current one is about to expire.Failed exchanges are rate-limited
After 10 failed exchanges from the same IP address within a minute, POST /auth/sdk-token
answers 429 until the window passes. Successful exchanges are never limited. The body says how
long to wait:
{ "message": "Too many failed attempts — try again shortly", "retryAfter": 42 }
Who a key acts as
A key acts as the workspace admin who generated it. Everything it does is attributed to that
person: chats it starts are theirs, and they're visible to the rest of the workspace like any other
chat. The key keeps working if that person is later changed to a member, but stops working the
moment they leave the workspace. The API keys tab then marks it Not working, and requests
fail with 401. Revoke it and generate a replacement from an admin who is staying.
Pinning a key to an agent
When you generate a key you can pin it to one agent. A pinned key:
- sees only that agent in
GET /workspaces/{workspaceId}/agents, - gets
403if a request names a different agent (for exampleagentIdwhen creating a chat), - gets
404when opening, messaging, or downloading from a chat that belongs to another agent, - always starts chats with the pinned agent when you leave out
agentId.
GET /chats) or the live
stream: both cover the whole workspace. Don't rely on a pin to keep chat
titles or messages from other agents away from whoever holds the key.Revoking a key
Revoke a key from the API keys tab of the Agents page. It stops working on its next request — there is no cache to wait out. Session tokens minted from it stop working at the same moment, and any open stream that used it is closed within about half a minute.
Each key shows when it was last used (updated at most once a minute), which helps you spot keys that are safe to revoke.