Skip to content
Linq

Transcript

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.