Skip to content
Linq

Messages

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 message's status
GET/v1/messages/{msg}
Get a message's timeline
GET/v1/messages/{msg}/timeline
ModelsExpand Collapse
MessageRetrieveResponse object { id, chat_id, created_at, 8 more }

A send’s delivery outcome.

id: string

Message id (msg_…).

chat_id: string
created_at: string
formatdate-time
state: string

Delivery state: queued until a terminal outcome (sent, failed, suppressed, …).

updated_at: string
formatdate-time
fallbacks: optional array of Fallback { token, from, rule, 2 more }

Capability rewrites applied at accept under degrade ("auto" or "acknowledged"); present only when something was rewritten (the same array the send’s 200 carried).

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
group_id: optional string

Correlation id present ONLY when this message was one of several a single request fanned out into (URL promotion): it is the first message’s id, carried on every member, the same value the send response and the message.sent/message.failed webhooks report. Use it to identify which fan-out group a message belongs to; it is a correlation key, not a member list. Absent on an ordinary single-message send.

last_error: optional string

Gateway error of the last attempt; rides only on a gateway-verdict failure.

last_status: optional number

Gateway HTTP status of the last attempt; rides only on a gateway-verdict failure.

formatint32
parts: optional array of unknown

The message’s canonical parts AS STORED at accept; post-degrade, post-promotion, i.e. what the customer’s device was (or will be) sent, not necessarily what you composed (fallbacks says when they differ). Typed as an opaque array rather than Part[] for the same one-decode-door reason TranscriptRow.parts is. Absent only when the stored content cannot be rendered canonically.

reason: optional string

Failure-only: whether to resubmit (permanent, undelivered, auth_error, suppressed, consent_revoked, …): the same value the message.failed webhook carried.

MessageTimelineResponse object { chat_id, entries, message_id, 2 more }

The row-level story of one outbound send.

chat_id: string
entries: array of object { at, kind, attempt, 13 more }

Every entry we hold, oldest first.

at: string

When the fact was recorded: accept time on accepted, when the delivery row last moved on delivery (it is mutable), the journal row’s time on a terminal event, and the outbox row’s creation on webhook.

formatdate-time
kind: string

What this entry is. Documented-open; skip values you do not know.

attempt: optional number

The row’s attempt counter. Present-and-zero is a real answer (“accepted, never attempted”), so read absence and zero differently.

formatint32
attempt_404: optional number

delivery only: attempts that answered 404.

formatint32
attempt_auth: optional number

delivery only: attempts that failed authentication.

formatint32
endpoint_id: optional string

webhook only: YOUR endpoint id (wh_…) the row is bound for.

event_type: optional string

webhook only: the partner event kind the row carries (message.sent | message.failed).

last_error: optional string

Failure-only: the gateway’s (or your endpoint’s) error text on a FAILED row.

last_status: optional number

Failure-only, exactly as on GET /v1/messages/{msg}: the gateway’s (or your endpoint’s) HTTP status on a FAILED row, and absent otherwise: a success does not publish its status here.

formatint32
message_id: optional string

webhook only: the message the row’s own payload names: the finer attribution key on the window-spanning row above.

next_attempt_at: optional string

When the next attempt is due. Rides only while the row is NON-terminal, so a settled entry never advertises a retry that will not happen.

formatdate-time
reason: optional string

message_failed only: the failure discriminator (permanent, undelivered, auth_error, suppressed, …), the same value that webhook carried.

seq: optional number

Journal entries only: the event’s seq: the same watermark GET /v1/chats/{chat}/events pages on.

formatint64
seq_from: optional number

webhook only: the row’s journal-seq window (inclusive). Every per-message emit produces a single-seq window today, so a webhook entry names exactly one message; the schema permits a wider one, and such a row appears on EVERY covered message’s timeline.

formatint64
seq_to: optional number
formatint64
state: optional string

Current state of the mutable row behind this entry: the delivery state (queued | sending | retry | sent | failed | undelivered | suppressed | cancelled) on delivery, the outbox state (pending | sending | delivered | failed) on webhook.

message_id: string

The message this timeline is about (msg_…).

retention: object { delivery_from, webhook_from }

The two retention horizons this response was computed against: the database clock’s now() minus each sweep’s own window. They differ, which is the whole reason truncated exists.

delivery_from: string

Delivery rows that settled before this may have been swept (90 days).

formatdate-time
webhook_from: string

Webhook outbox rows created before this may have been swept (30 days).

formatdate-time
truncated: array of string

Each section whose retention horizon this message has outlived; "delivery", "webhook", or both. Always present; an EMPTY array means an empty section is a fact about the world rather than a sweep.

It exists because the horizons differ: a 45-day-old send that delivered and whose webhook fired shows a delivery entry and zero webhook entries, which would otherwise be byte-identical to “the webhook was never created” on the one endpoint built to answer that question.

“MAY have been swept”, never “was”: the 30-day webhook sweep takes delivered rows ONLY, so a pending or failed outbox row survives past its horizon and truncation can hide only a SUCCESS.