mapier docs

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.

CodeThe fix
invalid_targetThe conversation or address did not resolve on the Mac. Re-derive it from a recent inbound message.
stale_targetA reply or reaction target aged out of history, or a group's membership changed. Wait for a new message, then use its ids.
payload_conflictThe payload contradicts the recorded command. Use a fresh idempotency key for changed content.
unsupported_capability · capability_not_negotiatedThe host 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 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.

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_exhaustedDispatch attempts ran out before execution.
command_claim_invalidThe claim was no longer valid when execution started.
stale_fenceA newer connector session superseded this one.
expiredThe dispatch deadline passed — 30 seconds on the agent-turn path, five minutes on the admin path.
outbound_pausedSending 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.

CodeMeaning for you
accepted_echo_unconfirmediMessage accepted the send; no echo confirmed it landed.
connector_crash_after_effect_startThe connector died after the operation began.
gateway_timeout · gateway_response_lostNo answer, or the answer was lost in transit.
reaction_unverified · 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.
moderation_unverifiedThe moderation action could not be verified.
journal_corruptThe 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.

On this page