Core concepts
The vocabulary of the Mapier API — accounts, conversations, commands, settlements and the stream that ties them together.
Most of the friction in a first integration with this API is vocabulary rather than HTTP. There are two id systems that look alike and are not interchangeable, an acknowledgement that is not a confirmation, and a delivery model that is weaker than it first appears.
This page defines those nouns once so every other guide can use them without re-explaining. Nothing here needs code.
Account and connector
An account is who you are. A connector is the Mac that account owns — a real machine, running Messages, signed in to an Apple ID, doing the actual sending. One credential maps to one account, and one account maps to one connector.
That chain is why no request has a "from" parameter. The API never asks which number you are sending as, because your credential has already decided: there is exactly one Mac behind any key. Sending as a different number means a different account with a different credential, not a different field.
It also means every identifier is account-scoped. A conversation id or an
attachment id belonging to another account returns 404 — the same 404 you
get for an id that never existed. Existence never leaks across accounts, so a
404 is not evidence that something is gone.
Handle
A handle is an address a person is reachable at. There are exactly two
accepted forms: a phone number in E.164 with a leading +, or an email
address in lowercase, NFC-normalised. +14155550100 and person@example.com
are valid; 4155550100 and Person@Example.com are rejected outright.
Handles appear wherever the API names a person rather than a thread: as the
recipientExternalId of a recipient target, in the participant list when
creating a group, as the address you test for reachability, and as the from
field on an inbound message.
A well-formed handle is not proof of reachability. Whether an address is on iMessage at all is a separate synchronous question, answered by handle check, and worth asking before you send to an address a human typed.
Conversation
A conversation is a thread — a one-to-one DM or a group. Mapier identifies
it with a UUID, the conversationId, and this is the single most common source
of confusion: it is not the iMessage GUID. Two id systems coexist, and they
are never interchangeable.
You cannot construct a conversation id and there is no endpoint that lists or
looks one up. It reaches you two ways: on an inbound message event from the
response stream, and on the settlement of a successful
group.create, which carries the id of the group it
created when it can resolve one in time. Everywhere else the practical
consequence holds — to open a thread with someone you send to their handle,
wait for the reply, and take the conversation id from that.
A DM's id is stable for the life of the thread. A group's id is resolved against its membership, so changing who is in the group invalidates it — see bounded history below.
Message and external id
A message is one bubble. On the stream it carries an externalId, which is
the iMessage GUID that Apple assigned. That is the other half of the two-id
system: conversations are Mapier UUIDs, messages are Apple GUIDs.
externalId is what you point at whenever a command needs to name a specific
message — the message you are replying to, or the message you are reacting to.
It is also how you correlate an inbound message with anything you have stored
about the thread.
One detail worth internalising: occurredAt on a message is the iMessage
send time, not the time Mapier received or forwarded it. Under a backlog the
two differ. And only inbound messages appear on the stream — your own sends
never echo back.
Command
A command is the unit of work, and it is the only way to change anything. The verb lives in the URL path, and the body is a fixed envelope of exactly two keys, both objects:
{ "target": {}, "payload": {} }target says who the command acts on; payload says what it does. The whole
body is capped at 65,536 bytes. A successful POST returns 202 with a
commandId, a status, and a disposition of either queued for a newly
recorded command or adopted for one the server recognised as a repeat.
A 202 means queued, not delivered. It confirms the envelope was
well-formed and the command was recorded for your Mac to collect. It says
nothing about whether the payload made sense, whether the connector was
reachable, or whether Apple accepted the send. Payloads are not validated at
the API boundary at all — a malformed one is accepted with 202 and then fails
asynchronously.
Which target you choose is not a cosmetic decision. It selects an internal routing path, and the two paths have materially different guarantees:
conversation target | recipient target | |
|---|---|---|
| Addresses | an existing thread, by Mapier UUID | a person, by handle |
| Rich content | effects, replies, links, polls | plain text only |
| Idempotent | yes — Idempotency-Key is honoured | no — the key is accepted and ignored |
| Dispatch window | 30 seconds | 5 minutes |
| Use when | you have already seen a message in it | you are opening a thread for the first time |
A third shape, new_group, exists solely to create a group and carries a list
of participant handles instead of an id. Addressing a
conversation covers all three in detail.
Settlement
A settlement is the terminal outcome of a command. It never arrives in the
HTTP response — it reaches you later, as a command event on the response
stream, exactly once per command.
Internally a command moves through several states, but they are suppressed: you never watch a command progress. It is queued, and then some time later it lands on one of six terminal statuses.
succeeded— the effect happened.failed— it did not, anderrorCodenames the reason.no_effect— the command was valid and correctly did nothing, such as removing a reaction that was not there.ambiguous— the outcome could not be established either way. Treat it as unknown, not as failure; retrying blindly can duplicate a real effect.cancelled— stopped before it ran.expired— the dispatch window closed before the Mac took the command.
errorCode is a string on failure and null otherwise, and its set of values
is explicitly open. Branch on status first, and treat an unfamiliar
errorCode as a generic failure rather than an unreachable case.
Note also what succeeded does not mean. It says iMessage accepted the send,
not that the message rendered on the recipient's device. There are no delivery
receipts, no read receipts and no typing events anywhere in the API.
The response stream
The response stream is a single long-lived Server-Sent Events connection, and it is the only push channel that exists. There are no webhooks.
It carries two event types. command events are settlements; message events
are inbound messages. It also writes SSE comments — one on connect, then a
heartbeat every 25 seconds — which are liveness signals, not events, and should
be ignored by your parser.
Delivery is at-most-once, and the stream is deliberately thin: no event ids, so
no Last-Event-ID; no replay, no backfill, no cursor; no ordering guarantee.
The socket drops periodically even when everything is healthy, so a client must
reconnect unconditionally and accept that whatever was emitted during the gap is
gone. Opening several streams does not fix this — every connection receives
every event, so it behaves as a broadcast rather than a work queue, and
duplicate processing is on you to prevent.
Idempotency and logical actions
A logical action is the thing you meant to do, as distinct from the HTTP
request that expressed it. You name one with the Idempotency-Key header, and
the settlement echoes it back as logicalActionId — which is how you match an
outcome to your own record without having to store the commandId first.
Repeating a key the server has already seen does not create a second command.
You get 202 with the same commandId and a disposition of adopted. Note
that the status in that response is the original command's current status,
which may already be terminal; the reply is not a settlement, and you still
learn the outcome from the stream.
The honest caveat is that this covers only some commands. Conversation sends,
tapbacks, reaction.apply and contact-card sharing honour the key. Recipient
sends and every group command ignore it and mint a fresh key per call, so a
retry there sends twice. Omitting the header has the same effect everywhere —
the server mints one, and the request is not retry-safe.
Capabilities
A capability is a versioned feature flag that a Mac advertises when it connects: whether it can send to groups, which message kinds it accepts, whether tapbacks support removal, whether reactions accept custom emoji.
Asking for something the connector does not advertise fails late rather than
early. The POST still returns 202; the command settles failed with a
capability-related errorCode some seconds later.
There is no discovery endpoint. A caller cannot enumerate what its own connector supports, which is a genuine gap in the API today — contact Mapier if you need to know before building on a specific feature.
Bounded history
Message ids are resolved against a bounded window of recent history, not the
full archive. Point a reply or a reaction at a message that has fallen outside
that window and the command settles stale_target. The window's extent is not
published, so treat any external id you have held for a long time as possibly
stale.
Group conversation ids go stale in the same way for a different reason. Adding
or removing a participant means the id no longer matches the membership it was
resolved against, and commands issued against the old id settle stale_target
too. The remedy is identical in both cases: wait for a new message in the
thread, which carries a current conversation id, and use that.
Rate limits
Four independent limits apply, each over a rolling 60-second window: 60 commands, 30 response-stream connections, 120 attachment downloads, and 30 handle checks. The stream limit counts connections, not events, which makes a reconnect loop without backoff the easiest way to hit it.
Exceeding a limit returns 429 with a Retry-After header and a matching
retryAfter in the body, always at least one second. Both are worth honouring
rather than approximating.
Unconfirmed
Limits are currently counted per client IP and per server process rather than per account, so your
effective headroom can vary with your egress address and with which process serves the request.
Build a backoff that reacts to 429 instead of pacing against the published numbers.