Skip to content
Linq

Chats

List chats
GET/v1/chats
Get a chat
GET/v1/chats/{chat}
Clarify an unclassifiable turn
POST/v1/chats/{chat}/clarify
Resolve a chat
POST/v1/chats/{chat}/resolve
ModelsExpand Collapse
ChatView object { id, brand, capabilities, 14 more }

The partner view of a chat.

id: string

Chat id (chat_…). The LIST endpoint spells the same fact chat_id; neither is renamed to match the other.

brand: object { id, business_hours, display_name, hours_timezone }

This chat’s brand and its configured display/hours.

id: string

The brand this chat belongs to; always your own.

business_hours: optional unknown

Business hours as stored: a LIST of ["HH:MM","HH:MM"] ranges per lowercase weekday key (monsun), so a split shift is expressible. Absent when no hours are configured.

display_name: optional string

The brand’s display name; absent when it has none.

hours_timezone: optional string

The IANA zone the hours are stated in (America/New_York). Hours are never configured without it; it may be configured without hours.

capabilities: array of string

The device’s most recently announced capability tokens. Empty means unknown, never “supports nothing”.

customer_handle: string

The customer identifier: an Apple Opaque ID, or canonical tel:+E164, per handle_kind.

handle_kind: "opaque" or "tel"

Which identifier kind customer_handle holds; branch on this, never read the string itself. A tel chat is not addressable for sends or typing.

One of the following:
"opaque"
"tel"
origin: "customer" or "invitation"

How the chat began. Not derivable from handle_kind: an accepted invitation arrives under a brand-new opaque id.

One of the following:
"customer"
"invitation"
owner: string

Turn owner: partner, flow, human_pending or human. Sends while not partner return HTTP 409 code 2011. flow is a platform flow holding the turn, typically the survey /resolve starts or the question /clarify asks: it ends on the customer’s answer or its own timeout, and ownership then returns, recorded by chat.owner_changed.

owner_since: string

When the chat entered its CURRENT owner state: the queue clock, NOT updated_at (which any progress bumps). Stamped only when the owner actually changes. One exception, and it is self-clearing: a chat that has not changed owner since the column was deployed reports the deploy time instead, which is a CEILING: deploy time is later than the true entry, so a wait derived from it reads SHORTER than the real one until that chat’s next owner move. Don’t page on it alone.

formatdate-time
send_blocked: object { blocked, code, message }

Whether a send would be refused right now, and why; read it BEFORE composing.

blocked: boolean
code: number

The code the send would return, byte-identical to the error.code a real send would answer with; 0 when blocked is false.

formatint32
message: string

The message the send would return, verbatim; empty when blocked is false.

state: string

Lifecycle phase: open or closed. Sends to a closed chat returns HTTP 409 code 2010.

updated_at: string

Last change of any kind, machine progress included.

formatdate-time
assigned_operator: optional string

The operator holding this chat; absent when nobody does (the common case).

business_id: optional string

The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. This is the current binding, not a per-message historical value, so it is the id you can act on today. Distinct from brand.id, which is your logical brand on this platform.

capabilities_announced_at: optional string

When a device last ANNOUNCED a capability-list; NOT when we last heard from them (an inbound carrying no capability header moves last_inbound_at and leaves this alone). Absent means no device has ever announced.

formatdate-time
capture: optional object { expires_at, group_id, opened_at, page_id }

The chat’s open reply capture (a degraded form mid-collection), so a takeover desk can warn before ending it: the group id form.response will carry, your page id, and the open/expiry instants. Absent when none is open; an expired capture is filtered out here, never closed by a read.

expires_at: string
formatdate-time
group_id: string

The degraded fan’s correlation id: the primary prompt message’s own id.

opened_at: string
formatdate-time
page_id: optional string

Your form page’s id.

last_inbound_at: optional string

When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet). A typing indicator is not recorded and does not move it; neither does anything the business sends.

formatdate-time
ChatListResponse object { data, next_cursor }

One page of chats, newest-first.

data: array of object { capabilities, chat_id, customer_handle, 10 more }
capabilities: array of string

The device’s most recently announced capability tokens. Empty means unknown, never “supports nothing”.

chat_id: string

Chat id (chat_…); pass it to GET /v1/chats/{chat} for the full view.

customer_handle: string

The customer identifier: an Apple Opaque ID, or canonical tel:+E164, per handle_kind.

handle_kind: "opaque" or "tel"

Which identifier kind customer_handle holds; branch on this, never read the string itself.

One of the following:
"opaque"
"tel"
origin: "customer" or "invitation"

How the chat began. Not derivable from handle_kind: an accepted invitation arrives under a brand-new opaque id.

One of the following:
"customer"
"invitation"
owner: string

Turn owner: partner, flow, human_pending or human. human_pending is the queue.

owner_since: string

When the chat entered its CURRENT owner state: the queue clock, NOT updated_at (which any progress bumps). Stamped only when the owner actually changes; see GET /v1/chats/{chat} for the one self-clearing exception.

formatdate-time
state: string

Lifecycle phase: open or closed.

updated_at: string

Last change of any kind: the list’s sort key, and the first half of the cursor.

formatdate-time
assigned_operator: optional string

The operator holding this chat; absent when nobody does (the common case).

business_id: optional string

The Apple business UUID your brand currently resolves to on this channel: the channel binding of record, present only when one is on file. The same value GET /v1/chats/{chat} serves.

last_inbound_at: optional string

When we last heard from the customer; any inbound, a message or a chat close. ABSENT when they have never written (an invitation nobody has answered yet).

formatdate-time
last_message: optional object { actor, at, has_attachment, 2 more }

The newest message on this chat, so the row has a subject line instead of only a handle and a wait age. ABSENT when the chat has nothing previewable: an invitation nobody has answered, a chat whose only events are ownership changes, or a message that decoded to nothing. Render nothing in that case; never invent a preview.

actor: string

Who spoke: customer on an inbound, partner on an outbound. It is the event log’s actor, not the composer: a send is recorded by the worker that delivered it, so a reply a human agent typed still reads partner here. GET /v1/chats/{chat}/transcript is where a human is named.

at: string

When that message was recorded. Distinct from both of its neighbours on this row: last_inbound_at moves only on inbound, and updated_at moves on any machine progress. Do not treat the three as one clock.

formatdate-time
has_attachment: boolean

Whether the message carried files. Deliberately ORTHOGONAL to kind, because the two answer different questions: kind says what to write on the row and this says whether to badge it. kind: "text" with this true is words PLUS a file: the one multi-part combination Apple admits.

kind: "text" or "attachment" or "interactive" or "reply"

What kind of thing it was: a CLOSED set. text: there were words. attachment: there were none, only files. reply: the customer answered an interactive message instead of typing. interactive: the business sent one. Words win when a message has both.

One of the following:
"text"
"attachment"
"interactive"
"reply"
preview: optional string

The opening words, with attachment placeholders () removed and a single where it was cut. ABSENT rather than empty when no words survived: an attachment-only message has a body of exactly , and a preview built from it verbatim would put an invisible character on the row. Say “sent a file” in your own words instead; this API does not ship desk copy.

next_cursor: string

Pass back as ?cursor= for the next page. EMPTY when this page exhausted the list, that, not an empty data, is how paging ends.

ChatClarifyResponse object { action }

What /clarify did on your behalf.

action: "clarified" or "escalated"

clarified: the one bounded question was asked; escalated: this chat had already used its question, so we escalated instead.

One of the following:
"clarified"
"escalated"
ChatResolveResponse object { action }

What /resolve did.

action: "resolved" or "already_resolved"

resolved: the CSAT flow was started; already_resolved: this chat was resolved within the last 10 minutes, so the call was a no-op; never retry it.

One of the following:
"resolved"
"already_resolved"

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

Attachment100 MB; exactly 100,000,000 bytes
Attachments per message100,000,000 bytes across all of them, together
Serialized interactiveData10 KiB
Rich link image200 kB decoded, PNG only
Invitation batch10,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
POST/v1/chats/{chat}/messages
Preview a message
POST/v1/chats/{chat}/messages/preview
ModelsExpand Collapse
AppExtensionPart object { app, received_message, reply_message, 4 more }

A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand’s registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

app: string

Which of the brand’s registered apps to send under.

received_message: object { title, subtitle }

Shown on a device without the extension installed.

title: string
subtitle: optional string
reply_message: object { title, subtitle }

Shown on a device without the extension installed.

title: string
subtitle: optional string
type: "app_extension"
url: string

The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

session_id: optional string

Reuse to address a card already sent; omit and Apple generates one.

use_live_layout: optional boolean

Defaults to true.

ApplePayPart object { payment_request, received_message, type }

AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand’s configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

payment_request: object { country_code, currency_code, line_items, 7 more }
country_code: string
currency_code: string
line_items: array of object { amount, label, type }
amount: string
label: string
type: optional "final" or "pending" or ""

Empty means unset.

One of the following:
"final"
"pending"
""
merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"
One of the following:
"supports3DS"
"supportsCredit"
"supportsDebit"
"supportsEMV"
supported_networks: array of "amex" or "discover" or "jcb" or 3 more
One of the following:
"amex"
"discover"
"jcb"
"masterCard"
"privateLabel"
"visa"
total: object { amount, label, type }

The grand total.

amount: string
label: string
type: optional "final" or "pending" or ""

Empty means unset.

One of the following:
"final"
"pending"
""
required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more
One of the following:
"email"
"name"
"phone"
"phoneticName"
"post"
required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more
One of the following:
"email"
"name"
"phone"
"phoneticName"
"post"
shipping_methods: optional array of object { amount, detail, identifier, label }
amount: string
detail: string
identifier: string
label: string
supported_countries: optional array of string
received_message: object { title, image_att_id, subtitle }

The bubble the customer sees before authorizing.

title: string
image_att_id: optional string
subtitle: optional string
type: "apple_pay"
AttachmentPart object { att_id, type }

An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part’s source_url form instead; never inline base64.

att_id: string
type: "attachment"
AuthenticatePart object { received_title, reply_title, scope, 3 more }

Opens Apple’s sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

received_title: string

The bubble the customer sees.

reply_title: string

The bubble after completion.

scope: array of string
type: "authenticate"
redirect_uri: optional string
state: optional string
ChoicesPart object { items, summary_text, type }

AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

items: array of object { id, label }
id: string
label: string
summary_text: string
type: "choices"
Fallback object { token, from, rule, 2 more }

One capability rewrite performed under degrade: "auto", or "acknowledged", which rewrites on exactly the same rules and records the swap the same way.

token: string

The missing capability token that forced the rewrite.

from: string

Canonical part type in.

rule: string

The fallback-tree rule applied (e.g. guide-17.5-select-le5).

to: string

Canonical part type out. For a rewritten form this is text_sequence: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response’s own messages[].

options: optional array of FallbackOption { index, item_id, label }

Present only when the rewrite flattened a menu.

index: number

1-based, matching the numbering the degraded copy emits.

formatint32
item_id: string

The original item id, so a customer’s “2” maps back to your routing.

label: string
FallbackOption object { index, item_id, label }

One entry of a menu a degrade rewrite flattened into words.

index: number

1-based, matching the numbering the degraded copy emits.

formatint32
item_id: string

The original item id, so a customer’s “2” maps back to your routing.

label: string
FormPart object { header, pages, type, 7 more }

AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

header: string
pages: array of object { id, kind, subtitle, 15 more }
id: string
kind: "select" or "picker" or "datePicker" or "input"
One of the following:
"select"
"picker"
"datePicker"
"input"
subtitle: string

The question shown to the user.

date_format: optional string

datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

hint_text: optional string

datePicker only.

items: optional array of object { id, label, image_id }

select and picker only.

id: string
label: string
image_id: optional string

select items only.

keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more

input only.

One of the following:
"default"
"asciiCapable"
"numbersAndPunctuation"
"URL"
"numberPad"
"phonePad"
"namePhonePad"
"emailAddress"
"decimalPad"
"webSearch"
label_text: optional string

datePicker only; defaults to “Date”.

max_chars: optional number

input only.

maximum_date: optional string

datePicker only, in date_format.

minimum_date: optional string

datePicker only, in date_format.

multiple: optional boolean

select only.

next_page_id: optional string
picker_title: optional string

picker only; empty centers the field.

required: optional boolean

input only.

selected_item_index: optional number

picker only.

start_date: optional string

datePicker only, in date_format.

title: optional string
type: "form"
bubble_image_id: optional string

Icon on the received/reply message bubble.

bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

show_summary: optional boolean
splash_button_title: optional string

Defaults to “Start”. The splash view shows when any splash field is set.

splash_image_id: optional string
splash_text: optional string
ListPickerPart object { sections, summary_text, type, 4 more }

AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

sections: array of object { items, title, multi_select }
items: array of object { id, label, detail, image_id }
id: string
label: string
detail: optional string
image_id: optional string
title: string
multi_select: optional boolean
summary_text: string
type: "list_picker"
bubble_image_id: optional string
bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

subtitle: optional string
Part = TextPart { body, type, subject } or ChoicesPart { items, summary_text, type } or ListPickerPart { sections, summary_text, type, 4 more } or 7 more

The sealed part union: one variant per member of the canonical content model (10 types). Discriminated on the wire by each part’s type const.

One of the following:
TextPart object { body, type, subject }

A plain text message. subject renders bold on AMB.

body: string
type: "text"
subject: optional string
ChoicesPart object { items, summary_text, type }

AMB Quick Reply: single-select items. summary_text is the transcript label, not the prompt; send the prompt as a preceding text message.

items: array of object { id, label }
id: string
label: string
summary_text: string
type: "choices"
ListPickerPart object { sections, summary_text, type, 4 more }

AMB List Picker: sections of items, per-section multi-select. Item image_id and bubble_image_id reference the shared images pool by id.

sections: array of object { items, title, multi_select }
items: array of object { id, label, detail, image_id }
id: string
label: string
detail: optional string
image_id: optional string
title: string
multi_select: optional boolean
summary_text: string
type: "list_picker"
bubble_image_id: optional string
bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

subtitle: optional string
TimeSlotsPart object { event, slots, type, 5 more }

AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

event: object { title, image_id, location }
title: string
image_id: optional string
location: optional object { latitude, longitude, radius_m, title }
latitude: optional number
longitude: optional number
radius_m: optional number
title: optional string
slots: array of object { id, duration_s, start }
id: string
duration_s: number
start: string
type: "time_slots"
bubble_image_id: optional string
bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

subtitle: optional string
timezone_offset_min: optional number
FormPart object { header, pages, type, 7 more }

AMB Message Form (dynamic v1.2). Page kinds: select, picker, datePicker, input. The last page is the submit page. Omitting show_summary means true.

header: string
pages: array of object { id, kind, subtitle, 15 more }
id: string
kind: "select" or "picker" or "datePicker" or "input"
One of the following:
"select"
"picker"
"datePicker"
"input"
subtitle: string

The question shown to the user.

date_format: optional string

datePicker only. ICU pattern, default MM/dd/yyyy. It governs how the device reads the three date strings.

hint_text: optional string

datePicker only.

items: optional array of object { id, label, image_id }

select and picker only.

id: string
label: string
image_id: optional string

select items only.

keyboard_type: optional "default" or "asciiCapable" or "numbersAndPunctuation" or 7 more

input only.

One of the following:
"default"
"asciiCapable"
"numbersAndPunctuation"
"URL"
"numberPad"
"phonePad"
"namePhonePad"
"emailAddress"
"decimalPad"
"webSearch"
label_text: optional string

datePicker only; defaults to “Date”.

max_chars: optional number

input only.

maximum_date: optional string

datePicker only, in date_format.

minimum_date: optional string

datePicker only, in date_format.

multiple: optional boolean

select only.

next_page_id: optional string
picker_title: optional string

picker only; empty centers the field.

required: optional boolean

input only.

selected_item_index: optional number

picker only.

start_date: optional string

datePicker only, in date_format.

title: optional string
type: "form"
bubble_image_id: optional string

Icon on the received/reply message bubble.

bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

show_summary: optional boolean
splash_button_title: optional string

Defaults to “Start”. The splash view shows when any splash field is set.

splash_image_id: optional string
splash_text: optional string
AttachmentPart object { att_id, type }

An attachment by reference: att_id names a file the platform holds for your brand, such as an inbound attachment we retained (the id on the message.received event). To send a file you host, use this part’s source_url form instead; never inline base64.

att_id: string
type: "attachment"
ApplePayPart object { payment_request, received_message, type }

AMB Apple Pay payment request. We fill in the merchant identity, gateway URL and merchant session from your brand’s configuration, so merchant_identifier, endpoints and merchant_session are rejected as unknown fields. Amounts are decimal strings.

payment_request: object { country_code, currency_code, line_items, 7 more }
country_code: string
currency_code: string
line_items: array of object { amount, label, type }
amount: string
label: string
type: optional "final" or "pending" or ""

Empty means unset.

One of the following:
"final"
"pending"
""
merchant_capabilities: array of "supports3DS" or "supportsCredit" or "supportsDebit" or "supportsEMV"
One of the following:
"supports3DS"
"supportsCredit"
"supportsDebit"
"supportsEMV"
supported_networks: array of "amex" or "discover" or "jcb" or 3 more
One of the following:
"amex"
"discover"
"jcb"
"masterCard"
"privateLabel"
"visa"
total: object { amount, label, type }

The grand total.

amount: string
label: string
type: optional "final" or "pending" or ""

Empty means unset.

One of the following:
"final"
"pending"
""
required_billing_contact_fields: optional array of "email" or "name" or "phone" or 2 more
One of the following:
"email"
"name"
"phone"
"phoneticName"
"post"
required_shipping_contact_fields: optional array of "email" or "name" or "phone" or 2 more
One of the following:
"email"
"name"
"phone"
"phoneticName"
"post"
shipping_methods: optional array of object { amount, detail, identifier, label }
amount: string
detail: string
identifier: string
label: string
supported_countries: optional array of string
received_message: object { title, image_att_id, subtitle }

The bubble the customer sees before authorizing.

title: string
image_att_id: optional string
subtitle: optional string
type: "apple_pay"
AuthenticatePart object { received_title, reply_title, scope, 3 more }

Opens Apple’s sign-in sheet (New Authentication Message). You supply the OAuth scope and the two bubble titles. We fill in state and redirect_uri when the customer accepts.

received_title: string

The bubble the customer sees.

reply_title: string

The bubble after completion.

scope: array of string
type: "authenticate"
redirect_uri: optional string
state: optional string
AppExtensionPart object { app, received_message, reply_message, 4 more }

A third-party App Extension bubble. We fill in the app identity (bid, appId, appName, appIcon) from your brand’s registered apps, so those are rejected as request fields. We never read url; it is handed to the extension exactly as you sent it.

app: string

Which of the brand’s registered apps to send under.

received_message: object { title, subtitle }

Shown on a device without the extension installed.

title: string
subtitle: optional string
reply_message: object { title, subtitle }

Shown on a device without the extension installed.

title: string
subtitle: optional string
type: "app_extension"
url: string

The payload the Messages app hands to the extension. Opaque: never parsed or rewritten.

session_id: optional string

Reuse to address a card already sent; omit and Apple generates one.

use_live_layout: optional boolean

Defaults to true.

PreviewMessageResponse object { chat_id, messages, wire_fidelity, 2 more }

The previewed send. Nothing was written; nothing will be sent.

chat_id: string
messages: array of PreviewedMessage { parts, type, wire, wire_unavailable }

What the ONE request would become, message by message (URL promotion can make several).

parts: array of unknown

The message’s post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real att_id that an accepted send returns. Typed as an opaque array rather than Part[] for the same one-decode-door reason TranscriptRow.parts is.

type: string

The part type of this message’s FIRST part: the same value the send’s 200 reports per message.

wire: optional unknown

The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when wire_unavailable says why.

wire_unavailable: optional string

Present only when this message has no wire preview (a plain attachment message), and why.

wire_fidelity: "representative"

Always representative: the wire bytes are an approximation of what delivery sends, never the exact payload.

capture: optional object { collection, expects, page_id, 4 more }

The reply capture this send WOULD open, identical to the send 200’s object except group_id, which is ABSENT here (nothing was minted). Absent entirely when no capture would open.

collection: string

single_shot: the whole prompt sequence goes at once. progressive is reserved.

expects: string

The reply shape we will match: choice, multi_choice, text, phone, email, or date_text (a date question at the text floor records the customer’s words verbatim; date_choice is reserved for a future variant that offers concrete slots).

page_id: string

Your form page’s own id: the key the eventual reply.values uses.

reasks: number

How many re-asks a mismatched reply earns before the collection ends abandoned. Fixed at 1.

formatint32
timeout_s: number

How long the collection stays open, in seconds. Fixed at 1800.

formatint32
group_id: optional string

ABSENT on the preview (nothing was minted); on the send 200, the fan’s correlation id: the primary message’s own id, the value form.response reports back.

options: optional array of object { index, item_id, label }

Present for the choice shapes only: what the customer can answer with, by number or label.

index: number
formatint32
item_id: string
label: string
fallbacks: optional array of Fallback { token, from, rule, 2 more }

The capability rewrites degrade would apply: the same array the send’s 200 would carry. Present only when something would be rewritten.

token: string

The missing capability token that forced the rewrite.

from: string

Canonical part type in.

rule: string

The fallback-tree rule applied (e.g. guide-17.5-select-le5).

to: string

Canonical part type out. For a rewritten form this is text_sequence: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response’s own messages[].

options: optional array of FallbackOption { index, item_id, label }

Present only when the rewrite flattened a menu.

index: number

1-based, matching the numbering the degraded copy emits.

formatint32
item_id: string

The original item id, so a customer’s “2” maps back to your routing.

label: string
PreviewedMessage object { parts, type, wire, wire_unavailable }

One message of a previewed send, in send order (URL promotion can make several).

parts: array of unknown

The message’s post-degrade, post-promotion parts. A URL attachment remains in its source form because preview does not fetch it or mint the real att_id that an accepted send returns. Typed as an opaque array rather than Part[] for the same one-decode-door reason TranscriptRow.parts is.

type: string

The part type of this message’s FIRST part: the same value the send’s 200 reports per message.

wire: optional unknown

The representative Apple wire fields for this message; see the operation description for exactly what is substituted. Absent when wire_unavailable says why.

wire_unavailable: optional string

Present only when this message has no wire preview (a plain attachment message), and why.

SentMessage object { id, type, parts }

One accepted message of a send (URL promotion can make several).

id: string

Message id (msg_…).

type: string

The part type of that message’s FIRST part.

parts: optional array of unknown

The message’s canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason TranscriptRow.parts is.

TextPart object { body, type, subject }

A plain text message. subject renders bold on AMB.

body: string
type: "text"
subject: optional string
TimeSlotsPart object { event, slots, type, 5 more }

AMB Time Picker: whole-minute, future-only slot starts; duration in seconds; timezone offset in minutes.

event: object { title, image_id, location }
title: string
image_id: optional string
location: optional object { latitude, longitude, radius_m, title }
latitude: optional number
longitude: optional number
radius_m: optional number
title: optional string
slots: array of object { id, duration_s, start }
id: string
duration_s: number
start: string
type: "time_slots"
bubble_image_id: optional string
bubble_style: optional "icon" or "small" or "large"

Size of the transcript bubble this part renders as, and therefore the size of bubble_image_id inside it. icon is a 280x65 pt bubble with a 40x40 pt image (120x120 px @3x). small is 280x85 pt with 60x60 pt (180x180 px). large is 280x210 pt with a 263x150 pt image (789x450 px), the photo-forward card. Omit to keep this part’s existing bubble.

One of the following:
"icon"
"small"
"large"
images: optional array of object { id, att_id, description }
id: string
att_id: string

A stored attachment’s att_id; never inline base64.

description: optional string

Read aloud by VoiceOver.

subtitle: optional string
timezone_offset_min: optional number
MessageSendResponse object { id, chat_id, delivery, 3 more }

The accepted send. A replay of the same Idempotency-Key within 24h returns these exact bytes; past that window the key is forgotten and the request executes again.

id: string

The FIRST message’s id: the send’s primary id.

chat_id: string
delivery: "queued"
messages: array of SentMessage { id, type, parts }

Every message this request was accepted as, in delivery order; ALWAYS present, one element for an ordinary send. Longer when an https URL in your text was promoted to its own rich-link message: each element then has its own message.sent/message.failed lifecycle, correlated by group_id. More than 10 messages returns HTTP 422 code 1075.

id: string

Message id (msg_…).

type: string

The part type of that message’s FIRST part.

parts: optional array of unknown

The message’s canonical parts AS STORED at accept: post-degrade, post-promotion, so you see what the customer will actually receive without a second read. Typed opaque for the same one-decode-door reason TranscriptRow.parts is.

capture: optional object { collection, expects, page_id, 4 more }

The reply capture this send opened; see CaptureObject. Absent when no capture opened.

collection: string

single_shot: the whole prompt sequence goes at once. progressive is reserved.

expects: string

The reply shape we will match: choice, multi_choice, text, phone, email, or date_text (a date question at the text floor records the customer’s words verbatim; date_choice is reserved for a future variant that offers concrete slots).

page_id: string

Your form page’s own id: the key the eventual reply.values uses.

reasks: number

How many re-asks a mismatched reply earns before the collection ends abandoned. Fixed at 1.

formatint32
timeout_s: number

How long the collection stays open, in seconds. Fixed at 1800.

formatint32
group_id: optional string

ABSENT on the preview (nothing was minted); on the send 200, the fan’s correlation id: the primary message’s own id, the value form.response reports back.

options: optional array of object { index, item_id, label }

Present for the choice shapes only: what the customer can answer with, by number or label.

index: number
formatint32
item_id: string
label: string
fallbacks: optional array of Fallback { token, from, rule, 2 more }

Present ONLY when degrade ("auto" or "acknowledged") actually rewrote a part: its presence is the signal that the customer saw something other than what you composed, and an acknowledgement does not suppress it. Absent otherwise (never an empty array).

token: string

The missing capability token that forced the rewrite.

from: string

Canonical part type in.

rule: string

The fallback-tree rule applied (e.g. guide-17.5-select-le5).

to: string

Canonical part type out. For a rewritten form this is text_sequence: the whole degraded fan is one rewrite outcome, stable across how many messages it produced; which types those messages carry is on the response’s own messages[].

options: optional array of FallbackOption { index, item_id, label }

Present only when the rewrite flattened a menu.

index: number

1-based, matching the numbering the degraded copy emits.

formatint32
item_id: string

The original item id, so a customer’s “2” maps back to your routing.

label: string

ChatsTyping

Start and stop the business-side typing indicator. It is live only: never recorded, and wrong once stale.

Set the typing indicator
POST/v1/chats/{chat}/typing
ModelsExpand Collapse
TypingSetResponse = unknown

Typing succeeds with an empty JSON object; we do not wait for Apple to confirm it.

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
GET/v1/chats/{chat}/events
ModelsExpand Collapse
JournalEntry object { actor, created_at, payload, 3 more }

One entry in the chat’s event log.

actor: string

Who wrote it: customer, partner, human, system or brand.

created_at: string
formatdate-time
payload: unknown

The event’s own JSON payload; shape depends on type.

seq: number

Gapless per-chat sequence number: the events cursor.

formatint64
type: string

Event type (message_received, message_sent, owner_changed, …).

operator: optional string

WHICH human, for the events that have one: the operator name the acting request carried (attributed through our agent console). Present ONLY beside actor: "human", and only when the caller named an operator; absent everywhere else, including on every event recorded before an operator id was ever sent. So its presence is the signal that this row can be rendered as “Dana accepted this” rather than “someone accepted this”; its absence is never “no human”, only “unattributed”.

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
GET/v1/chats/{chat}/transcript
ModelsExpand Collapse
TranscriptRow object { actor, created_at, event_type, 13 more }

One transcript row, ready to render: an event-log entry rebuilt for display. It is not a JournalEntry: that shape has an open set of type values and a payload whose shape depends on it, so a client has nothing to switch on. This row leads with a closed kind and carries the message’s canonical parts and delivery state.

actor: string

Who wrote it: customer, partner, human, flow, system or brand. On a row carrying a message_id this is who COMPOSED that message: a human agent replying from a console reads human, a deterministic flow reads flow, and NOT the actor of the underlying event, which is always partner on a send because that event means we delivered it. Everywhere else it is the underlying event’s own actor.

created_at: string
formatdate-time
event_type: string

The underlying event type (message_received, owner_changed, consent_send_refused, …); render detail from it, but branch on kind.

kind: "customer_message" or "business_message" or "system"

What KIND of thing happened; switch on this. The set is CLOSED: an event type added later renders as system, never as a fourth value.

One of the following:
"customer_message"
"business_message"
"system"
seq: number

The seq this row is built from; same numbering as the events endpoint, and the transcript’s own cursor.

formatint64
attachments: optional array of object { att_id, mime_type, name, 2 more }

Files the CUSTOMER sent with this message; absent when they sent none.

att_id: optional string

The retained plaintext’s id (att_…); absent when nothing was retained.

mime_type: optional string
name: optional string

The file’s name as the customer’s device sent it.

reason: optional string

Present exactly when att_id is absent, and a CLOSED set: too_large | brand_unresolved | store_unavailable | disabled | unavailable (any failure earlier than the retention decision). Never the underlying error text, that is free-form and can carry the channel’s own temporary download URL, so it stays on /events.

size: optional number

Size in bytes.

formatint64
delivery: optional string

That message’s delivery state, read from the message row: queued | sending | sent | retry | failed | undelivered | suppressed. Two of those are terminal failures, not one: undelivered is the “resubmitting later may land” outcome and failed the one that will not. There is NO delivered or read receipt here or anywhere on this API; AMB produces neither, and sent means only that Apple accepted the send.

form_response: optional unknown

A form_response row’s FULL record, first-class: values keyed by your original page ids, item_ids, status, sources, opened_at, closed_at. Present only on event_type: "form_response" rows.

group_id: optional string

Ties a fan-out member to its group, and a form_response row to the degraded prompt messages that asked, so grouping needs no payload spelunking. Present on rows that belong to a group.

message_id: optional string

The outbound message this row is about; pass it to GET /v1/messages/{msg}.

operator: optional string

WHICH human. On a row carrying a message_id this is the operator who composed that send, where one did and where the send named them. On every other row it is the events endpoint’s operator: the human we recorded, with the same rules.

On message rows this deliberately does NOT match /events, which shows no operator for the same seq: a send is recorded by the worker that delivered it, with actor partner and no operator, so the composer is only knowable from the message row. Absence never means “no human”; it means this row cannot say which.

parts: optional array of unknown

The message’s canonical parts (the same array a send takes), absent on system rows and on a message that carried none: an invitation delivery records its invitation identity rather than parts. Typed as an opaque array rather than Part[] on purpose: the generated Go transport must never contain a second decoder for the part union (content.ParseParts is the one decode door), and a response $ref to Part would put one there.

reaction: optional object { action, correlation, kind, 7 more }

The reaction this customer text row IS, when the text is a tapback. The row keeps its text verbatim and kind stays customer_message, so a client ignoring this key sees exactly the transcript it saw before.

action: "added" or "removed"

Whether the customer APPLIED this reaction or TOOK IT AWAY. CLOSED: added or removed, and always present. Apple sends a removal as its own message, so a removal is its own transcript fact rather than the absence of one. Note what this does NOT tell you: replacing one reaction with another sends the new added and no removed beside it, so state is last-one-wins per (message, customer) and a client that appends will accumulate reactions the customer no longer has.

One of the following:
"added"
"removed"
correlation: "inferred" or "ambiguous" or "unmatched" or "unavailable"

HOW the target was reached. CLOSED: inferred (exactly one message in the recent window carried that text, and both target keys name it), ambiguous (several did; candidate_count says how many, and no winner is picked for you), unmatched (none did), unavailable (Apple named no target at all, which is what a reaction to a non-text message looks like). There is no exact, because no reaction on this wire carries an id.

One of the following:
"inferred"
"ambiguous"
"unmatched"
"unavailable"
kind: "like" or "love" or "dislike" or 5 more

What the customer did. CLOSED: like, love, dislike, laugh, emphasize, question, sticker (a sticker or Memoji), or emoji (any other character, which rides emoji).

One of the following:
"like"
"love"
"dislike"
"laugh"
"emphasize"
"question"
"sticker"
"emoji"
attachment_id: optional string

The sticker’s image, on a sticker whose image we retained: pass it to GET /v1/attachments/{att}/content. Present ONLY when kind is sticker and action is added. Absent when the image was not retained, or when more than one file arrived and the wire does not say which is the sticker: no handle is better than the wrong one. Do not assume a format. Captures show both image/png and image/heic; read the response’s own content type. A PRESENT ID IS NOT A PROMISE OF BYTES, exactly as for att_id: retained attachments are swept after their published TTL and the event is immutable, so an old reaction still names the id it was minted with and the content read answers 404. Handle that; it is the ordinary outcome for anything old enough.

candidate_count: optional number

How many messages carried that exact text, on ambiguous.

formatint32
emoji: optional string

The character the customer picked, present only when kind is emoji, where the character IS the reaction and no named one exists for it.

quoted_text: optional string

The original message’s text as Apple quoted it back: the only handle this wire offers, and what the correlation is made against. Absent when Apple named no target.

target_msg_id: optional string

The message reacted to, on inferred. Pass it to GET /v1/messages/{msg}.

target_seq: optional number

That message’s seq, on inferred: the transcript row to scroll to.

formatint64
target_side: optional "business" or "customer"

WHOSE message was reacted to, on inferred: business or customer. A customer can react to their own message, so this is not derivable from the reaction being a customer event. Unlike the correlation it is not an inference; it is the matched row’s own type.

One of the following:
"business"
"customer"
reason: optional string

The reason the event states, where it states one: the failure discriminator on a failed or suppressed send, the handoff reason on an ownership change, the refusal reason on a consent row.

reply: optional unknown

A customer’s normalized interactive selection (quick reply, list pick, time slot); absent when they sent plain text. A customer who taps a Quick Reply sends no text, so this is the row’s only content.

request_id: optional string

The requestIdentifier this message was delivered under: the exact value an interactive reply names in its own request_id, so an answer can be matched to the message it answers instead of inferred from item ids (the same card sent twice defines the same item ids, while each interactive send is delivered under a fresh identifier). Present only on a row naming a message that recorded one: interactive sends mint one, plain sends do not, and rows from before this field was served carry none.

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
GET/v1/chats/{chat}/activity
ModelsExpand Collapse
ActivityResponse object { activity, consent, consent_events, origin }

The activity timeline: the chat’s governance story as a read-time composition. origin, consent, and consent_events appear on the first page only; a resumed page (after_seq present) carries activity alone.

activity: array of object { actor, at, class, 3 more }

The governance events, seq-paged exactly as /events.

actor: string

Who did this: customer, partner, human, flow, system, or brand.

at: string
formatdate-time
class: "contact" or "consent" or "control"

Server-promised closed class; render by class for any type you do not map.

One of the following:
"contact"
"consent"
"control"
seq: number

The event log’s own seq: the same id the /events row carries.

formatint64
type: string

The event type (owner_changed, handoff_accepted, flow_entered, …).

detail: optional unknown

Per-type detail from the event’s payload. OPEN AND ADDITIVE by contract: keys may be added over time, never renamed or removed; operator, reason and the per-type ids are present whenever their event recorded them.

One of the following:
origin: optional object { kind, at, invitation }

Contact provenance. First page only.

kind: "customer_initiated" or "invitation"

customer_initiated (the customer’s first message created the chat) or invitation (business-initiated).

One of the following:
"customer_initiated"
"invitation"
at: optional string

First-contact instant for a customer-initiated chat: the chat’s own creation stamp.

formatdate-time
invitation: optional object { id, delivery, sent_at, 3 more }

The originating invitation, for kind: invitation.

id: string
delivery: string

Its delivery state.

sent_at: string

When the invitation was sent.

formatdate-time
response: optional string

The customer’s response, when one was recorded.

response_source: optional string

How the response was resolved: tap, or the literal-text fallback.

superseded_by_chat_id: optional string

Set when a later invitation superseded this conversation.

ChatsHandoff

Ownership of a chat — take it, hand it back, close it. An exclusive owner is the only sender.

Request or return a handoff
POST/v1/chats/{chat}/handoff
Accept a pending handoff
POST/v1/chats/{chat}/handoff/accept
ModelsExpand Collapse
HandoffRequestResponse object { ok }

The bare {"ok":true} acknowledgement; failures ride the error envelope instead.

ok: true
HandoffAcceptResponse object { ok }

The bare {"ok":true} acknowledgement; failures ride the error envelope instead.

ok: true