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:
{
"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:
{
"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.
curl "https://api.boltclaw.ai/v1/inquiries?limit=100" \
-H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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) |
{
"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.
curl https://api.boltclaw.ai/v1/inquiries/c41f9a08-6d52-4e17-8b3c-2f7a91d05e64/messages \
-H "Authorization: Bearer bc_live_EXAMPLExxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
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) |
{
"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) |
{
"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.