Idempotency and retries
Idempotency-Key works on some commands and is silently ignored on others, and the difference is invisible in the request.
Retrying a request that may already have run is normal — a socket closes, a
503 comes back, your process restarts mid-send. The API gives you an
Idempotency-Key header for exactly that. It deduplicates on part of the
command surface, and on the rest it is accepted, ignored, and your retry sends
a second message.
Which side you land on depends on the command type and its target, and nothing in the request or the response tells you which one you got. This page is the map.
Which commands honour the key
Every command takes one of two internal routing paths, chosen from its type and its target. The agent-turn path deduplicates on your key. The admin path mints a fresh key per call and cannot.
| Command | Target | Path | Idempotency-Key |
|---|---|---|---|
message.send | conversation | agent-turn | Honoured |
message.send | recipient | admin | Ignored |
reaction.tapback | conversation | agent-turn | Honoured |
reaction.apply | conversation (DM) | agent-turn | Honoured |
name_photo.share | conversation (DM) | agent-turn | Honoured |
group.create | new_group | admin | Ignored |
group.update | conversation (group) | admin | Ignored |
group.leave | conversation (group) | admin | Ignored |
message.send appears twice on purpose. The same command type is idempotent
or not depending on whether you addressed a conversation or a raw phone
number.
The two paths differ in one other way worth knowing: an agent-turn command has 30 seconds to be dispatched to the Mac before it expires, an admin command has five minutes. Expiry does not release the key, which is the subject of "Resending after a settlement" below.
On the admin path the Idempotency-Key header is read and silently discarded. There is no
warning, no header echoed back, no difference in the 202. Retrying a group.create creates a
second group. Retrying a recipient-target message.send sends the message twice.
Working around the admin path
You cannot make those four calls idempotent from the outside, so the mitigation is procedural:
- Wait for the settlement before deciding to retry. A
202you never saw the outcome of is not evidence the command failed. Hold the response stream open and let the command event tell you. - Record your intent under your own id before you send, then store the
returned
commandIdagainst it. If you have acommandId, the command exists — reconcile it rather than resending. - Get onto a conversation target as soon as you can. The usual pattern for
a cold outreach is one plain-text recipient send, then wait for the inbound
reply, then use its
conversationIdfor everything after. Sends and reactions into that conversation all take the agent-turn path, so only the first message carries the duplicate risk. See Addressing a conversation.
Using the header
Any non-empty string works. Send it on the command request:
curl -X POST $MAPIER_BASE_URL/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8842-confirmation" \
-d '{
"target": {
"kind": "conversation",
"conversationId": "a0000000-0000-4000-8000-000000000001"
},
"payload": { "text": "Your order is on its way." }
}'Retrying is then a matter of calling the same function again with the same key. Do not generate a new key on the retry — that is a different command, and it sends a second message.
async function sendWithRetry(conversationId: string, text: string, key: string) {
for (let attempt = 0; ; attempt++) {
try {
return await send(conversationId, text, key); // same key every time
} catch (err) {
if (attempt >= 3) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
}
}That wrapper retries every failure, which is the wrong policy for a 400 —
those fail identically forever. Handling errors shows
the same loop with the status classified before it decides to retry.
What a deduplicated call returns
A replay is not an error. It returns 202 like any other call, with the
same commandId and disposition: "adopted":
{ "commandId": "cmd-1", "status": "queued", "disposition": "queued" }{ "commandId": "cmd-1", "status": "succeeded", "disposition": "adopted" }Two things about that second response catch people out.
status is the original command's current status, not its status when it
was created. An adopted duplicate can come back queued, executing or
already succeeded, depending on how far the first attempt got. Do not treat
queued as "it has not started" or succeeded as a value you can only see
here.
A replay does not give you the settlement outcome. Even a response saying
succeeded carries no errorCode and no detail. The authoritative outcome is
still the single command event on the response stream, which is emitted once
for the original commandId and never re-emitted for the replay. If it
settled while you were disconnected, that event is gone — the stream has no
backfill — so keep the stream open rather than polling with replays.
Correlating settlements
On the agent-turn path the settlement's logicalActionId echoes the key you
sent, so you can match an event back to your own domain object without having
stored the commandId:
{
"commandId": "cmd-1",
"logicalActionId": "order-8842-confirmation",
"status": "succeeded",
"errorCode": null
}On the admin path logicalActionId is equal to commandId and tells you
nothing extra — another reason to store the commandId yourself for every
admin-path call.
Resending after a settlement
Reusing a key is what makes an in-flight retry safe, and it is exactly what you
must not do once the command has settled. The key stays bound to that
command permanently, whatever its final status. A same-key call after an
expired or failed settlement is adopted, comes back 202 carrying that
terminal status, and sends nothing.
So the two situations need opposite handling:
- You are unsure whether the request landed — no response, a
503, a restart. Resend with the same key. At worst it is adopted. - You saw a settlement and want the work done anyway — an
expiredcommand, or afailedone whose cause you have corrected. That is a new command and needs a new key.
A resend under a new key is a genuinely new command, so nothing protects you from sending twice if
the first attempt did in fact reach iMessage. Only mint a new key for a settlement you have seen
with your own eyes on the stream — never for a 202 you lost track of, and never for an
ambiguous one. See Handling errors.
Omitting the header
If you send no Idempotency-Key, the server mints a UUID for you. The command
runs normally, but the request is not retry-safe: your retry carries a
different server-minted key and is treated as a new command.
Omit it only for genuinely fire-and-forget work you would be happy to see duplicated. For anything a person will read, send a key.
Choosing a key
A random UUID generated at call time defeats the purpose — your retry produces a different one. The key should be derived from the thing you are doing, so that the same intent always produces the same string:
order-8842-confirmationticket-1043-first-replydigest-2026-08-29-a0000000-0000-4000-8000-000000000001
If you must generate the key, generate it once, persist it next to the work
item, and reuse it on every attempt including after a restart. When a
settlement forces a deliberate resend, extend the string rather than dropping
to a random UUID — order-8842-confirmation-2 is still derived from the
intent, so its own retries stay safe.
Deduplication is scoped per environment, connector and account, so a key can
never collide with another account's. That also means a key is only unique
within your own account — daily-digest reused across two different
conversations is one command, and the second send is silently adopted rather
than delivered.
Unconfirmed
The deduplication constraint is permanent, and no retention policy for settled commands is documented. Do not assume a key becomes reusable after an hour, a day or a release. Treat every key you have ever sent as burned, and make new intents produce new keys.
Related
Reconnection and delivery guarantees
What the stream does not promise, a correct parser and reconnect loop, and how to design around lost events.
Capabilities
Versioned feature flags each Mac advertises, why a command can be accepted and then fail as unsupported, and how to tell the capability failure codes apart.