# Pages

Link-in-bio pages, served at boltclaw.ai/{slug}.

A page is what a visitor sees at `boltclaw.ai/<slug>`.

```json
{
  "id": "3f8c1e42-9b7a-4d61-a0f5-2c9e1b7d4a83",
  "slug": "acme",
  "title": "Acme Supply Co.",
  "bio": "Everything for the discerning coyote.",
  "avatar_url": "https://.../page-assets/3f8c1e42-.../avatar.png",
  "theme": { "background": "#111111", "accent": "#5B8DEF", "buttonStyle": "solid" },
  "socials": [{ "platform": "instagram", "url": "https://example.com/social" }],
  "contact_enabled": true,
  "contact_note": "I reply within two days. Booking enquiries only, please.",
  "official_channel": true,
  "published": true,
  "created_at": "2026-03-01T09:12:44.201118+00:00",
  "updated_at": "2026-03-04T17:02:10.885210+00:00"
}
```

`avatar_url` is read-only here — avatars are uploaded from the dashboard.

## Slugs

A slug is the public URL of the page, so it is fixed once created. There is no
endpoint that renames one: a rename breaks every link, QR code and printed card
pointing at the old name, and frees that name for someone else.

A slug must be lowercase letters, digits and single hyphens, 1–63 characters,
with no leading or trailing hyphen and no doubled hyphen. `acme`, `acme-supply`
and `acme2` are fine; `Acme`, `-acme`, `acme-` and `acme--supply` are not.

These names are reserved and cannot be used:

```
admin  ads  api  assets  auth  blog  chatgpt-integration  compare  dashboard
docs  faq  favicon  features  onboarding  portal  pricing  privacy  public  r
robots  settings  sitemap  static  support  terms  tools  use-cases  waitlist
why-boltclaw
```

## Themes

`theme` accepts only these keys. An unrecognised key is rejected with `400`
rather than silently dropped, so a typo is something you find at the request
and not on the rendered page:

`background`, `surface`, `text`, `muted`, `accent`, `accentText`, `border`,
`font`, `buttonStyle`, `radius`

Each value is a string of at most 64 characters. Colours may be hex
(`#5B8DEF`), a colour function (`rgb(...)`, `oklch(...)`), or a CSS colour
keyword. A value the renderer does not recognise falls back to the default
rather than breaking the page.

## Socials

`socials` is an array of at most 12 `{ "platform", "url" }` objects (the
default on every plan; `social_icons_per_page` in `GET /v1/usage`). `platform`
is a short lowercase slug (`instagram`, `x`, `github`). `url` must be `http`,
`https`, `mailto:` or `tel:`. Any other key on an entry is rejected.

## Operations

### `GET /v1/pages`

**List pages**

Scope `pages:read`.

The workspace's pages, newest first.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `limit` | query | `integer` | Page size, 1 to 200. Above 200 is clamped; anything else is the default. (default `50`; >= 1; <= 200) |
| `cursor` | query | `string` | The `next_cursor` from the previous page. Opaque; an invalid one is `400`. (max 512) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PageList` | A page of pages. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `POST /v1/pages`

**Create a page**

Scope `pages:write`. Accepts `Idempotency-Key`. **Can make something publicly visible.**

Creates a page at boltclaw.ai/{slug}. Slugs are global: `409 CONFLICT` means the name is
taken, by anyone. A reserved slug is `400`. The organisation's plan sets how many pages it
may hold and publish (`402 PLAN_LIMIT`). This operation draws on its own budget of 10
attempts an hour per key (`429`).

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `slug` | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `title` | `string \| null` | 120 characters by default; your organisation's number is `page_title_chars` in `GET /usage`. (max 120) |
| `bio` | `string \| null` | 600 characters by default; your organisation's number is `page_bio_chars` in `GET /usage`. (max 600) |
| `theme` | `Theme` | The page's colours and style. Each value is a string of 1 to 64 characters. |
| `socials` | `Social[]` | 12 icons by default; your organisation's number is `social_icons_per_page` in `GET /usage`. A page already holding more may save the same number or fewer. (<= 12 items) |
| `contact_enabled` | `boolean` |  |
| `contact_note` | `string \| null` | 300 characters by default; your organisation's number is `contact_note_chars` in `GET /usage`. (max 300) |
| `official_channel` | `boolean` |  |
| `published` | `boolean` |  |

```json
{
  "slug": "<slug>"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `Page` | Created. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 409 | `ErrorEnvelope` | `CONFLICT`: the request clashes with the object's current state (the message says how). `IDEMPOTENCY_KEY_CONFLICT` / `IDEMPOTENCY_KEY_IN_FLIGHT`: the `Idempotency-Key` was used with another request, or its first request is still running. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `GET /v1/pages/{slug}`

**Read a page**

Scope `pages:read`. Another workspace's answers `404`.

One of the workspace's pages, published or not.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Page` | The page. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `PATCH /v1/pages/{slug}`

**Update a page**

Scope `pages:write`. Another workspace's answers `404`. **Can make something publicly visible.**

Changes the fields you send. The slug cannot change: it is the page's public address, and
renaming it would break every link and QR code pointing at it. Publishing a page counts
against the plan's published-page allowance (`402 PLAN_LIMIT`).

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `title` | `string \| null` | 120 characters by default; your organisation's number is `page_title_chars` in `GET /usage`. (max 120) |
| `bio` | `string \| null` | 600 characters by default; your organisation's number is `page_bio_chars` in `GET /usage`. (max 600) |
| `theme` | `Theme` | The page's colours and style. Each value is a string of 1 to 64 characters. |
| `socials` | `Social[]` | 12 icons by default; your organisation's number is `social_icons_per_page` in `GET /usage`. A page already holding more may save the same number or fewer. (<= 12 items) |
| `contact_enabled` | `boolean` |  |
| `contact_note` | `string \| null` | 300 characters by default; your organisation's number is `contact_note_chars` in `GET /usage`. (max 300) |
| `official_channel` | `boolean` |  |
| `published` | `boolean` |  |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Page` | The updated page. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 402 | `ErrorEnvelope` | `PLAN_LIMIT`: the organisation's plan does not allow more of this. The message names the allowance. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 413 | `ErrorEnvelope` | `PAYLOAD_TOO_LARGE`: the body is over 64 KB. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

### `DELETE /v1/pages/{slug}`

**Delete a page**

Scope `pages:delete`. Another workspace's answers `404`.

Deletes the page, its links and its statistics. People who messaged it keep a read-only
copy of their own conversation; your access to those conversations ends. Needs its own
scope, `pages:delete`, which `pages:write` does not include.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PageDeleted` | Deleted. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 428 | `ErrorEnvelope` | `APPROVAL_REQUIRED` on the first try, `APPROVAL_PENDING` on a retry before a person has decided. NOTHING HAS HAPPENED either way. The body carries `approval` with a `url`: show it to the person, and once they approve, send the identical request again with the approval id in `BoltClaw-Approval`. Only an AI assistant's request is ever answered this way. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

**Delete a page** — answers `200`

The page, its links and its statistics, and your access to every conversation on it. There is no undo. The other person keeps a read-only archive of their own thread.

```bash
curl -X DELETE https://api.boltclaw.ai/v1/pages/acme \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

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

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

### `GET /v1/pages/{slug}/stats`

**Read a page's views and clicks**

Scope `pages:read`. Another workspace's answers `404`.

Views and link clicks between `from` and `to` (the last 30 days by default), counted by
BoltClaw itself. The answer starts no earlier than your plan's analytics history allows
(`analytics_days` in `GET /usage`: 30 days on Free), whatever `from` says; the window
actually used is echoed back, so a `from` later than the one you sent means the plan's
history ended there. Nothing older is deleted: a larger plan sees it at once.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `slug` | path | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `from` | query | `string` | An ISO-8601 date or timestamp. Default 30 days before `to`. |
| `to` | query | `string` | An ISO-8601 date or timestamp, later than `from`. Default now. |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `PageStats` | The counts. |
| 400 | `ErrorEnvelope` | `VALIDATION_ERROR`: a body that is not JSON, a field that breaks its rule (named in `fields`), a bad query value or cursor, or a malformed `Idempotency-Key`. |
| 401 | `ErrorEnvelope` | `UNAUTHORIZED`: no key, or one that is malformed, unknown or revoked. It never says which. |
| 403 | `ErrorEnvelope` | `INSUFFICIENT_SCOPE`: the key lacks this operation's scope. Or `ORGANISATION_SUSPENDED`: the key's organisation is suspended. Never about another workspace's object, which is 404. |
| 404 | `ErrorEnvelope` | `NOT_FOUND`: no such object, or it belongs to another workspace. The two are deliberately the same answer and never 403. If the id is right, check you are using the right key. |
| 429 | `ErrorEnvelope` | `RATE_LIMITED`: too many requests. Wait for `Retry-After` seconds. |
| 500 | `ErrorEnvelope` | `INTERNAL_ERROR`: ours. Safe to retry; send the same `Idempotency-Key` on a POST. |

## Shapes

The objects these operations send and answer.

### NextCursor

Pass back as `cursor` for the next page; null on the last page.

`string | null`.

### Page

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `slug` | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `title` | `string \| null` | (**required**) |
| `bio` | `string \| null` | (**required**) |
| `avatar_url` | `uri \| null` | (**required**) |
| `theme` | `Theme` | The page's colours and style. Each value is a string of 1 to 64 characters. (**required**) |
| `socials` | `Social[]` | (**required**) |
| `contact_enabled` | `boolean` | (**required**) |
| `contact_note` | `string \| null` | The creator's own words above the message box. (**required**) |
| `official_channel` | `boolean` | The creator's promise that this is the only channel they answer through. (**required**) |
| `published` | `boolean` | (**required**) |
| `created_at` | `date-time` | (**required**) |
| `updated_at` | `date-time` | (**required**) |

### PageCreate

| Field | Type |  |
|---|---|---|
| `slug` | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `title` | `string \| null` | 120 characters by default; your organisation's number is `page_title_chars` in `GET /usage`. (max 120) |
| `bio` | `string \| null` | 600 characters by default; your organisation's number is `page_bio_chars` in `GET /usage`. (max 600) |
| `theme` | `Theme` | The page's colours and style. Each value is a string of 1 to 64 characters. |
| `socials` | `Social[]` | 12 icons by default; your organisation's number is `social_icons_per_page` in `GET /usage`. A page already holding more may save the same number or fewer. (<= 12 items) |
| `contact_enabled` | `boolean` |  |
| `contact_note` | `string \| null` | 300 characters by default; your organisation's number is `contact_note_chars` in `GET /usage`. (max 300) |
| `official_channel` | `boolean` |  |
| `published` | `boolean` |  |

### PageDeleted

| Field | Type |  |
|---|---|---|
| `deleted` | `true` | (**required**) |
| `slug` | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `message` | `string` | (**required**) |

### PageList

| Field | Type |  |
|---|---|---|
| `data` | `Page[]` | (**required**) |
| `next_cursor` | `NextCursor` | Pass back as `cursor` for the next page; null on the last page. (**required**) |

### PageStats

| Field | Type |  |
|---|---|---|
| `slug` | `Slug` | Lowercase letters, digits and single hyphens. Reserved names are refused. (**required**; min 1; max 63) |
| `from` | `date-time` | (**required**) |
| `to` | `date-time` | (**required**) |
| `views` | `integer` | (**required**) |
| `clicks` | `integer` | (**required**) |
| `links` | `object[]` | (**required**) |

### PageUpdate

At least one field. The slug cannot change.

| Field | Type |  |
|---|---|---|
| `title` | `string \| null` | 120 characters by default; your organisation's number is `page_title_chars` in `GET /usage`. (max 120) |
| `bio` | `string \| null` | 600 characters by default; your organisation's number is `page_bio_chars` in `GET /usage`. (max 600) |
| `theme` | `Theme` | The page's colours and style. Each value is a string of 1 to 64 characters. |
| `socials` | `Social[]` | 12 icons by default; your organisation's number is `social_icons_per_page` in `GET /usage`. A page already holding more may save the same number or fewer. (<= 12 items) |
| `contact_enabled` | `boolean` |  |
| `contact_note` | `string \| null` | 300 characters by default; your organisation's number is `contact_note_chars` in `GET /usage`. (max 300) |
| `official_channel` | `boolean` |  |
| `published` | `boolean` |  |

### Slug

Lowercase letters, digits and single hyphens. Reserved names are refused.

`string`.

### Social

| Field | Type |  |
|---|---|---|
| `platform` | `string` | (**required**) |
| `url` | `string` | (**required**; max 2048) |

### Theme

The page's colours and style. Each value is a string of 1 to 64 characters.

| Field | Type |  |
|---|---|---|
| `background` | `string` | (min 1; max 64) |
| `surface` | `string` | (min 1; max 64) |
| `text` | `string` | (min 1; max 64) |
| `muted` | `string` | (min 1; max 64) |
| `accent` | `string` | (min 1; max 64) |
| `accentText` | `string` | (min 1; max 64) |
| `border` | `string` | (min 1; max 64) |
| `font` | `string` | (min 1; max 64) |
| `buttonStyle` | `string` | (min 1; max 64) |
| `radius` | `string` | (min 1; max 64) |

<!-- https://boltclaw.ai/docs/api/pages 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. -->
