Text and rich text
Plain sends and the UTF-8 byte limit — and the effects, subject lines and threaded replies a confirmed conversation carries.
Most of what you send is text: a bare { text } object, into any thread, live
or test. Once a contact has written to the line, their conversation also
carries the rich half — an effect, a subject and a quoted reply — as keys
beside the same text, with no kind to set.
Plain text
The smallest useful payload is one key. No kind, no other fields.
curl -X POST https://api.mapier.ai/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-4821-ready" \
-H "x-mapier-version: 2026-10-15" \
-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 line has sent 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.
On an iMessage line, the thread chooses the path, and the path decides what the text carries
A one-to-one conversation the transport has confirmed (it does so when the contact first writes
in) or a group chat takes the conversation path, and any other thread the recipient path.
Both answer a missing text with a 422 at payload.text.
| Conversation path | Recipient path | |
|---|---|---|
| Sends to | The thread's address, email too | E.164 numbers only |
effect, subject or a reply | Carried, the reply quoted | Refused before queueing |
Idempotency-Key | Honoured | Not honoured (below) |
| Line may wait | 30 seconds, then expired | Five minutes |
text limit | 65,536 bytes | 16,384 bytes |
On the recipient path, 422 validation_failed refuses an email address at the field you wrote it
at (target.recipientExternalId or its spellings, or target.conversationId for an email peer),
and a blank or oversized text once written, as invalid_text. A send whose answer was lost is left
needs_reconcile, never posted twice.
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. A conversation-target envelope costs a little over a
hundred bytes, so the real ceiling on text is that much below 65,536.
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 checked before anything else. A body over 65,536 bytes is refused with 413 payload_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.
Effects and subject lines
On a confirmed conversation the text takes optional companions, as keys
beside it. There is no kind to set and no separate payload shape.
Prop
Type
{
"text": "Your table is ready.",
"subject": "Order 4821",
"effect": "gentle"
}Use the subject for a short label — a ticket number, an order reference — rather than for the first sentence of your message. Which effects exist and who actually sees them is the effects page.
Formatting ranges
textFormatting styles, animates or mentions parts of the text: 1 to 256
ranges, each {start, length} in UTF-16 code units of text, inside it and
cutting no character in two, with at least one of:
styles— any ofbold,italic,underlineandstrikethrough;effect— one ofbounce,stretch,squish,scaleRipple,big,bloom,somersault,nod,shake,jitter,smallandexplode, an animation of those characters (the whole bubble's effect iseffecton the payload);mention— the address of the person the one-to-one conversation is with; the range covers the name shown for them intext, never their address. Any other mention, and any mention in a group chat, is refused.
{
"text": "Your table is ready.",
"textFormatting": [{ "start": 14, "length": 5, "styles": ["bold"], "effect": "jitter" }]
}Each of the twelve, drawn on an iPhone. Pick one to play it:
A mention's range covers the name as you wrote it in text; its value is the
contact's address:
{
"text": "Thanks, Sam!",
"textFormatting": [{ "start": 8, "length": 3, "mention": "+14155550100" }]
}A range that breaks a rule is 422 validation_failed at that range
(payload.textFormatting[i] or one of its keys). A list that is not 1 to 256
ranges, formatting on anything but a text, and formatting into a thread that is
not confirmed are each a 422 at payload.textFormatting, before anything is
written. A line that cannot draw mentions is sent nothing: the command settles
failed with capability_not_negotiated.
A key beyond these is refused by name: an invented formatting field is a 422 validation_failed at payload.<key>, rather than accepted and sent as plain text.
Threaded replies
A reply is named beside the payload, never in it: target.replyToMessageId,
Mapier's id for the message you are answering — the messageId on the
message frame that delivered it. On a confirmed conversation the line draws
your text threaded under that message. It quotes a message the contact sent,
or one of yours whose command frame carried an externalId.
{
"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": [],
"attachments": [],
"messageId": "0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22"
}Take messageId from that and name it at the target:
curl -X POST https://api.mapier.ai/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: reply-to-0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22" \
-H "x-mapier-version: 2026-10-15" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000001",
"replyToMessageId": "0192a7c4-6a02-7e11-8c4d-2b9f7e3a5d22"
},
"payload": { "text": "The 7pm one is table 4, by the window." }
}'Deriving the idempotency key from the message you are replying to is a habit worth forming: replay the same inbound event twice and the second send is adopted rather than duplicated.
A reply is refused before anything is written, with 422 validation_failed at
target.replyToMessageId, when it names a message in another conversation,
one that carries no iMessage id — a message the contact did not send and whose
command frame carried no externalId — or any message at all on the
recipient path. A refused reply is never sent unquoted instead.
Reply targets go stale
Before sending, the line checks that the message is still in the recent history it keeps for that
conversation. One 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 line checks at send time rather than before the API answers, a stale reply costs you a full round trip through the stream before you find out.
How far back the window reaches
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.
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 | The transport settled the send — not Apple's "Delivered". |
failed | Carries an errorCode. stale_target for a reply the line no longer holds, invalid_target for a bad address, and others. |
ambiguous | The send may or may not have gone out; its message is needs_reconcile. Do not blind-retry. |
no_effect | The transport took the command and could not prove it changed anything. Do not blind-retry. |
expired | The command's deadline passed before it was dispatched — 30 seconds on the conversation path, five minutes on the recipient path. |
Delivered, read and "not delivered" are the stream's receipt and
delivery_failed frames, on request — see
Receipts and delivery verdicts. Neither changes
command status. Treat an errorCode you do not recognise as a generic
failure — the list is not closed.