Idempotency and retries
Send the same Idempotency-Key again and the first command is adopted rather than repeated — on every command in test, and on every live command but group.create, typing.start and message.read. Leave the header off and every attempt is a new command.
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: a key that has already been used
adopts the command it named rather than queuing a second one — or, where
there is nothing to adopt, is refused with 409 conflict, and never runs the
command twice. That holds on every message.send — recipient or conversation,
live or test — on every command in test, and on every command on a live line
but three.
The three exceptions: group.create, typing.start and message.read on a live line
group.create on a live line accepts the header and ignores it: the line mints a fresh
command for every POST, so a retried group.create is a second group. Wait for the settlement
before deciding it failed, and correlate on the commandId from the 202, which is the only handle
it has. In test the sandbox honours the key on group.create too, so a rehearsal cannot show you
this. typing.start and message.read on a live line ignore the key as well, for a gentler
reason: each is a fire-and-forget signal with nothing to adopt, so a repeat is a second signal — a
typing signal coalesced only when it lands within five seconds of the first, and a read never.
Two facts follow, and the rest of this page is their consequences:
- A key is never absent. Send one and it is yours; send none and the platform mints a UUID for that request — so a retry of a request that sent no key is a new command, because the retry carries a new minted key.
- A key belongs to one project and one environment. Two projects can use the same string; a test key and a live key on the same project can too.
Using the header
A key is any string of up to 200 characters: at least one, none of them a
control character (U+0000–U+001F or U+007F), and no whitespace at either end.
One outside that — an empty or all-whitespace value included — is
422 validation_failed at Idempotency-Key before anything is written or
sent. It is never trimmed, because a trimmed key is a different key. Send it on
the command request:
curl -X POST https://api.mapier.ai/v1/commands/message.send \
-H "Authorization: Bearer $MAPIER_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-8842-confirmation" \
-d '{
"target": { "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 or
a 422 — those fail identically forever. Handling errors
shows the same loop with the status classified before it decides to retry.
Never put a live group.create behind it: the key does not protect that one.
What a deduplicated call returns
A replay is not an error. It returns 202 like any other call, with the
same commandId as the first send and disposition: "adopted":
{ "commandId": "0192…", "status": "queued", "disposition": "queued" }{ "commandId": "0192…", "status": "queued", "disposition": "adopted" }status is always queued on a 202, an adopted one included — it says the
command was accepted, never how it ended. An HTTP adopt does not give you the
settlement. The authoritative outcome is the command frame on the response
stream, emitted once per command. If it settled while you were disconnected,
reconnecting with Last-Event-ID replays it — see
Resuming.
When a key is refused
A replay is refused rather than adopted when there is nothing to adopt, always
with 409 conflict and a details.reason that says which of four cases it is.
None of them sends anything. The reason is an open string — a transport may
supply its own — so fall back to the code for one you do not recognise.
details.reason | What happened to the first request | What to do |
|---|---|---|
idempotency_key_in_flight | It is still running: sent again before the first request with that key was answered. | Wait, then send again with the same key. |
idempotency_key_failed | A message.send no transport took: it was refused, and nothing was sent. details.messageId is the message it wrote, details.errorCode the code it settled with, and details.failureClass that code's class. | Send it again under a new key. |
idempotency_key_unresolved | A message.send whose outcome is unknown — its message is needs_reconcile, or its attempt died before any answer and has not been reconciled yet, when details.errorCode is still null. details.messageId names it. | Check the conversation first: repeating it could deliver the message twice. |
idempotency_key_reused | The key already named a different command. The sandbox compares every command's type, target and payload with what the key first named; a live line does for some commands and transports, and may adopt the second as the first for others. | Send the original body under that key, or use a new key — see Choosing a key. |
One exception to idempotency_key_unresolved: on an iMessage line, a
same-key retry of a conversation-path
send whose first attempt died before any answer, made before it is reconciled,
is passed to the line again instead. The line adopts it if the first attempt
reached it, and you get a 202, or the line's refusal of the re-send.
Correlating settlements
The settlement's logicalActionId carries the key you sent — or the one minted
for a request that sent none — so you can match an event back to your own
domain object without having stored the commandId:
{
"commandId": "0192…",
"logicalActionId": "order-8842-confirmation",
"status": "succeeded",
"errorCode": null,
"conversationId": "a0000000-…",
"failureClass": null
}Those are the command frame's published fields; a frame may carry more, so
read the ones you need and ignore the rest. Two cases carry something else in
logicalActionId. A live group.create carries a copy of its commandId,
because the line mints its own identity for it. A message.send on the
recipient path whose answer was lost — its message left needs_reconcile —
carries the transport's own value rather than your key. So correlate by
commandId, the one field that holds on every command.
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 you have decided the work should happen again. The key
stays bound to its command whatever the final status, and a same-key call after
a failed settlement sends nothing: it is adopted and answers 202 with the
original commandId — or, for a message.send no transport ever took, it is
refused with 409 conflict and idempotency_key_failed. (A live
group.create is the exception in the other direction: nothing is ever
adopted, so nothing stops a resend either.)
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, or refused with a409that says why (above). The one503this does not cover is a livemessage.send'supstream_unavailable: the platform recorded that send and settled it when the line did not take it, so the key is spent — a same-key resend sends nothing and answers409 conflict, withidempotency_key_failedor, when no usable answer came back,idempotency_key_unresolved. Treat it as a settlement you have seen — see Handling errors. - You saw a settlement and want the work done anyway — a
failedcommand whose cause you have corrected, anexpiredone. 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 —
on the stream, on a webhook, or on the console's Commands page — never for a 202 you lost track
of, and never for an ambiguous or needs_reconcile one. See Handling errors.
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-09-14-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.
A key names one command. A second message.send under a key an earlier
message.send used is never a second message: it is adopted as that send,
whatever its target or text, or refused with one of the 409s above when the
first has nothing to adopt — daily-digest reused across two conversations
delivers at most once, in both environments. Any other reuse is answered
differently by the two environments.
In test, a request whose type, target or payload differs from what the key
first named is the 409 idempotency_key_reused above. On a live line it
depends on the type and the transport: the same 409, or the line may adopt
the second as the earlier command — 202, disposition: "adopted", and
nothing done. Never let one key reach two commands.
A chat_background.set and a
chat_background.remove are the pair where
this bites. On a live line the transport reads the two as one verb, so one key
reused across a set and a remove is answered with the first command and changes
nothing more: a remove sent under the key its set used removes nothing. On a
test line the same reuse is 409 conflict with idempotency_key_reused. Give
each its own key.
Keys in test mode
In test, a key is bound for the life of the sandbox world's current
generation. Reset world on the Sandbox page starts a new generation
and every key used in the previous one works again — so a test fixture can
send first-hello on every run and get a fresh queued each time, which is
the one place a key is ever released. Nothing releases a live key; treat every
live key you have sent as bound.