Skip to content
Linq

API Reference

API Overview

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

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

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

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

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

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

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

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

Attachments

Read a stored attachment’s metadata and bytes: files delivered inbound and retained, and files stored for your brand. A part references one by att_id; to send a file you host, use the source_url part form.

Upload an attachment
POST/v1/attachments
Get an attachment's metadata
GET/v1/attachments/{att}
Download an attachment's bytes
GET/v1/attachments/{att}/content

A customer who sends STOP is suppressed across every category, including transactional. Sends return 403 consent_required until they opt back in.

Two things lift it, and they lift different amounts. If the customer messages you again, that alone clears the suppression; they wrote to you, so you may answer. If they send SUBSCRIBE, that clears the suppression and re-grants marketing, but only marketing. No keyword can re-grant account_notification; that one needs its own disclosure.

Lifting it doesn’t rescue sends already in flight. A message you queued before the customer opted out stays suppressed and finishes as message.failed with reason consent_revoked.

Attest a consent grant
POST/v1/consent

Invitations

An invitation is the only way to initiate contact. It sends a templated card to a phone number, not a free-text message.

Setup, once: create an invitation template: the brand name printed on the card, and optionally your logo, uploaded in the same request:

POST /v1/invitation_templates
Content-Type: multipart/form-data

name = "Acme Rides"
logo = <PNG file, optional>

The response carries the template’s id (invt_…). A template with a logo renders the card with your image; one without renders the plain card: the platform picks the right card for you. If creation is refused 403 invitation_card_not_granted, your brand is not yet approved for invitations of that shape: approval is per-brand and raised with us, not self-service.

Then, per invitation:

POST /v1/invitations
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

{"to": "+15551234567", "template_id": "invt_a4f2c718d0", "reference_id": "order-4417"}

Two conditions apply:

  • A recorded opt-in for that number, posted to POST /v1/consent with subject_kind: "tel". Without one the send returns 403 consent_required.
  • One of your template ids. GET /v1/invitation_templates lists them.

If the customer accepts, a chat is created and you message it normally. The outcome arrives as invitation.accepted, invitation.declined, or invitation.opted_out.

POST /v1/invitation_batches submits up to 10,000 invitations at once, all from one template: its name and logo are resolved when you submit and frozen into the run, so editing the template mid-run never changes what queued recipients receive. We check every recipient when you submit, so you learn which ones will fail before we send anything. Track the run by its id rather than per message, and POST /v1/invitation_batches/{batch}/cancel stops any recipient we haven’t tried yet.


Send an invitation
POST/v1/invitations
Get an invitation
GET/v1/invitations/{inv}

Invitation Templates

An invitation is the only way to initiate contact. It sends a templated card to a phone number, not a free-text message.

Setup, once: create an invitation template: the brand name printed on the card, and optionally your logo, uploaded in the same request:

POST /v1/invitation_templates
Content-Type: multipart/form-data

name = "Acme Rides"
logo = <PNG file, optional>

The response carries the template’s id (invt_…). A template with a logo renders the card with your image; one without renders the plain card: the platform picks the right card for you. If creation is refused 403 invitation_card_not_granted, your brand is not yet approved for invitations of that shape: approval is per-brand and raised with us, not self-service.

Then, per invitation:

POST /v1/invitations
Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7

{"to": "+15551234567", "template_id": "invt_a4f2c718d0", "reference_id": "order-4417"}

Two conditions apply:

  • A recorded opt-in for that number, posted to POST /v1/consent with subject_kind: "tel". Without one the send returns 403 consent_required.
  • One of your template ids. GET /v1/invitation_templates lists them.

If the customer accepts, a chat is created and you message it normally. The outcome arrives as invitation.accepted, invitation.declined, or invitation.opted_out.

POST /v1/invitation_batches submits up to 10,000 invitations at once, all from one template: its name and logo are resolved when you submit and frozen into the run, so editing the template mid-run never changes what queued recipients receive. We check every recipient when you submit, so you learn which ones will fail before we send anything. Track the run by its id rather than per message, and POST /v1/invitation_batches/{batch}/cancel stops any recipient we haven’t tried yet.


List invitation templates
GET/v1/invitation_templates
Create an invitation template
POST/v1/invitation_templates
Get an invitation template
GET/v1/invitation_templates/{invt}
Download an invitation template's logo
GET/v1/invitation_templates/{invt}/logo
Update an invitation template
PUT/v1/invitation_templates/{invt}
Delete an invitation template
DELETE/v1/invitation_templates/{invt}

Invitation Batches

Invitations in bulk, as an asynchronous batch with its own status and per-row outcome.

Submit a batch of invitations
POST/v1/invitation_batches
Get a batch run's progress
GET/v1/invitation_batches/{batch}
List a batch run's recipients
GET/v1/invitation_batches/{batch}/recipients
Cancel a batch run
POST/v1/invitation_batches/{batch}/cancel

Contract

The API serving its own contract: this OpenAPI document, and the part authority schemas.

Me

Confirm which brand your key belongs to, and list the part types this API supports.

Meta

Confirm which brand your key belongs to, and list the part types this API supports.

Get the part registry
GET/v1/meta/parts

Webhook Endpoints

Register the URLs your brand receives events on, choose which events each one gets, and rotate its signing secret — all from the API.

List webhook endpoints
GET/v1/webhook_endpoints
Create a webhook endpoint
POST/v1/webhook_endpoints
Get a webhook endpoint
GET/v1/webhook_endpoints/{wh}
Update a webhook endpoint
PATCH/v1/webhook_endpoints/{wh}
Delete a webhook endpoint
DELETE/v1/webhook_endpoints/{wh}
Rotate a webhook endpoint's secret
POST/v1/webhook_endpoints/{wh}/rotate_secret
Replay past events to a webhook endpoint
POST/v1/webhook_endpoints/{wh}/replay

Webhooks

Events

Your brand’s event log. Every event is recorded when it happens, whether or not an endpoint was listening for it, so you can read what you missed before you had one.

List events
GET/v1/events
Get event
GET/v1/events/{evt}

Channels

The brand’s linked channel identities — which Apple Messages for Business business this key’s brand sends and receives as.

List linked channels
GET/v1/channels
Link a channel
POST/v1/channels

Settings

The brand’s channel settings: the policy documents and contact a brand edits itself, and the groups managed with Linq.

Get channel settings
GET/v1/settings

SettingsIntegrations

The brand’s channel settings: the policy documents and contact a brand edits itself, and the groups managed with Linq.

Set the Apple Pay integration
PUT/v1/settings/integrations/apple_pay
Set the OAuth integration
PUT/v1/settings/integrations/oauth
Set the iMessage-app integration
PUT/v1/settings/integrations/app_extensions
Clear an integration group
DELETE/v1/settings/integrations/{group}