Text and rich text
Plain sends, the UTF-8 byte limit, threaded replies and subject lines.
Most of what you send is text. There are two payload shapes for it: a bare
{ text } object, and a rich_text object that adds effects, threaded replies
and a subject line.
Plain text
The smallest useful payload is one key. No kind, no other fields.
curl -X POST $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-ready" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000001"
},
"payload": { "text": "Your table is ready — we will hold it for 15 minutes." }
}'Either way the API answers the same shape, and answers it before the Mac has seen anything:
{ "commandId": "cmd-1", "status": "queued", "disposition": "queued" }Nothing about the message has been delivered yet. Hold onto commandId, or
onto the Idempotency-Key you chose, and match it against the command event
that arrives on the response stream.
A recipient target takes plain text only
When target.kind is recipient, the server reads payload.text and
discards every other key — kind, effect, subject and
replyToMessageExternalId all vanish silently, and the message arrives as
plain text. If the payload has no text string at all, the call fails
immediately with 400 recipient_send_requires_recipientExternalId_and_text.
Everything else on this page needs a conversation target.
The limit is 65,536 bytes, not characters
text must be between 1 and 65,536 bytes of UTF-8. That is a byte budget, and
different scripts spend it at very different rates: ASCII costs one byte per
character, most accented Latin and Cyrillic cost two, CJK costs three, and a
single emoji costs four — more once skin-tone modifiers, variation selectors or
zero-width joiners are involved. A family emoji can run past twenty bytes on
its own.
The whole request body is capped at the same 65,536 bytes, and the body is
always bigger than the string inside it — the target object, the key names and
the quotes all count. So the real ceiling on text is 65,536 bytes minus the
envelope, a little over a hundred bytes for a conversation-target send.
Measure the serialised body, not String.length:
const encoder = new TextEncoder();
const MAX_BODY_BYTES = 65_536;
function bodyFits(body: unknown): boolean {
return encoder.encode(JSON.stringify(body)).length <= MAX_BODY_BYTES;
}In practice you will rarely come close — a 64 KB iMessage is not a good message. The limit matters when you are relaying content you did not write, such as a model's output or a customer's pasted document.
Size is the one thing the boundary does check. A body over 65,536 bytes is rejected with 400 body_too_large before anything is queued — you get a synchronous error here rather than a 202
and a later failure on the stream. Split long content into several messages instead of trimming it
to fit.
rich_text — replies, subjects and effects
Add kind: "rich_text" and the same text field gains three optional
companions.
Prop
Type
All three are optional, and rich_text with none of them behaves exactly like
a plain send. There is no reason to reach for it until you need one.
Threaded replies
replyToMessageExternalId takes an iMessage GUID — specifically the
externalId field of a message event you already received on the stream.
That is the same identifier space as replyToExternalId on inbound events, so
you can thread your reply under the exact message a customer sent.
{
"conversationId": "a0000000-0000-4000-8000-000000000001",
"from": "+14155550100",
"text": "which one is the 7pm booking?",
"externalId": "guid-1",
"replyToExternalId": null,
"occurredAt": "2026-08-29T18:04:11Z",
"attachmentIds": []
}Take externalId from that and hand it straight back:
curl -X POST $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-to-guid-1" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000001"
},
"payload": {
"kind": "rich_text",
"text": "The 7pm one is table 4, by the window.",
"replyToMessageExternalId": "guid-1"
}
}'Deriving the idempotency key from the GUID you are replying to is a habit worth forming: replay the same inbound event twice and the second send is adopted rather than duplicated.
Reply targets go stale
Before sending, the connector checks that the GUID is present in the recent history it keeps for
that conversation. A GUID that has aged out of that window settles stale_target — a 202 first,
then a failure on the stream. Reply promptly, and treat stale_target as "send it as a normal
message instead", not as an error to retry.
Because the check happens on the Mac at send time rather than at the API boundary, a stale GUID costs you a full round trip through the stream before you find out.
Unconfirmed
How far back that history window reaches is not documented. Do not build a scheduler that replies
to a message hours later and expects the thread to hold; fall back to an unthreaded send when
stale_target comes back.
Subject lines
subject is 1 to 1024 characters and maps to the iMessage subject field. On
clients configured to show it, the subject renders above the body; on clients
that are not, it is folded in with the message. Use it for a short label — a
ticket number, an order reference — rather than for the first sentence of your
message.
{
"kind": "rich_text",
"subject": "Order 4821",
"text": "Your table is ready."
}What can come back
A send that the API accepts returns 202. What arrives afterwards on the
stream is the part that tells you whether it worked:
| Settlement | What happened |
|---|---|
succeeded | iMessage accepted the send. Not a delivery receipt — nothing confirms it appeared on the recipient's device. |
failed | Carries an errorCode. stale_target for a dead reply GUID, invalid_target for a bad address, and others. |
ambiguous | The send may or may not have gone out. Do not blind-retry; check the thread. |
expired | The command's deadline passed before it was dispatched — 30 seconds on the conversation path. |
There are no delivery or read receipts anywhere in the API, so succeeded is
the strongest signal you get. Treat an errorCode you do not recognise as a
generic failure — the list is not closed.