mapier docs

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.

CommandTargetPathIdempotency-Key
message.sendconversationagent-turnHonoured
message.sendrecipientadminIgnored
reaction.tapbackconversationagent-turnHonoured
reaction.applyconversation (DM)agent-turnHonoured
name_photo.shareconversation (DM)agent-turnHonoured
group.createnew_groupadminIgnored
group.updateconversation (group)adminIgnored
group.leaveconversation (group)adminIgnored

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 202 you 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 commandId against it. If you have a commandId, 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 conversationId for 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":

First call
{ "commandId": "cmd-1", "status": "queued", "disposition": "queued" }
Same key, replayed
{ "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:

event: command
{
  "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 expired command, or a failed one 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-confirmation
  • ticket-1043-first-reply
  • digest-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.

On this page