Send your first message
Open the response stream, send a message over HTTP, and watch the outcome arrive.
Preview API — the base URL below is a development host, and the production hostname is not assigned yet.
There is nothing to install. The Mapier API is plain HTTP with a bearer
credential, so curl and fetch are the whole toolchain, and there is no
client library to keep in step with the service.
What takes five minutes is not the request — it is understanding that the
request only tells you half the story. A send returns 202 immediately, and
the actual outcome arrives seconds later on a separate connection. So you open
that connection first.
Get a credential
Access is not self-serve today: contact Mapier to have an account and a Mac connector provisioned for you. You will be given a single key.
Export it as MAPIER_API_KEY — every example below reads it from the
environment. Authentication covers what the
credential identifies and how to keep it out of your logs.
Open the response stream first
Open a second terminal and start the stream before you send anything. It is a
long-lived Server-Sent Events connection, and -N stops curl buffering it.
curl -N $MAPIER_BASE_URL/v1/response_stream \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Accept: text/event-stream"Within a moment you get the first bytes, and then a heartbeat every 25 seconds:
: connected
: pingBoth lines begin with a colon, which makes them SSE comments rather than events. They tell you the socket is alive and nothing more.
This ordering is the whole point of the page. A 202 from a send means the
command was written down for your Mac to pick up — not that a message was
delivered. The stream is the only place the real outcome appears: there are no
webhooks, and no endpoint to poll a command's status after the fact. It also
has no replay or backfill, so anything emitted while you were not connected is
gone for good.
If this returns 404 no_connector, your account has no Mac attached yet and nothing you send will
ever settle. That is a provisioning problem, not a code one.
Send a message
Back in the first terminal, send plain text to a handle you control — your own
phone is the easiest target. The verb goes in the path, and the body is exactly
two keys: target (who) and payload (what).
curl -X POST $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target": { "kind": "recipient", "recipientExternalId": "+14155550100" },
"payload": { "text": "Hello from the Mapier API." }
}'The response comes back straight away:
{
"commandId": "cmd-1",
"status": "queued",
"disposition": "queued"
}Keep commandId. It is how you recognise this command's outcome on the stream.
A recipient target addresses a person by handle and starts a new thread. It
is deliberately limited: plain text only, and not retry-safe — sending the
same request twice sends two messages. Both limits disappear once you have a
conversation id, which is step 5.
Watch it settle
Switch to the stream terminal. A few seconds after the 202, the settlement
appears:
event: command
data: {"commandId":"cmd-1","logicalActionId":"cmd-1","status":"succeeded","errorCode":null}There is exactly one of these per command, and only terminal statuses are ever
emitted — the internal claimed, executing and accepted steps are
suppressed, so you will never see a command progress. It queues, then some time
later it lands on one of succeeded, failed, no_effect, ambiguous,
cancelled or expired.
Had it failed instead, the same event would differ only in its last two fields:
event: command
data: {"commandId":"cmd-1","logicalActionId":"cmd-1","status":"failed","errorCode":"invalid_target"}errorCode is a string when something went wrong and null otherwise. The set
of values is open and will grow, so branch on status first and treat an
errorCode you do not recognise as a generic failure rather than an
unreachable case.
Succeeded is not delivered
succeeded means iMessage accepted the send. It is not a delivery receipt — nothing in the API
tells you the message rendered on the other device, and there are no read receipts or typing
events either.
Reply into the conversation
When the recipient replies, a second kind of event arrives on the same stream:
event: message
data: {"conversationId":"a0000000-0000-4000-8000-000000000001","from":"+14155550100","text":"got it","externalId":"guid-1","replyToExternalId":null,"occurredAt":"2026-08-29T09:24:31.000Z","attachmentIds":[]}That conversationId is the thing you were waiting for. It is a Mapier UUID
naming the thread — not the iMessage GUID, which is the separate externalId
field on each message. You cannot construct a conversation id and there is no
endpoint that looks one up, so an inbound message is how you get one. (The
settlement of a successful group.create can also
carry the id of the group it created, but that is the only other source.)
Store it against your own records, and address the thread with a conversation
target from now on. That switches the command onto a different routing path,
which is where rich content and retry safety live:
curl -X POST $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: welcome-a0000000-0000-4000-8000-000000000001" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000001"
},
"payload": {
"kind": "rich_text",
"text": "Thanks — this one can carry an effect.",
"effect": "confetti"
}
}'Two things changed. The payload gained a kind, unlocking effects, replies,
links and polls. And the Idempotency-Key header is now honoured: repeat that
exact request and you get the same commandId back with
"disposition": "adopted" instead of a second message.
Note that your own send does not echo back on the stream. Only inbound messages
produce a message event.
What to know before you scale
The four things most likely to bite you between a working curl and a working
integration:
- The stream is at-most-once, with no resume. No event ids, no
Last-Event-ID, no cursor, no backfill, and no ordering guarantee. The socket drops periodically even when everything is healthy, so reconnect unconditionally in a loop and accept the gap. Opening several streams does not help: every connection receives every event, so it is a broadcast, not a work queue. - Idempotency only covers some commands. Conversation sends, tapbacks,
reaction.applyand contact-card sharing honourIdempotency-Key. Recipient sends and all group commands silently ignore it and mint a fresh key per call, so a retry there really does send twice. See Idempotency. - Payloads are not validated at the API boundary. A
202confirms the envelope was well-formed and nothing else. A malformed payload is accepted, then settlesfailedon the stream — which is another reason to be reading it before you send. - Rate limits are per 60-second rolling window: 60 commands, 30 stream
connections, 120 attachment downloads, 30 handle checks. A
429carries both aRetry-Afterheader and aretryAfterfield. Note the stream limit counts connections, so a reconnect loop that retries without backoff will exhaust it.