mapier docs

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 targetrecipient target
Addressesan existing thread, by Mapier UUIDa person, by handle
Rich contenteffects, replies, links, pollsplain text only
Idempotentyes — Idempotency-Key is honouredno — the key is accepted and ignored
Dispatch window30 seconds5 minutes
Use whenyou have already seen a message in ityou 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, and errorCode names 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.

On this page