Messages

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:

202 Accepted
{ "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 of bold, italic, underline and strikethrough;
  • effect — one of bounce, stretch, squish, scaleRipple, big, bloom, somersault, nod, shake, jitter, small and explode, an animation of those characters (the whole bubble's effect is effect on the payload);
  • mention — the address of the person the one-to-one conversation is with; the range covers the name shown for them in text, 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.

an inbound message event
{
  "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:

SettlementWhat happened
succeededThe transport settled the send — not Apple's "Delivered".
failedCarries an errorCode. stale_target for a reply the line no longer holds, invalid_target for a bad address, and others.
ambiguousThe send may or may not have gone out; its message is needs_reconcile. Do not blind-retry.
no_effectThe transport took the command and could not prove it changed anything. Do not blind-retry.
expiredThe 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.

On this page