Handling errors
Errors arrive in two places, and a client that only checks HTTP responses will believe every message was delivered.
There are two error surfaces on this API and they are separated by seconds.
Synchronous. The HTTP response to your request. Something about the request was wrong, or the service could not accept it. The command was never queued and nothing happened on iMessage. The body is one envelope on every route:
{
"error": {
"code": "validation_failed",
"message": "That command names nobody to send to.",
"details": [{ "path": "target.recipientExternalId", "message": "…" }]
}
}Switch on code — it is a closed set and each value maps to exactly one HTTP
status — and never parse message, which is a sentence for a person and may
change. On a 422, details is an array of { path, message, … }
entries naming the field each refusal is about. On any other refusal that has
details, it is an object whose reason says which refusal it is.
Asynchronous. A command event on the
response stream, arriving after the command
was queued, dispatched to the line and attempted. The body carries a status
and an errorCode.
A 202 means queued. A client that treats it as delivered is not handling
errors at all; it is handling the first surface and ignoring the second.
Everything about an unreachable recipient, a stale reply target or an
unsupported capability lands on that second surface.
This page is the narrative version. Error codes has the exhaustive tables — every HTTP code and every settlement code with its meaning. Read that when you need to look one up; read this when you are deciding what your client should do.
The decision tree
Work through it in this order. The first four branches are HTTP; the last is the stream.
A 4xx other than 429. Your request is wrong, and it will be wrong again.
Fix the code, do not retry, and do not put it on a retry queue — a
validation_failed or a 404 unsupported_command will fail identically
forever. Log the code and the request that produced it. One 409 is the
exception: conflict with details.reason: "idempotency_key_in_flight" means
the first request under your key is still running, so wait and send it again
with the same key — the worked client below does. Three more 4xxs are not
about the body: 401 invalid_credentials is the key
(Authentication); 403 recipient_not_allowed is a
contact you may not send to, and details.reason says why — opted_out (STOP
is honoured on every inbound message, and a send to somebody who sent one is
refused until they opt back in), blocked, paused or
outside_service_window; and 409 project_archived is a project somebody
archived in the console.
A 429. You exceeded a budget: on POST /v1/commands/{type} your
project's command ceiling for this environment, which every key of the project
spends together; on the other routes, this key's. Honour Retry-After (always
at least 1 second) and try again. Do not back off exponentially on top of it —
the header already tells you when the window reopens. The platform's own
budgets put the wait in that header alone; a 429 that carries
details.retryAfter in the body is the open-streams cap or a transport's own
limit, and means the same thing. A transport that gave no wait leaves both out,
so fall back to one second. See Rate limits.
A 503. Something between you and the line's device is unavailable, and the code
says which — and whether anything was recorded before it happened.
no_line (the project has no active line — request one from the console),
no_line_capacity (every line is full — request another; capacity_exhausted
at x-mapier-version: 2026-10-15 or later) and line_unavailable with a
reason (line_paused, line_released, relay_down, channel_not_available
— and from 2026-10-15, line_retired and transport_down in place of
line_released and relay_down) are decided before anything is recorded, so
nothing was sent. Back off exponentially and retry with the same
Idempotency-Key; the retry is a fresh attempt. A relay_down that persists
for more than a few minutes is an operational problem rather than a transient
one; the Lines page shows the line's health.
upstream_unavailable means the transport behind the line did not take the
command. On every command but message.send, retry it the same way, with the
same key: a repeat is adopted rather than run twice, on every command but a
live group.create, typing.start and message.read. A live message.send is the exception. The platform
recorded it against your key before it asked the line, and settled it when the
line did not take it, so the key is spent: a retry under it sends nothing.
It answers 409 conflict — details.reason: "idempotency_key_failed" when the
line refused the send, and "idempotency_key_unresolved" when no usable answer
came back — with the first attempt's messageId beside it. To send the
message, use a new key. When details.reason on the 503 is timeout,
network or status_5xx, the line may have taken the first attempt before it
failed to answer, so look at the conversation before you resend: a new key is
a new message.
service_busy is the one 503 that is not about the line: the platform had no
database connection free for your request in time. It did nothing, and it
says how long to wait — details.retryAfter seconds, the same number as the
Retry-After header. Wait that long and send the same request; no exponential
backoff is needed on top of it.
A 500. A persistence or unhandled failure inside the service. Retry
once, with the same key, then escalate. Repeated 500s are not something a
client can fix.
A 202 followed by a settlement that is not succeeded. The command
reached the line and did not do what you asked, or could not be confirmed to
have done it. What to do depends on the status and the errorCode — see the
three buckets below.
Retrying a 500 or a 503 with the same Idempotency-Key is safe in both environments on every
command but group.create, typing.start and message.read on a live line, which ignore the
key: a key that has been used adopts the original command rather than queuing a second one, or
answers 409 conflict when there is nothing to adopt — idempotency_key_in_flight means wait and
retry with the same key. The one 503 it does not cover is a live message.send's
upstream_unavailable: that send was recorded and settled, the key is spent, and a retry under it
sends nothing — sending needs a new key. A request that sent no key is not covered either —
the platform minted one for that request alone, and a retry without the header is a new command.
See Idempotency and retries.
Settlement codes, by what you should do
errorCode names the cause on a failed event, and also on an ambiguous,
no_effect, cancelled or expired one — ambiguous and no_effect being
the two outcomes where the transport took the command and could not prove what
it changed. It is null when the command succeeded, and the field is nullable
everywhere, so branch on status first — then on failureClass, the closed
set beside it — and read errorCode third. needs_reconcile is the state a send's message is
left in, never a frame's status.
The codes sort into three groups, and the third is the one that needs a policy
rather than a reflex.
Fix the request, then resend
The command was well-formed enough to be accepted but wrong by the time it ran.
Nothing was sent, so a corrected resend is safe — under a new
Idempotency-Key, since the payload has changed.
| Code | The fix |
|---|---|
invalid_target | The conversation or address could not be found by the line. Re-derive it from a recent inbound message. |
stale_target | A reaction target aged out of history, or was sent one to one into a conversation the transport named with a group's id, whose history does not hold it: react to a newer one, by its ids. A group addressed right after its membership changed: wait for its next message, then send again under a new Idempotency-Key. |
payload_conflict | The payload contradicts the recorded command. Use a fresh idempotency key for changed content. |
unsupported_capability · capability_not_negotiated | The line cannot do this at all. Fall back to a simpler form — see Capabilities. |
capability_constraint_violation | The operation is supported but this variant is not. Change the variant. |
invalid_session_capabilities | A fault in the line's session, not your request. Back off and escalate if it repeats. |
command_claim_invalid | The change does not apply as the line last saw the target — for a group.update, adding someone already in the group or removing someone who is not. Fix the change rather than retrying it. |
Retry — the effect did not happen
The command failed before or during dispatch without reaching iMessage. A
resend produces one message, not two — but send it under a new
Idempotency-Key. The old key stays bound to the settled command, so reusing
it sends nothing: it is adopted — a 202 naming the old command — or, when no
transport took the first send, refused as 409 conflict with
details.reason: "idempotency_key_failed". See
Idempotency and retries.
| Code | Meaning for you |
|---|---|
gateway_rejected | iMessage refused it. Resend; if it repeats, the content or target is the problem. |
send_returned_not_ok | The send call failed outright. |
preparation_dispatch_exhausted | The command could not be handed to the line before the platform gave up. |
stale_fence | The line was interrupted before it ran this command. |
expired · command_expired_before_execution | The deadline passed before the line took it — 30 seconds on the conversation path, five minutes on the recipient path. The second usually accompanies an expired status. |
outbound_paused | Sending was paused. Resume-and-resend, but not in a tight loop. |
Investigate — the effect may have happened
The line started the operation and could not confirm the result. These arrive
on an ambiguous frame more often than a failed one, and a send they end
leaves its message needs_reconcile.
| Code | Meaning for you |
|---|---|
outbound_echo_unconfirmed | iMessage accepted the send; nothing confirmed it went out. |
connector_crash_after_effect_start | The line's device stopped after the operation began. |
gateway_timeout · gateway_response_lost | No answer, or the answer was lost in transit. |
tapback_unverified · poll_vote_unverified | The reaction or vote could not be verified afterwards. |
group_content_unverified · group_update_unverified · group_leave_unverified | The group operation could not be verified. |
chat_background_unverified | The background change could not be read back. Ask someone in the conversation. |
moderation_unverified | An edit or an unsend could not be verified. |
journal_corrupt | The line lost its record of the operation, so the outcome is unknown. |
Do not blindly resend anything in this bucket. The command could not prove the outcome, so "unverified" is as close to certainty as the settlement gets — and a resend after a send that actually succeeded puts the same message in front of the reader twice. Route these to a human, or to a reconciliation pass that looks for the next inbound message in the conversation, rather than to your retry queue.
operator_cancelled is the odd one out: it means a person cancelled the
command. It is a terminal outcome, not a failure to recover from.
A worked client
The HTTP half. It classifies the response, honours Retry-After, allows one
500 retry, waits out a 409 idempotency_key_in_flight, and
caps every loop at five retries. It reuses the same idempotency key across
every attempt so a duplicate is adopted rather than executed — on every
command but a live group.create, typing.start and message.read, the caveat from the callout
above. It does not retry a message.send's upstream_unavailable: on a live
line that key is spent, so the error goes back to the caller, whose resend
needs a new key.
const BASE = "https://api.mapier.ai";
export class MapierError extends Error {
constructor(
readonly status: number,
readonly code: string
) {
super(`${status} ${code}`);
}
}
export async function command(type: string, body: unknown, idempotencyKey: string) {
let serverErrorRetries = 0;
for (let attempt = 0; ; attempt++) {
const res = await fetch(`${BASE}/v1/commands/${type}`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(body),
});
if (res.status === 202) return res.json(); // queued, NOT delivered
const answer = (await res.json().catch(() => ({}))) as {
error?: { code?: string; details?: { retryAfter?: number; reason?: string } };
};
const code = answer.error?.code ?? "unknown";
// A 429 and a 503 service_busy both say how long to wait. Wait that long,
// then send the same request.
if ((res.status === 429 || code === "service_busy") && attempt < 5) {
const wait = Number(res.headers.get("retry-after") ?? answer.error?.details?.retryAfter ?? 1);
await sleep(wait * 1000);
continue;
}
// A live message.send the line did not take was recorded against this key
// and settled: the key is spent, and a retry under it sends nothing. Hand
// it back — a resend needs a new key.
const spent = type === "message.send" && code === "upstream_unavailable";
// A 409 whose reason is idempotency_key_in_flight: the first attempt under
// this key is still running. Wait, then send it again under the same key.
// The other 409 reasons say there is nothing to adopt; they go back.
const inFlight =
res.status === 409 && answer.error?.details?.reason === "idempotency_key_in_flight";
// Any other 503 is transient; 500 gets exactly one retry before we give up.
const transient =
!spent &&
(res.status === 503 || inFlight || (res.status === 500 && serverErrorRetries++ < 1));
if (transient && attempt < 5) {
await sleep(Math.min(2 ** attempt * 500, 8_000));
continue;
}
throw new MapierError(res.status, code);
}
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));The asynchronous half. Only terminal statuses are ever emitted, and exactly one
event arrives per command, so a switch on status covers the whole space:
const FIX_AND_RESEND = new Set([
"invalid_target",
"stale_target",
"payload_conflict",
"unsupported_capability",
"capability_not_negotiated",
"capability_constraint_violation",
"invalid_session_capabilities",
"command_claim_invalid",
]);
const RETRY_SAFE = new Set([
"gateway_rejected",
"send_returned_not_ok",
"preparation_dispatch_exhausted",
"stale_fence",
"expired",
"command_expired_before_execution",
"outbound_paused",
]);
// For brevity this sketch branches on errorCode; failureClass is the coarser
// switch beside it.
type CommandEvent = {
commandId: string;
logicalActionId: string;
status: string;
failureClass: string | null;
errorCode: string | null;
};
export function onCommandEvent(event: CommandEvent) {
switch (event.status) {
case "succeeded":
// Also covers the "nothing needed doing" cases — a reaction that was
// already there, a contact card already shared. The command did what you
// asked; the stream does not distinguish the two.
return markDispatched(event.commandId);
case "cancelled":
case "expired":
return markAbandoned(event.commandId, event.errorCode);
case "ambiguous":
case "no_effect":
// The transport took it and could not prove what it changed: it may or
// may not have been delivered. Never auto-resend.
return escalate(event.commandId, event.errorCode);
case "failed": {
const code = event.errorCode ?? "";
// Resends go out under a NEW idempotency key: the old one is bound to
// this settled command and would be adopted rather than executed.
if (RETRY_SAFE.has(code)) return scheduleResend(event.commandId);
if (FIX_AND_RESEND.has(code)) return repairAndResend(event.commandId, code);
// Unrecognised code: treat as a generic failure, and never as a success.
return escalate(event.commandId, code);
}
default:
return escalate(event.commandId, event.errorCode);
}
}Note the last two branches. Both the errorCode list and, in principle, the
status list can grow, and the safe default in each case is "we do not know that
this was delivered".
The settlement errorCode list is not a closed set. New codes can appear without a version
change. Match on the codes you handle and fall through to a generic failure for everything else —
a client that throws on an unknown code will stop processing the stream the first time one
appears.