Core concepts
The vocabulary of the Mapier API — keys, 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.
Key
What a key identifies — a project and an environment, and the lines it can send from — is in Authentication.
Handle
A handle is an address a person is reachable at. There are two kinds: a
phone number, whose canonical form is E.164 with a leading +, and an email
address, canonically lowercase. On an iMessage line the API normalises what you
send — formatting is stripped, a bare ten-digit number is read as a US one, and
an email is lowercased — so 4155550100 and Person@Example.com are read as
+14155550100 and person@example.com. An address it cannot read as either
is 422 validation_failed.
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. One exception is worth committing to memory: a
message.send that takes a live iMessage line's recipient path — a
new thread, or one the contact has not written into — sends to phone numbers
only, and answers 422 validation_failed for an email address that is valid
everywhere else: at the target field you wrote it at
(target.recipientExternalId, or whichever of its spellings you used), or at
target.conversationId when it is the peer of a thread you named that takes the
recipient path. On the conversation path a message reaches the contact at
whatever address the conversation is with, an email address included. In test
an email address sends.
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 with one contact, or a group chat. 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 on an inbound message event from the response
stream, and on the command frame of a command that acted on a thread. So to
open a thread with someone you send to their handle, and take the conversation
id from that send's command frame or from their reply.
A conversation's id is stable for the life of the thread. A group chat is a
conversation too: a message someone writes in a group arrives under the group's
own conversationId, with isGroup: true, and a send to that id reaches every
member. group.create's settlement carries a null
conversationId, because it opens the thread the transport then reports — see
Groups.
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 a tapback points at — the message you are reacting to.
The same frame also carries Mapier's own messageId: a reply, an edit, an
unsend and a receipts lookup name a message by that.
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 two keys, both objects:
{ "target": {}, "payload": {} }target says who the command acts on; payload says what it does. Each
defaults to {}, so a command whose whole meaning is its type needs no body at
all. 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 request was
well-formed and the command was recorded for your line to collect. It says
nothing about whether the line was reachable or whether Apple accepted the
send. How much of the payload was checked first depends on the command: a
message.send key the line cannot carry, or a send with no text, is a
422 validation_failed and nothing is queued, while other commands' payloads
are checked where they run — so a malformed one can still be a 202 that fails
on the stream. See
What is checked before anything is queued.
The target you choose decides who a send is for, not how it travels. On a live iMessage line the thread chooses the path: a thread the contact has written into, or a group chat the transport has reported, takes the conversation path, which carries effects, subjects, replies, stickers and rich links, and every other thread takes the recipient path — plain text only, capped at 16,384 bytes, to a phone number — whichever target named it.
conversation target | recipient target | |
|---|---|---|
| Addresses | an existing thread, by Mapier UUID | a person — by E.164 phone number only on a live line |
| Rich content | once the contact has written into the thread, or in a group chat | not until the contact answers — plain text only |
| Idempotent | yes — a repeated key is never delivered twice | yes — a repeated key is never delivered twice |
| 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 frame on the response
stream, emitted once per command. Correlate it by commandId.
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 transport settled the command as done. Formessage.sendthat is the transport's settlement of the send, not a delivery receipt.failed— it did not happen;errorCodeis the transport's reason andfailureClasssays what kind of failure it was.ambiguousandno_effect— the transport took the command and could not prove what it changed. Treat these as unknown, not as failure; retrying blindly can duplicate a real effect. A send that ends this way leaves its messageneeds_reconcile, and a same-key retry of it is adopted and still saysqueued. (Amessage.sendthat no transport ever took has nocommandframe, and a same-key retry of it is a409 conflict:idempotency_key_failedwhen it was refused, andidempotency_key_unresolvedwhen its outcome is unknown, because repeating it could deliver it twice. See Idempotency.)cancelled— stopped before it ran.expired— the dispatch window closed before the command was taken.
errorCode is a string on failure and null otherwise, and its set of values
is explicitly open. Branch on status first and then on failureClass, a
closed set — target, content, capability, line, rate_limited,
not_run, transport, unknown — and read errorCode for the detail.
Delivered, read and "not delivered" do not change a command's status. They
are their own frames, receipt and delivery_failed, on a stream that asks for
them — see Receipts and delivery verdicts — and
GET /v1/messages/{messageId}/receipts asks about one message
now. There are no typing events.
The response stream
The response stream is a single long-lived Server-Sent Events connection per project and environment.
It carries a command frame for every settlement and a message frame for
every inbound message; on request, a receipt frame for each delivery or read
and a delivery_failed frame for each "not delivered" verdict
(?events=receipts,delivery_failures), and location, moderation and poll
frames (?events=locations, ?events=moderation, ?events=polls); and an
error frame when it is ending because your
key or project stopped working. 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. Ignore an event: name you do not recognise.
Every frame carries an id:. Reconnect with Last-Event-ID set to the last
one you handled and the stream replays what followed it — at most 500 per
batch, as far back as 90 days — so a drop costs a reconnect and nothing more.
See Reconnecting.
The socket drops periodically even when everything is healthy, so a client
reconnects unconditionally, with its last id. Opening several streams is a
broadcast rather than a work queue: every connection receives every frame its
filters admit, and duplicate processing is on you to prevent — apply frames by
id.
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. The
status in that response is queued, as on every 202, even when the original
command has long since settled; the reply is not a settlement, and you still
learn the outcome from the stream. Where there is nothing to adopt — a
message.send still in flight, or one no transport ever took — the repeat is
refused with 409 conflict instead, and details.reason says which (see
Idempotency).
The honest caveat is that three commands are not covered. On a live line
typing.start and message.read ignore the key, and group.create accepts it and ignores it, minting a fresh command per
call, so a retry there creates a second group — and its settlement's
logicalActionId is a copy of commandId.
Every other command that runs honours the key: every message.send, on
either target, is recorded against its key by the platform itself, and the rest
by the line or, in test, the sandbox. Omitting the header is a trap everywhere —
the server mints a key for that one request, so a retry is a new command.
Capabilities
A capability is something a line can do: whether it can send to groups,
which message kinds it accepts, whether tapbacks support removal. Most are
checked before anything is queued — see Capabilities
for the few a request can meet after its 202.
Asking for something the line 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.
What you can read is
GET /v1/whoami's lines[].capabilities: the command types each
line will carry, in the API's own names, which is the list to branch on — and a
command it lists can still settle failed on a line that cannot run it.
Ask if you need to know more 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 tapback 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.
The remedy is to react or reply while the message is recent, and to take the ids of a newer message when an old one has gone stale.
Rate limits
Five independent limits apply, each over a fixed one-minute window. Commands
are budgeted per project and environment — 600 commands a minute by
default, spent together by every key of the project, and your project may
carry a ceiling of its own, which GET /v1/whoami reports as
limits.commandsPerMinute. The other four are per key: 10 stream
connects, 120 attachment downloads, 120 history reads 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, always at least
one second, which says when the window reopens. Honour it rather than
approximating, and build a backoff that reacts to 429 rather than pacing
against the published numbers alone. A higher command ceiling is something
Mapier sets for a project on request — see Rate limits.