# Conversations

Threads people start from a page, and the messages in them.

Someone signed in, wrote to one of your pages, and the two of you now have a
thread. This used to be a one-way contact form; a conversation replaced it, and
the shape of the resource changed with it.

A thread:

```json
{
  "id": "c41f9a08-6d52-4e17-8b3c-2f7a91d05e64",
  "page_slug": "acme",
  "channel": "page",
  "participant": {
    "id": "7b1e4c02-95a6-4f3d-8e17-2d5c8a91f4b3",
    "display_name": "Jordan Ruiz"
  },
  "subject": "Bulk order",
  "status": "new",
  "closed_at": null,
  "last_message_at": "2026-03-04T14:02:55.301118+00:00",
  "created_at": "2026-03-04T11:47:23.118904+00:00"
}
```

**There is no `email` and no `message` on a thread, and there will not be.**
The words are messages now — fetch them from the endpoint below.

## `participant` — who this is, on this page

`participant.id` identifies the other person **on this page and nowhere else.**
It is stable: every thread they open on this page carries the same id, so you
can group a correspondent's history and recognise a returning customer. That
matters more than it sounds, because only one thread per person per page can be
*open* at a time — closed ones accumulate, and without this field a dozen
threads from one person are a dozen strangers.

It is also **deliberately useless outside this page.** The same human writing to
another of your pages gets a different `participant.id`, and so does the same
human writing to a different creator. There is no value anywhere in this API
that is the same for one person across two pages, and that is the single
property the whole design protects: your systems and another customer's cannot
be joined on it.

`display_name` is the name on their Google account. It may be `null`.

`participant` itself is `null` on an archived thread whose page you deleted —
there is no page left to be identified on.

**There is still no email address, on either side.** `participant` does not
carry one and will not. Replying through this API reaches the person inside
Bolt Claw; neither of you learns the other's address, which is the reason these
conversations live here rather than in two mailboxes.

## `channel`

`"page"` for every thread today, because today every conversation arrives
through one of your `/<slug>` pages. It is in the response now so that a client
written today can branch on it, rather than one written today breaking on the
day a second value appears.

Treat `page_slug` as **channel-specific**: it is present when `channel` is
`"page"`, and a future channel may carry a different locator or none. Branch on
`channel` before reading it.

`status` is one of `new`, `read`, `replied`, `archived`, `spam`. A thread
returns to `new` when the other person writes again, so a conversation you had
filed comes back up. Your own replies never change it.

`closed_at` set means neither side can add to the thread. Only you can close a
thread, and only you can re-open it.

A message:

```json
{
  "id": "0f2b6a1e-9c74-4d0a-b3f5-7c1e8d2a4b60",
  "from": "them",
  "body": "Do you ship to the desert?",
  "read_at": "2026-03-04T12:10:04.882031+00:00",
  "created_at": "2026-03-04T11:47:23.118904+00:00"
}
```

`from` is `"you"` or `"them"` and never an account id. `read_at` is when the
other party opened it — it is set the first time and not updated afterwards, so
it means *first read*, not *last seen*.

`read_at` on a message **you** received is set by `POST /v1/inquiries/{id}/read`
and by nothing else — fetching this endpoint does not set it. A `null` therefore
means "not marked read", not "not read": see that endpoint for why the
distinction is deliberate.

## Operations

### `GET /v1/inquiries`

**List conversations**

Scope `inquiries:read`.

Conversations on the workspace's pages, most recently active first. Poll with
`since=<your last poll>` to get every thread that has had a message since.

**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) |
| `since` | query | `string` | Only threads with a message at or after this ISO-8601 date or timestamp. |
| `slug` | query | `Slug` | Only threads on this page. (min 1; max 63) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `InquiryList` | A page of conversations. |
| 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. |

**Conversations touched since a time** — answers `200`

Threads, not words: subject, status and timestamps only. Read one conversation to get its messages.

```bash
curl "https://api.boltclaw.ai/v1/inquiries?limit=100" \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

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

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

### `PATCH /v1/inquiries/{id}`

**Set a conversation's status**

Scope `inquiries:write`. Another workspace's answers `404`.

The status in your inbox, and nothing else. The words cannot be edited.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `status` | `InquiryStatus` | (**required**; one of `new`, `read`, `replied`, `archived`, `spam`) |

```json
{
  "status": "new"
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Inquiry` | The conversation. |
| 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. |
| 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/inquiries/{id}/messages`

**Read a conversation**

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

The messages, oldest first. Reading them does not mark them read; call
`POST /inquiries/{id}/read` when a person has actually opened the thread.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |
| `limit` | query | `integer` | Page size, 1 to 200. Above 200 is clamped; anything else is the default. (default `50`; >= 1; <= 200) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `MessageList` | The messages. |
| 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. |

**One conversation, oldest message first** — answers `200`

No sender field and no email address on either side. `you` says which side a message came from, which is all a client needs to render it.

```bash
curl https://api.boltclaw.ai/v1/inquiries/c41f9a08-6d52-4e17-8b3c-2f7a91d05e64/messages \
  -H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
```

```js
const res = await fetch("https://api.boltclaw.ai/v1/inquiries/c41f9a08-6d52-4e17-8b3c-2f7a91d05e64/messages", {
  headers: {
    Authorization: `Bearer ${process.env.BOLTCLAW_KEY}`,
  },
});

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

*Not executed by our own test run: A conversation only exists once a person has messaged a page, and there is no operation in /v1 that creates one.*

### `POST /v1/inquiries/{id}/messages`

**Reply in a conversation**

Scope `inquiries:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Sends a message to the person who wrote in, from your page; they are emailed. A closed
conversation is `409 CONFLICT`. Limited to 60 an hour per key and 30 an hour per
conversation by default (`429`), the same per-conversation number the dashboard is held
to; your plan's numbers are `api_messages_per_hour` and `thread_messages_per_hour` in
`GET /usage`.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `body` | `string` | (**required**; min 1; max 5000) |

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

**Answers**

| Status | Body |  |
|---|---|---|
| 201 | `MessageSent` | Sent. |
| 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. |
| 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. |
| 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. |

### `POST /v1/inquiries/{id}/close`

**Close or re-open a conversation**

Scope `inquiries:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

While closed, neither side can write. Re-opening can collide with a newer open thread from
the same person on the same page (`409 CONFLICT`). Closing is not blocking; blocking a
person happens in the dashboard.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Request body** (required)

| Field | Type |  |
|---|---|---|
| `closed` | `boolean` | (**required**) |

```json
{
  "closed": true
}
```

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `Inquiry` | The conversation. |
| 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. |
| 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. |

### `POST /v1/inquiries/{id}/read`

**Mark a conversation read**

Scope `inquiries:write`. Accepts `Idempotency-Key`. Another workspace's answers `404`.

Marks every unread message from the person who wrote in as read, which they see as a read
receipt. Call it when a person opens the thread, never from a background poll. Takes no
body; a second call marks nothing.

**Parameters**

| Name | In | Type |  |
|---|---|---|---|
| `id` | path | `uuid` | An id this API gave you. Anything that is not one of yours is `404`. (**required**) |

**Answers**

| Status | Body |  |
|---|---|---|
| 200 | `MarkedRead` | How many messages were marked. |
| 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.

### Inquiry

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `page_slug` | `string \| null` | The page it was started on. (**required**) |
| `channel` | `string` | (**required**; one of `page`) |
| `participant` | `object \| null` | The person who wrote in, as they are known on this page only: `id` is stable on this page and unrelated to the same person on any other page. There is no email. (**required**) |
| `subject` | `string \| null` | (**required**) |
| `status` | `InquiryStatus` | (**required**; one of `new`, `read`, `replied`, `archived`, `spam`) |
| `closed_at` | `date-time \| null` | (**required**) |
| `last_message_at` | `date-time` | (**required**) |
| `created_at` | `date-time` | (**required**) |

### InquiryClose

| Field | Type |  |
|---|---|---|
| `closed` | `boolean` | (**required**) |

### InquiryList

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

### InquiryStatus

One of: `new`, `read`, `replied`, `archived`, `spam`.

### InquiryUpdate

| Field | Type |  |
|---|---|---|
| `status` | `InquiryStatus` | (**required**; one of `new`, `read`, `replied`, `archived`, `spam`) |

### MarkedRead

| Field | Type |  |
|---|---|---|
| `marked_read` | `integer` | (**required**; >= 0) |

### Message

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |
| `from` | `string` | `you` is the creator's side, whoever on the team wrote it; `them` is the person who wrote in. (**required**; one of `you`, `them`) |
| `body` | `string` | (**required**) |
| `read_at` | `date-time \| null` | (**required**) |
| `created_at` | `date-time` | (**required**) |

### MessageCreate

| Field | Type |  |
|---|---|---|
| `body` | `string` | (**required**; min 1; max 5000) |

### MessageList

| Field | Type |  |
|---|---|---|
| `data` | `Message[]` | (**required**) |

### MessageSent

| Field | Type |  |
|---|---|---|
| `id` | `uuid` | (**required**) |

### NextCursor

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

`string | null`.

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