Chats
List chats
Get a chat
Clarify an unclassifiable turn
Resolve a chat
ChatsMessages
Request
POST /v1/chats/{chat}/messages
Authorization: Bearer sk_live_...
Idempotency-Key: 3f2504e0-4f89-11d3-9a0c-0305e82c3301
{"parts": [{"type": "text", "body": "Your order ships tomorrow."}]}
Response
{"id": "msg_4a1c...",
"chat_id": "chat_2d4f8a91...",
"delivery": "queued",
"messages": [{"id": "msg_4a1c...", "type": "text"}]}
Idempotency-Key is required. A replay of the same key within 24 hours returns
the original response with Idempotent-Replayed: true. A refused send records no
key, so the same key can be reused once the cause is fixed.
Delivery
queued means accepted, not delivered. The outcome arrives as message.sent or
message.failed, or from GET /v1/messages/{msg} if you missed the webhook.
message.failed carries a reason; consent_revoked, capability_rejected,
and so on.
One request, several messages
An https:// URL in a text part becomes a rich link, and the text around it
becomes a message of its own. So one request can produce several messages.
They all appear in messages, they are all covered by the one
Idempotency-Key, and each one gets its own delivery webhook, tied together by
group_id. Count what’s in messages; not the requests you sent.
Parts
A message is an array of parts. Ten types are available:
text · choices · list_picker · time_slots · form · rich_link ·
attachment · apple_pay · authenticate · app_extension
Each type has a JSON Schema at GET /v1/parts/{type}. We serve it unmodified,
so you can hand it straight to a structured-output model as a tool schema.
Some rules can’t be expressed in JSON Schema; length caps, how many choices a
picker may carry, whether an ISO code is upper or lower case. Those ride along
as metadata on each schema, and we enforce them when you send. Breaking one
returns 422 with a pointer to the exact field at fault.
Text plus attachments is the only multi-part combination Apple accepts. The 
character (U+FFFC) positions each attachment within the text.
Sending a file
Send the file and the message in one request: make the POST
multipart/form-data, put the message JSON in a message field, and give
each attachment part the name of the file part carrying its bytes.
POST /v1/chats/{chat}/messages
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7
Content-Type: multipart/form-data
message = {"parts": [
{"type": "text", "body": "Here's the menu you asked for"},
{"type": "attachment", "file": "photo"}
], "category": "transactional"}
photo = <the JPEG bytes>
The response is the same as any send. A blind retry with the same
Idempotency-Key and the same file replays rather than re-sending. Max
size is 100 MB; per file, and for a message’s files together.
Artwork you reference by id
Some parts name a stored file instead of carrying one, and they take an
attachment id rather than inline base64: a rich link’s image_att_id, the
images inside a list picker, a time-slot picker or a form, and your brand’s
default rich-link image.
Upload the asset once, then reference that id as often as you need it.
POST /v1/attachments
Content-Type: image/png
x-filename: menu-thumb.png
<the PNG bytes>
{"id": "att_1a3f77", "mime": "image/png", "size": 20481}
Uploads are idempotent over your brand and the bytes, so a retry returns the
same id and stores nothing twice. The stored mime and name stay the first
upload’s, so uploading the same bytes under a corrected content-type hands
you the original type back; to change a stored type, upload altered bytes,
which are a different attachment.
This route is for assets you reference. To send a file to a customer, put it in the send, as above.
Capabilities
Each inbound message announces what the customer’s device can render, most recent wins. Sending a part the current device cannot render returns:
Response
{"error": {"type": "capability", "code": "capability_unsupported",
"message": "this device did not announce FORM"},
"request_id": "req_01HQ7X..."}
Set "degrade": "auto" and we rewrite the part into something the device can
display, then return a fallbacks array describing what we swapped. If
fallbacks is present, the customer saw something other than what you
composed; check it before assuming your message landed as written.
Limits
| Attachment | 100 MB; exactly 100,000,000 bytes |
| Attachments per message | 100,000,000 bytes across all of them, together |
Serialized interactiveData | 10 KiB |
| Rich link image | 200 kB decoded, PNG only |
| Invitation batch | 10,000 recipients |
The interactiveData limit is measured over your bytes. Content we add at delivery, such as an Apple Pay
session or an app_extension icon, is not charged against it.
The two attachment rows are the same number for a reason: one 100 MB file is
fine, and so are ten 10 MB files, but a message carrying more than
100,000,000 bytes of files in total is more than we will carry to Apple in
one send. Files sent in the request are refused up front with 413 too_large naming the ceiling.
Send a message
Preview a message
ChatsTyping
Start and stop the business-side typing indicator. It is live only: never recorded, and wrong once stale.
Set the typing indicator
ChatsEvents
A chat is a conversation between one customer and your brand. It starts when a customer messages you, or when they accept an invitation you sent. No endpoint creates one directly, and you can’t message someone who has never contacted you. Invitations are the one way to reach a customer first, and they send an Apple-templated card rather than a message you write.
A chat is identified by its chat_id, like
chat_2d4f8a91-6c03-47be-b5d2-9e81c3f60a27. Treat it as an opaque string,
don’t parse it, and don’t depend on its prefix or length.
A chat_id belonging to another brand returns 404, the same response you get
for one that doesn’t exist. You can’t tell the two apart, which is deliberate.
GET /v1/chats/{chat} returns the full object. The fields that govern whether
you can send:
state; open or closed. A customer deletes a conversation on their
device, and the chat closes. Sends to a closed chat return 409 chat_closed. A
new inbound message reopens it.
owner, who currently holds the conversation: partner, flow,
human_pending, or human. Only one of them holds it at a time, and only the
holder can send. Sending while owner is anything but partner returns
409 chat_owned, which is what stops your automation from talking over a human
agent mid-sentence.
POST /v1/chats/{chat}/handoff passes the conversation to someone else.
?force=true is on the SEND, not the handoff: it sends into a chat you do not
own and records that you overrode the gate (chat.send_forced). It does not
override consent: a customer who opted out still returns
403 consent_required.
capabilities; what the customer’s device told us it can display, as of
their most recent message. An empty array means we don’t know yet, not that the
device supports nothing.
send_blocked; whether a send would be refused right now, and with which
error:
Response
{"send_blocked": {"blocked": true,
"code": "chat_owned",
"message": "a human agent holds this chat"}}
We run the same checks, in the same order, that a real send runs. So code and
message are exactly what you would get back if you tried to send.
"blocked": false means nothing about the chat itself would refuse you. It is
not a promise that your message will be accepted. Anything that depends on the
message: its size, or whether the customer’s device can render the parts you
composed; is checked when you send it.
Listing
GET /v1/chats returns your chats newest-first, a page at a time. Pass the
previous response’s next_cursor back verbatim; the list ends when it comes
back as an empty string. Filters are optional: state, owner,
assigned_operator.
Reading a chat
GET /v1/chats/{chat}/transcript returns what the customer saw, in order.
Response
{"data": [{"seq": 13,
"kind": "customer_message",
"event_type": "message_received",
"actor": "customer",
"created_at": "2026-08-06T14:01:58Z",
"parts": [{"type": "text", "body": "where is my driver?"}]}]}
Decide what to render from kind. It has three values and will never have
more: customer_message, business_message, and system. When we add a new
event type in a later release it arrives as system, so a switch you write
today keeps working.
event_type says what the event actually was (message_received,
owner_changed, consent_send_refused). Use it to fill in the detail once
you’ve decided how to render the row, but don’t build your control flow on
it, because new values appear here over time.
seq numbers the events in a chat and never skips. Page with
?after_seq=<the last seq you saw>&limit=…. GET /v1/chats/{chat}/events
returns the same events without the transcript’s formatting.
List chat events
ChatsTranscript
A chat is a conversation between one customer and your brand. It starts when a customer messages you, or when they accept an invitation you sent. No endpoint creates one directly, and you can’t message someone who has never contacted you. Invitations are the one way to reach a customer first, and they send an Apple-templated card rather than a message you write.
A chat is identified by its chat_id, like
chat_2d4f8a91-6c03-47be-b5d2-9e81c3f60a27. Treat it as an opaque string,
don’t parse it, and don’t depend on its prefix or length.
A chat_id belonging to another brand returns 404, the same response you get
for one that doesn’t exist. You can’t tell the two apart, which is deliberate.
GET /v1/chats/{chat} returns the full object. The fields that govern whether
you can send:
state; open or closed. A customer deletes a conversation on their
device, and the chat closes. Sends to a closed chat return 409 chat_closed. A
new inbound message reopens it.
owner, who currently holds the conversation: partner, flow,
human_pending, or human. Only one of them holds it at a time, and only the
holder can send. Sending while owner is anything but partner returns
409 chat_owned, which is what stops your automation from talking over a human
agent mid-sentence.
POST /v1/chats/{chat}/handoff passes the conversation to someone else.
?force=true is on the SEND, not the handoff: it sends into a chat you do not
own and records that you overrode the gate (chat.send_forced). It does not
override consent: a customer who opted out still returns
403 consent_required.
capabilities; what the customer’s device told us it can display, as of
their most recent message. An empty array means we don’t know yet, not that the
device supports nothing.
send_blocked; whether a send would be refused right now, and with which
error:
Response
{"send_blocked": {"blocked": true,
"code": "chat_owned",
"message": "a human agent holds this chat"}}
We run the same checks, in the same order, that a real send runs. So code and
message are exactly what you would get back if you tried to send.
"blocked": false means nothing about the chat itself would refuse you. It is
not a promise that your message will be accepted. Anything that depends on the
message: its size, or whether the customer’s device can render the parts you
composed; is checked when you send it.
Listing
GET /v1/chats returns your chats newest-first, a page at a time. Pass the
previous response’s next_cursor back verbatim; the list ends when it comes
back as an empty string. Filters are optional: state, owner,
assigned_operator.
Reading a chat
GET /v1/chats/{chat}/transcript returns what the customer saw, in order.
Response
{"data": [{"seq": 13,
"kind": "customer_message",
"event_type": "message_received",
"actor": "customer",
"created_at": "2026-08-06T14:01:58Z",
"parts": [{"type": "text", "body": "where is my driver?"}]}]}
Decide what to render from kind. It has three values and will never have
more: customer_message, business_message, and system. When we add a new
event type in a later release it arrives as system, so a switch you write
today keeps working.
event_type says what the event actually was (message_received,
owner_changed, consent_send_refused). Use it to fill in the detail once
you’ve decided how to render the row, but don’t build your control flow on
it, because new values appear here over time.
seq numbers the events in a chat and never skips. Page with
?after_seq=<the last seq you saw>&limit=…. GET /v1/chats/{chat}/events
returns the same events without the transcript’s formatting.
List chat transcript
ChatsActivity
A chat is a conversation between one customer and your brand. It starts when a customer messages you, or when they accept an invitation you sent. No endpoint creates one directly, and you can’t message someone who has never contacted you. Invitations are the one way to reach a customer first, and they send an Apple-templated card rather than a message you write.
A chat is identified by its chat_id, like
chat_2d4f8a91-6c03-47be-b5d2-9e81c3f60a27. Treat it as an opaque string,
don’t parse it, and don’t depend on its prefix or length.
A chat_id belonging to another brand returns 404, the same response you get
for one that doesn’t exist. You can’t tell the two apart, which is deliberate.
GET /v1/chats/{chat} returns the full object. The fields that govern whether
you can send:
state; open or closed. A customer deletes a conversation on their
device, and the chat closes. Sends to a closed chat return 409 chat_closed. A
new inbound message reopens it.
owner, who currently holds the conversation: partner, flow,
human_pending, or human. Only one of them holds it at a time, and only the
holder can send. Sending while owner is anything but partner returns
409 chat_owned, which is what stops your automation from talking over a human
agent mid-sentence.
POST /v1/chats/{chat}/handoff passes the conversation to someone else.
?force=true is on the SEND, not the handoff: it sends into a chat you do not
own and records that you overrode the gate (chat.send_forced). It does not
override consent: a customer who opted out still returns
403 consent_required.
capabilities; what the customer’s device told us it can display, as of
their most recent message. An empty array means we don’t know yet, not that the
device supports nothing.
send_blocked; whether a send would be refused right now, and with which
error:
Response
{"send_blocked": {"blocked": true,
"code": "chat_owned",
"message": "a human agent holds this chat"}}
We run the same checks, in the same order, that a real send runs. So code and
message are exactly what you would get back if you tried to send.
"blocked": false means nothing about the chat itself would refuse you. It is
not a promise that your message will be accepted. Anything that depends on the
message: its size, or whether the customer’s device can render the parts you
composed; is checked when you send it.
Listing
GET /v1/chats returns your chats newest-first, a page at a time. Pass the
previous response’s next_cursor back verbatim; the list ends when it comes
back as an empty string. Filters are optional: state, owner,
assigned_operator.
Reading a chat
GET /v1/chats/{chat}/transcript returns what the customer saw, in order.
Response
{"data": [{"seq": 13,
"kind": "customer_message",
"event_type": "message_received",
"actor": "customer",
"created_at": "2026-08-06T14:01:58Z",
"parts": [{"type": "text", "body": "where is my driver?"}]}]}
Decide what to render from kind. It has three values and will never have
more: customer_message, business_message, and system. When we add a new
event type in a later release it arrives as system, so a switch you write
today keeps working.
event_type says what the event actually was (message_received,
owner_changed, consent_send_refused). Use it to fill in the detail once
you’ve decided how to render the row, but don’t build your control flow on
it, because new values appear here over time.
seq numbers the events in a chat and never skips. Page with
?after_seq=<the last seq you saw>&limit=…. GET /v1/chats/{chat}/events
returns the same events without the transcript’s formatting.
Get a chat's activity timeline
ChatsHandoff
Ownership of a chat — take it, hand it back, close it. An exclusive owner is the only sender.