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" \
-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.
The thread chooses the path, and the path decides what the text carries
On an iMessage line a text goes one of two ways. Into a thread the transport has confirmed is a
conversation with that one contact — which it does the first time the contact writes into it —
or has reported as a group chat, where it reaches every member, every message takes the
conversation path: effect and subject ride beside text, a reply
named at target.replyToMessageId is quoted, the transport honours Idempotency-Key, and the
line must take the command within 30 seconds or it settles expired. Into any other thread — a
recipient send to somebody who has not written, or a thread whose contact has not answered yet —
the text takes the recipient path, which carries text alone: effect and subject are
refused at payload.<key> and a reply at target.replyToMessageId, before anything is queued.
That path lets a command wait five minutes, refuses a text that is blank or over 16,384 bytes
— a quarter of the 65,536 below — with 422 validation_failed and invalid_text as the entry's
reason, once the message is written, and does not honour Idempotency-Key itself: the platform still records your key, so
a retry is never posted twice, but a send whose answer was lost leaves its message
needs_reconcile rather than being sent again. Either path sends to an E.164 phone number, never
an email address: an email is a 422 at the target field you wrote it at
(target.recipientExternalId, or whichever of its spellings you used), or at
target.conversationId for a thread whose peer is one. A payload with no text string at all is
422 validation_failed at payload.text.
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" }]
}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" \
-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.