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