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 { "error": "<code>" }.
Asynchronous. A command event on the
response stream, arriving after the command
was queued, dispatched to the Mac 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 a malformed payload, 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
target_and_payload_required or an invalid_conversationId will fail
identically forever. Log the code and the request that produced it. The one
4xx that is not about the body is 401 unauthenticated, which is the
credential — see Authentication.
A 429. You exceeded a per-endpoint budget. Honour Retry-After (also
present as retryAfter in the body, 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. See Rate limits.
A 503. Something between you and the Mac is temporarily unavailable — no
connector holds a live lease, the connector is unreachable, sending is paused.
These clear on their own. Back off exponentially and retry with the same
Idempotency-Key. If no_live_connector persists for more than a few minutes,
it is an operational problem rather than a transient one.
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 Mac 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 is only safe from double-sending if the command takes the idempotent
routing path. group.create, group.update, group.leave and recipient-target message.send
ignore your key, so a retry there can duplicate the effect. See Idempotency and
retries.
Settlement codes, by what you should do
errorCode names the cause on a failed event, and often on an ambiguous
or a no_effect one. It is null when the command succeeded, and the field is
nullable everywhere, so branch on status first and read errorCode second.
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 did not resolve on the Mac. Re-derive it from a recent inbound message. |
stale_target | A reply or reaction target aged out of history, or a group's membership changed. Wait for a new message, then use its ids. |
payload_conflict | The payload contradicts the recorded command. Use a fresh idempotency key for changed content. |
unsupported_capability · capability_not_negotiated | The host 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 connector-session fault, not your request. Back off and escalate if it repeats. |
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 returns that command's terminal status and sends nothing. 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 | Dispatch attempts ran out before execution. |
command_claim_invalid | The claim was no longer valid when execution started. |
stale_fence | A newer connector session superseded this one. |
expired | The dispatch deadline passed — 30 seconds on the agent-turn path, five minutes on the admin path. |
outbound_paused | Sending was paused. Resume-and-resend, but not in a tight loop. |
Investigate — the effect may have happened
The Mac started the operation and could not confirm the result. These settle
ambiguous more often than failed.
| Code | Meaning for you |
|---|---|
accepted_echo_unconfirmed | iMessage accepted the send; no echo confirmed it landed. |
connector_crash_after_effect_start | The connector died after the operation began. |
gateway_timeout · gateway_response_lost | No answer, or the answer was lost in transit. |
reaction_unverified · 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. |
moderation_unverified | The moderation action could not be verified. |
journal_corrupt | The durability journal was unreadable. |
Do not blindly resend anything in this bucket. There is no delivery receipt on this API, so "unverified" is as close to certainty as you get — 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, caps every loop at five attempts, and reuses the same idempotency
key across every attempt so a duplicate is adopted rather than executed — on
the agent-turn path, which is the caveat from the callout above:
const BASE = process.env.MAPIER_BASE_URL!;
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 detail = await res.json().catch(() => ({}) as { error?: string; retryAfter?: number });
const code = detail.error ?? "unknown";
if (res.status === 429 && attempt < 5) {
const wait = Number(res.headers.get("retry-after") ?? detail.retryAfter ?? 1);
await sleep(wait * 1000);
continue;
}
// 503 is transient; 500 gets exactly one retry before we give up.
const transient = res.status === 503 || (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",
]);
const RETRY_SAFE = new Set([
"gateway_rejected",
"send_returned_not_ok",
"preparation_dispatch_exhausted",
"command_claim_invalid",
"stale_fence",
"expired",
"outbound_paused",
]);
type CommandEvent = {
commandId: string;
logicalActionId: string;
status: string;
errorCode: string | null;
};
export function onCommandEvent(event: CommandEvent) {
switch (event.status) {
case "succeeded":
return markDispatched(event.commandId);
case "no_effect":
// Nothing was wrong; there was nothing to do. An already-applied
// reaction, or a contact card that was already shared.
return markNoOp(event.commandId);
case "cancelled":
case "expired":
return markAbandoned(event.commandId, event.errorCode);
case "ambiguous":
// 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".
Unconfirmed
The settlement errorCode list is not a closed set. It is assembled from the service's own
source rather than a published registry, and 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.