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.

CodeThe fix
invalid_targetThe conversation or address could not be found by the line. Re-derive it from a recent inbound message.
stale_targetA 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_conflictThe payload contradicts the recorded command. Use a fresh idempotency key for changed content.
unsupported_capability · capability_not_negotiatedThe line cannot do this at all. Fall back to a simpler form — see Capabilities.
capability_constraint_violationThe operation is supported but this variant is not. Change the variant.
invalid_session_capabilitiesA fault in the line's session, not your request. Back off and escalate if it repeats.
command_claim_invalidThe 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.

CodeMeaning for you
gateway_rejectediMessage refused it. Resend; if it repeats, the content or target is the problem.
send_returned_not_okThe send call failed outright.
preparation_dispatch_exhaustedThe command could not be handed to the line before the platform gave up.
stale_fenceThe line was interrupted before it ran this command.
expired · command_expired_before_executionThe 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_pausedSending 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.

CodeMeaning for you
outbound_echo_unconfirmediMessage accepted the send; nothing confirmed it went out.
connector_crash_after_effect_startThe line's device stopped after the operation began.
gateway_timeout · gateway_response_lostNo answer, or the answer was lost in transit.
tapback_unverified · poll_vote_unverifiedThe reaction or vote could not be verified afterwards.
group_content_unverified · group_update_unverified · group_leave_unverifiedThe group operation could not be verified.
chat_background_unverifiedThe background change could not be read back. Ask someone in the conversation.
moderation_unverifiedAn edit or an unsend could not be verified.
journal_corruptThe 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.

On this page