mapier docs
Response stream

Command settlement events

What each terminal status means, and how to correlate a settlement with the command you sent.

Every command you send settles exactly once, and that settlement is written to the response stream as a single event: command frame. This is the only place the outcome is reported — the 202 you got back from POST /v1/commands/:type told you the command was queued, and nothing more.

event: command
data: {"commandId":"cmd-1","logicalActionId":"order-2481","status":"succeeded","errorCode":null}

The frame

Prop

Type

Exactly one event per command

Internally a command moves through queued, claimed, executing and accepted before it settles. None of those appear on the stream. Only the six terminal statuses are emitted, and a command emits one event when it reaches one.

That makes the stream a settlement feed rather than a progress feed. You cannot watch a command work its way through the system, and you should not build a progress UI that expects intermediate frames — they will never arrive.

The six terminal statuses

The whole point of reading the stream is branching correctly here. Two of them mean the work is done, three mean it is not, and one means you do not know — and that last distinction is the one clients get wrong.

succeeded

The command took effect. For a send, that means iMessage accepted the message. errorCode is null.

Do: mark the work done and move on.

succeeded is not a delivery receipt. It means iMessage took the message from the Mac connector without complaint — not that it reached the recipient's device, not that it was displayed, and not that anyone read it. The API has no delivery, read or typing signals of any kind. If your product needs proof a human saw something, the only evidence available is a reply arriving on the stream as a message event.

failed

The command definitively did not happen. Nothing was sent, applied or changed.

Do: read errorCode to decide whether a retry can possibly help. Some codes describe a transient condition — outbound_paused, for example. Others describe your request and will fail identically forever, such as invalid_target or capability_not_negotiated. Retrying the second kind spends rate-limit budget to reach the same answer.

Remember that payloads are not validated at the API boundary, so a malformed payload is a 202 followed by a failed here. If a command you believe is well-formed fails immediately every time, the payload is the first place to look.

ambiguous

The command may or may not have taken effect. Certainty was lost somewhere between the connector and iMessage — the response was never confirmed, or the connector went away after the work began. Codes such as accepted_echo_unconfirmed, gateway_response_lost and gateway_timeout describe this shape of outcome.

Do not blindly resend on ambiguous

Resending an ambiguous command is how you send the same message twice. The effect may already have happened, and you cannot look: there is no endpoint that reads a conversation's history.

If the command was on the agent-turn path and you sent an Idempotency-Key, retrying with the same key is safe: the replay is adopted rather than executed again. If it was on the admin path — a recipient-target send, or any group.* command — the key is ignored and a retry sends a second time. There, the correct move is usually to surface the uncertainty to a human rather than guess. See Idempotency.

no_effect

The command was valid, ran, and found the world already in the state it was asking for. This is normally a success, not a problem.

Concrete cases you will actually hit:

  • reaction.tapback with already_reacted — that reaction was already on the message.
  • reaction.tapback with remove set and not_reacted — there was nothing to remove.
  • name_photo.share with not_offered — usually because your contact card has already been shared with that conversation.

Do: treat it as done. Do not retry; the retry will settle no_effect too.

cancelled

The command was stopped before it took effect.

There is no cancel endpoint on /v1, so this never comes from your own request path — it originates from outside your integration, and operator_cancelled is among the codes that can accompany it.

Do: treat it as not done, and do not automatically retry. Something deliberately stopped this command, and an automatic retry is a fight with whatever that was.

expired

The command was never dispatched inside its deadline. The deadline depends on the routing path: 30 seconds on the agent-turn path, five minutes on the admin path. The usual cause is that the Mac connector was offline or badly backed up for that whole window.

Do: treat it as not done. This one is genuinely worth retrying — but check the connector is back first, or every retry expires the same way.

At a glance

StatusDid it happen?Safe to retry?
succeededYesNo need
no_effectAlready trueNo need
failedNoOnly if errorCode says the cause was transient
expiredNoYes, once the connector is healthy
cancelledNoNot automatically
ambiguousUnknownOnly with the same Idempotency-Key, on the agent-turn path

The logicalActionId trap

logicalActionId looks like it exists to carry your own identifier through to the settlement, and on some commands it does.

logicalActionId is only your key on one path

It echoes your Idempotency-Key for commands on the agent-turn path: message.send with a conversation target, reaction.tapback, reaction.apply and name_photo.share.

For everything on the admin path — message.send with a recipient target, group.create, group.update, group.leave — the server mints a fresh key per call and logicalActionId is a copy of commandId. A client that correlates on it will match settlements for half its commands and silently drop the rest.

Correlate on commandId. It is present on every settlement, unique, and identical to the id the 202 handed you.

Correlating settlements with your own records

The pattern that works is a map from commandId to whatever you were doing, populated when the 202 comes back and drained when the settlement arrives. The part most implementations forget is the sweep: the stream is at-most-once, so an entry can sit there forever waiting for an event that was lost.

type CommandEvent = {
  commandId: string;
  logicalActionId: string;
  status: string;
  errorCode: string | null;
  conversationId?: string;
};

type Pending = {
  what: string; // your own description, for logs and for the timeout path
  queuedAt: number;
  finish: (result: TrackResult) => void;
};

// Resolving either way — rather than rejecting on timeout — means a caller that
// fires and forgets cannot produce an unhandled rejection, which under Node's
// default `--unhandled-rejections=throw` would take the process down.
export type TrackResult = { settled: CommandEvent } | { timedOut: true; what: string };

const pending = new Map<string, Pending>();

// Call this with the commandId from the 202.
export function track(commandId: string, what: string) {
  return new Promise<TrackResult>((finish) => {
    pending.set(commandId, { what, queuedAt: Date.now(), finish });
  });
}

// Call this for every `command` event off the stream.
export function onCommandEvent(event: CommandEvent) {
  const entry = pending.get(event.commandId);
  if (!entry) return; // already timed out, or belongs to another worker
  pending.delete(event.commandId);
  entry.finish({ settled: event });
}

// Nothing settles a lost event but a clock. The longest dispatch deadline is
// five minutes, so anything older than that is never arriving.
const GIVE_UP_AFTER_MS = 6 * 60_000;

// Started explicitly, and `unref`d so it never holds the event loop open — a
// module-scope timer would stop a one-shot script from ever exiting.
export function startSweeper() {
  const timer = setInterval(() => {
    const now = Date.now();
    for (const [commandId, entry] of pending) {
      if (now - entry.queuedAt < GIVE_UP_AFTER_MS) continue;
      pending.delete(commandId);
      entry.finish({ timedOut: true, what: entry.what });
    }
  }, 30_000);

  timer.unref();
  return () => clearInterval(timer);
}

Using it reads the way you would hope:

const BASE = process.env.MAPIER_BASE_URL!;

startSweeper(); // once, at boot

const res = await fetch(`${BASE}/v1/commands/message.send`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MAPIER_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "order-2481-confirmation",
  },
  body: JSON.stringify({
    target: { kind: "conversation", conversationId: "a0000000-0000-4000-8000-000000000001" },
    payload: { text: "Your order is on its way." },
  }),
});

const { commandId } = await res.json();
const result = await track(commandId, "order 2481 confirmation");

if ("timedOut" in result) {
  // No settlement arrived. The stream is at-most-once, so this means "unknown",
  // not "failed" — reconcile from your own records rather than resending.
  console.warn("no settlement for", result.what);
} else {
  console.log(result.settled.status, result.settled.errorCode);
}

Two things to keep straight as you build on this:

  • A timeout is not a failure. It means the same thing ambiguous means — you do not know. Route it to the same handling, not to your retry path.
  • The map is in memory. A restart loses every in-flight entry. If the outcome of a command matters after a deploy, record commandId and queuedAt somewhere durable and reconcile from there instead. See Reconnection and delivery guarantees.

Unknown codes

The errorCode list is not closed. New codes can appear without a version change, so match the ones you handle specially and let everything else fall through to a generic failure. An unrecognised errorCode is never a reason to assume the command worked.

The six statuses above are the ones the stream emits, but give your switch a default branch anyway — one that records the value rather than throwing, and that treats an unknown outcome as unknown rather than as success.

On this page