# Authentication and keys

How a key is issued, what a scope allows, and why a key never does more than the person who created it.

Send your key as a bearer token:

```
Authorization: Bearer bc_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

## Getting a key

Dashboard → **Settings** → **Developers** → **Create key**.

**The key is shown once, at creation, and never again.** We store a SHA-256
hash of it, not the key, so there is no "reveal" button and no support request
that can recover one. If you lose it, rotate it.

Give each key a name that says where it runs — up to 60 characters. When
something needs turning off at short notice, the name is what tells you which
one. You can give it an expiry of up to 365 days, which is what you want for a
key you are handing to somebody else.

How many active keys a workspace may hold depends on its organisation's plan —
see [Plans and rate limits](https://boltclaw.ai/docs/rate-limits); never more than 20. The
refusal names the number that applied. Revoked and expired ones do not count.
The limit is there so that "revoke everything and start again" stays a bounded
amount of work at the moment you least want an unbounded one.

Everything on that screen is also [an API call](https://boltclaw.ai/docs/api/api-keys), so rotating every
key on a schedule is a cron job rather than somebody remembering.

**List the keys this workspace holds** — answers `200`

Metadata only, including revoked and expired rows: the row is the record that a credential was issued here. Never a key itself, which is not stored.

```bash
curl https://api.boltclaw.ai/v1/keys \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/keys", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 200
const data = await res.json();
```

## Scopes

A key does only what it was created to do. You pick that at creation, and you
can change it afterwards without changing the key itself — see
[`PATCH /v1/keys/{id}`](https://boltclaw.ai/docs/api/api-keys#patch-v1keysid).

| Scope | What it allows |
|---|---|
| `pages:read` | Read pages, links and statistics |
| `pages:write` | Create pages; edit links, theme and settings |
| `pages:delete` | **Delete a page permanently** |
| `inquiries:read` | Read conversations and messages |
| `inquiries:write` | Reply, mark read, close, re-open and file conversations |
| `social:read` | Read connected social accounts, posts, the social inbox, automations and usage |
| `social:write` | Schedule, publish and cancel posts, upload media, reply to and hide comments, send DMs, manage automations |
| `leads:read` | **Read captured leads — other people's email addresses and phone numbers** |
| `conversions:write` | Report signups, sales and refunds for your referral programs |
| `referrals:read` | Read your referral programs, their partners, commissions, payouts and statistics |
| `referrals:write` | Create and edit referral programs, approve or reject partners, adjust commissions, mark payouts paid, record rewards that are not cash as given, and create the program's codes in your Stripe |
| `referrals:embed` | Show your own users a [referral panel](https://boltclaw.ai/docs/referral-panel): mint panel sessions and create, update and read the referrers behind them, and read the [friend's offer](https://boltclaw.ai/docs/referral-welcome) and who invited a customer. Nothing else. **Never carried by an AI assistant** |
| `referrals:payouts` | For [your own payout system](https://boltclaw.ai/docs/referral-payout-webhook): **send a payout batch**, read where a requested payout goes, and report what happened to it. Beside `referrals:write`, it also lets a key [forfeit money held for a missing tax form](https://boltclaw.ai/docs/referral-tax). **Never carried by an AI assistant** |
| `keys:read` | List API keys and what each may do. Never a key itself |
| `keys:write` | **Create, re-scope, rotate and revoke API keys** |
| `webhooks:read` | List webhook endpoints, the event catalog and a delivery log |
| `webhooks:write` | **Register, change, pause and delete webhook endpoints; rotate a signing secret; redeliver; send a test event** |
| `logs:read` | Read the workspace's activity log and the log of what AI assistants did |
| `workspace:read` | Reserved. `GET /v1/ping` and `GET /v1/workspace` need no scope at all |

## Keys an AI assistant holds

A key approved through the [device sign-in](https://boltclaw.ai/docs/api/agents) is an *agent*
key, and three things are true of it that are not true of a key you created in
the dashboard.

**It can never carry `keys:read`, `keys:write`, `webhooks:read` or
`webhooks:write`.** Not "those are left out of the form": the database refuses
them, on the way in and on every request afterwards, whatever anybody ticks.
Something acting on its own should not be able to issue credentials or point
your data at a new address, and that is a property of the key rather than a
policy somebody has to remember. `referrals:embed` is refused the same way: a
panel session is minted by your own server for a person who is signed in to your
product, never by an assistant. So is `referrals:payouts`: an assistant never
reads where a promoter's money goes or reports a payout's result, and it sends a
payout batch only with a person's approval.

**An organisation can turn every one of them off in one click,** under
Settings, AI agents, without hunting down and revoking each key, and turn them
back on without anybody signing in again. While it is off, those keys answer
`403 AGENT_ACCESS_DISABLED`; a key you created in the dashboard is untouched.

**Everything one does is on a log,** `GET /v1/logs/agent-calls`, whatever the
outcome.

### What an assistant must ask about

Some calls stop and ask a person first. The API answers `428` with a link,
**nothing happens**, and the assistant retries the identical request with the
approval id once somebody has decided. The mechanics are
on [the Agents page](https://boltclaw.ai/docs/api/agents#approvals).

Which calls, and whether you can decide to stop being asked:

| Action | Operation | Can an organisation waive it? |
|---|---|---|
| `page.delete` | `DELETE /v1/pages/{slug}` | Yes |
| `page.unpublish` | `PATCH /v1/pages/{slug}` with `published: false` | Yes |
| `link.delete` | `DELETE /v1/links/{id}` | Yes |
| `conversation.reply` | `POST /v1/inquiries/{id}/messages` | Yes |
| `post.publish` | `POST /v1/posts`, unless `draft: true` | Yes |
| `post.delete` | `DELETE /v1/posts/{id}` | Yes |
| `inbox.reply` | `POST /v1/inbox/{id}/reply` | Yes |
| `inbox.hide` | `POST /v1/inbox/{id}/hide` | Yes |
| `automation.enable` | `POST /v1/automations`, unless `enabled: false`; `PATCH /v1/automations/{id}` with `enabled: true` | Yes |
| `automation.delete` | `DELETE /v1/automations/{id}` | Yes |
| `lead.delete` | `DELETE /v1/leads/{id}` | Yes |
| `referral.payout.paid` | `POST /v1/referrals/payouts/{id}/paid` | **No** |
| `referral.commission.adjust` | `POST /v1/referrals/commissions/{id}/adjust` | **No** |
| `referral.partner.approve` | `POST /v1/referrals/partners/{id}/approve` | **No** |
| `referral.commission.bonus` | `POST /v1/referrals/partners/{id}/bonuses` | **No** |
| `referral.reward.delete` | `DELETE /v1/referrals/rewards/{id}` | **No** |
| `referral.group.delete` | `DELETE /v1/referrals/groups/{id}` | **No** |
| `referral.referrer.erase` | `DELETE /v1/referrals/referrers/{id}` | **No** |
| `referral.grant.fulfil` | `POST /v1/referrals/grants/{id}/fulfil`; `POST /v1/referrals/grants/{id}/send` | **No** |
| `referral.grant.fail` | `POST /v1/referrals/grants/{id}/fail` | **No** |
| `referral.stripe.codes` | `POST /v1/referrals/programs/{id}/stripe-codes` | **No** |
| `referral.testdata.clear` | `DELETE /v1/referrals/programs/{id}/test-data` | **No** |
| `referral.flag.dismiss` | `POST /v1/referrals/flags/{id}/dismiss`; `POST /v1/referrals/programs/{id}/flags/dismiss` | **No** |
| `referral.flag.confirm` | `POST /v1/referrals/flags/{id}/confirm`; `POST /v1/referrals/programs/{id}/flags/confirm` | **No** |
| `referral.risk.update` | `PATCH /v1/referrals/programs/{id}/risk-policy` | **No** |
| `referral.payout.send` | `POST /v1/referrals/payout-batches/{id}/send` | **No** |
| `referral.payout.settings` | `PATCH /v1/referrals/programs/{id}/payout-settings` | **No** |
| `referral.commission.forfeit` | `POST /v1/referrals/partners/{id}/forfeit` | **No** |
| `referral.tax.settings` | `PATCH /v1/referrals/programs/{id}/tax` | **No** |

The first group is your own content, and an organisation that trusts its
assistant with it may turn those prompts off, or trust one connection for up to
eight hours. The referral rows move somebody else's money or change somebody
else's access, or what they will be paid, and are asked every time: turning off a convenience switch is
not a decision that an assistant may pay people without asking.

## A key never does more than the person who created it

This is the one rule worth reading twice.

A key's authority is **the scopes stamped on it, intersected with the
permissions the person who created it still holds in that workspace**, worked
out on every request. Nothing is cached and nothing is a snapshot. So:

- change that person's role, and every key they made narrows on its next
  request;
- remove them from the organisation, and their keys carry nothing and answer
  `403 INSUFFICIENT_SCOPE`;
- the key keeps working for everything they still may do.

`GET /v1/ping` reports the difference as `lost_scopes`, and
[`GET /v1/keys`](https://boltclaw.ai/docs/api/api-keys#get-v1keys) reports it per key. Normally both are empty. When
one is not, the fix is at your end: restore that person's permission, or have
somebody who holds it create the key again.

The same rule bounds what you can grant. A key you ask for carries at most what
**you** carry, and a key created by another key carries at most what **that
key** carries — so a chain of keys collapses to one person's live permissions
however long it is. Anything asked for and not granted comes back in `dropped`,
and the key is still created.

Eight presets are offered in the dashboard. **Inbox** (`inquiries:read` +
`inquiries:write`) is the default and is exactly what a self-hosted inbox
needs. **Pages** manages pages and links with no access to conversations.
**Social** (`social:read` + `social:write`) schedules posts, works the social
inbox and runs automations. **Full** is all three. A fifth, **Referral
tracking**, is `conversions:write` alone: the key your checkout server holds to
[report conversions](https://boltclaw.ai/docs/api/referrals), which can read nothing. A sixth,
**Referrals** (`referrals:read` + `referrals:write`), runs your referral
programs from your own systems; it does not report conversions, so the two
jobs stay on two keys. A seventh, **Referral panel** (`referrals:embed` alone),
is the key the server that shows your users their referrals holds: it mints
panel sessions and creates referrers, and a leaked one cannot touch a program,
a partner or a payout. See [Referral panel](https://boltclaw.ai/docs/referral-panel). An eighth,
**Referral payouts** (`referrals:read` + `referrals:payouts`), is the key your own
payout system holds: it sends payout batches, reads where each requested payout
goes and reports what happened. See [Paying partners](https://boltclaw.ai/docs/referral-payouts).

**`pages:delete` is in none of them.** It is the only irreversible operation in
this API — deleting a page also ends your access to every conversation on it —
and a key is a credential you are told to paste into a CI runner and a Zapier
step. It is a separate tick box, on purpose.

**Neither is `leads:read`.** A lead is somebody else's email address or phone
number, handed to your automation by a person who expected you — not every
tool you connect — to have it. A key pasted into a scheduling tool has no
business reading them, so this scope is only ever added on purpose.

A call outside a key's scopes answers **`403` with `code:
"INSUFFICIENT_SCOPE"`**, and the message names the scope that was missing.
Nothing is read or written first, so the refusal tells you nothing about
whether the page or thread exists — it is a statement about your key only.

Do not retry a `403`; no amount of waiting adds a scope. It is the one error
here that means "the request was fine, this credential was not."

**What a key outside its scopes gets** — answers `403`

A key without `leads:read` is refused before anything is read, so the answer says nothing about whether you have any leads.

```bash
curl https://api.boltclaw.ai/v1/leads \
  -H "Authorization: Bearer bc_live_NARROWxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/leads", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

// 403: branch on the "code", not the sentence
const data = await res.json();
```

Keys created before scopes existed hold the five scopes that existed then, so
nothing they could do before stopped working — `pages:delete` included. They
hold no referral scope and no `keys:` scope.

**One scope moved.** `GET /v1/webhooks` is now `webhooks:read`. It still
accepts `inquiries:read`, so a key issued before the move keeps working; that
acceptance will be withdrawn, so issue new keys with `webhooks:read`.

**`keys:*` and `webhooks:*` cannot be held by an AI assistant's key.** Whatever
is asked for, a key approved for an assistant carries neither. Issuing
credentials and pointing your data at a new address are things a person does,
or a system a person deployed; they are not things a credential acting on its
own should be able to do. Your own scripts and servers may hold both.

## Storing a key

A key can do everything to your pages that its creator can. Treat it like a
password:

- Put it in an environment variable or a secret manager. Never in a repository,
  a client-side bundle, a mobile app, or a browser — anything shipped to a user
  is readable by that user.
- Use a separate key per system, so one can be revoked without stopping the
  others.
- Give it an expiry if you can. A key that stops on its own is one you cannot
  forget about.

## Rotating a key

Dashboard → **Settings** → **Developers** → the rotate button, or
[`POST /v1/keys/{id}/rotate`](https://boltclaw.ai/docs/api/api-keys#post-v1keysidrotate).

Rotation issues a replacement with the same scopes and the same expiry, shows
it once, and retires the old one. `grace_seconds` keeps the old value working
for up to 24 hours, so the new one can reach every deployment before anything
breaks; zero stops it at once, which is what you want when the old value
leaked.

**The replacement has a new id.** If you store the id as well as the value,
store both again. `rotated_from` on the new key names the old one.

## Revoking a key

Dashboard → **Settings** → **Developers** → the revoke button, or
[`DELETE /v1/keys/{id}`](https://boltclaw.ai/docs/api/api-keys#delete-v1keysid).

Revocation is immediate. The next request with that key gets `401`. The key's
row stays in your list, marked revoked, so a key that turns up in a log later
is still identifiable. Revoking a key that is already revoked or expired is
`404`.

## Checking a key

`GET /v1/ping` answers with the scopes the key carries, and `GET /v1/workspace`
with the workspace it belongs to. Neither needs a scope, so any working key can
call them: use them while you set an integration up, and as a liveness probe.

```json
{
  "pong": true,
  "key_kind": "secret",
  "scopes": ["pages:read", "pages:write"],
  "lost_scopes": []
}
```

```json
{ "id": "6f1c2a9e-3b7d-4c51-9e08-2d4a7b6c1f30", "name": "Main" }
```

Every key belongs to one workspace, so no other call takes a workspace
parameter, and this is the only response an ordinary key gets that names one.
The two others are not answered to a key at all: the approval of a
[device sign-in](https://boltclaw.ai/docs/api/agents#signing-in-without-pasting-a-key), which
goes only to the program that started it, and a route reserved for the hosted
MCP server.

<!-- https://boltclaw.ai/docs/authentication as markdown. Generated from openapi.yaml; API version 1.22.0. -->
<!-- Every /docs page answers as markdown: add .md to its path, or send Accept: text/markdown. -->
