Response stream

Command settlement events

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

A command you send settles on the response stream as an event: command frame. The 202 you got back from POST /v1/commands/:type told you the command was queued, and nothing more.

id: 0192a7c4-5b1e-7c3d-9f2a-6e8d4b1c0a37
event: command
data: {"commandId":"3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71","logicalActionId":"order-2481","status":"succeeded","errorCode":null,"conversationId":"a0000000-0000-4000-8000-000000000001","type":"message.send","messageId":"0192a7c4-5b1e-7c3d-9f2a-6e8d4b1c0a99","externalId":null,"namePhoto":null,"projectId":"b0000000-0000-4000-8000-000000000002","environment":"live","failureClass":null}

Every 202 says status: "queued", a retry's (disposition: "adopted") included — that is the HTTP reply, not the stream.

The frame

Prop

Type

Only the terminal status

A command's states before it settles never appear on the stream. Its terminal status is emitted once; a frame repeated across a reconnect has the same id.

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. One means the work is done, three mean it is not, and two mean you do not know — and that last distinction is the one clients get wrong.

succeeded

The command took effect. For message.send, succeeded is the transport's settlement of the send. It is not Apple's Delivered stamp.

succeeded also covers the cases where the command ran and found the world already in the state you asked for — a tapback that was already on the message, a remove for a reaction that was not there, a contact card already shared. The end state is what you asked for, and the settlement does not distinguish those from a change you made. If your application cares whether it was the one that changed something — a counter, an audit trail — the stream cannot tell you.

Do: mark the work done and move on. Do not mark the message delivered.

Apple Delivered and Read do not change command status. They arrive as receipt frames on a stream opened with ?events=receipts, and GET /v1/messages/:messageId/receipts asks for one message's, now.

failed

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

Do: read failureClass 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.

A message.send payload the line cannot carry never reaches this stream — it is a 422 before anything is queued. A reaction.tapback, name_photo.share or group.create payload is checked where it runs, so a malformed one can be 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 and no_effect

The transport took the command and could not prove what it changed. Certainty was lost somewhere between the line and iMessage — nothing confirmed the send went out, or the line went away after the work began. Codes such as outbound_echo_unconfirmed, gateway_response_lost and gateway_timeout describe this shape of outcome.

Do not blindly resend on ambiguous or no_effect

Resending an unproved command is how you send the same message twice. The effect may already have happened.

Which key to use depends on what you actually saw, and the two cases are opposites:

  • You saw this settlement. The command has settled, so its key is spent — a same-key resend is adopted, returns 202 with disposition: "adopted", and sends nothing — or, for a message.send no transport ever took, is refused with 409 conflict; see When a key is refused. Getting the message delivered needs a new key, which means accepting the risk of a double send.
  • You saw no settlement at all — a dropped stream, a restart. Reopen the stream with Last-Event-ID; the frames you missed are replayed. If you must resend before they arrive, the same key is never executed again — it is adopted, or refused with a 409 that says why — on every command but group.create, typing.start and message.read on a live line, which ignore the key, so a group.create retry there creates a second group. There, the correct move is usually to surface the uncertainty to a human rather than guess. See Idempotency.

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 is 30 seconds on the conversation path and five minutes on the recipient path. The usual cause is that the line 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 line is back first, or every retry expires the same way.

At a glance

StatusDid it happen?Safe to retry?
succeededYesNo need
failedNoOnly if failureClass says it can help, under a new Idempotency-Key
expiredNoYes, under a new Idempotency-Key, once the line is healthy
cancelledNoNot automatically
ambiguous, no_effectUnknownNot blindly — see the callout above

The logicalActionId trap

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

logicalActionId is not always your key

It carries your Idempotency-Key — or the one minted for a request that sent none — on every command in test, and on every live command but one.

For group.create, a live line mints its own identity per call and logicalActionId is a copy of commandId. A client that correlates on it will match every other command's settlement and silently drop that one.

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: an entry can sit there forever waiting for an event you never read — and a repeated frame for the same id must overwrite the first.

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

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>();
const lastByCommand = new Map<string, CommandEvent>();

// Call this with the commandId from the 202.
export function track(commandId: string, what: string) {
  // A settlement can arrive before the 202 is read: answer from what the stream already saw.
  const seen = lastByCommand.get(commandId);
  if (seen) return Promise.resolve<TrackResult>({ settled: seen });
  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) {
  lastByCommand.set(event.commandId, event); // last-write-wins
  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 an event you never read but a clock.
const GIVE_UP_AFTER_MS = 6 * 60_000;

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 = "https://api.mapier.ai";

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. Do not treat this as failed.
  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.

How to branch

A switch that treats succeeded as delivered is the bug this page exists to prevent.

switch (event.status) {
  case "succeeded":
    markSendSettled(event.commandId); // not Apple Delivered
    break;

  case "failed":
  case "expired":
  case "cancelled":
    markNotDone(event.commandId, event.errorCode);
    break;

  case "ambiguous":
  case "no_effect":
    markOutcomeUnknown(event.commandId, event.errorCode);
    break;

  default:
    // A status this code does not recognise. Not an error, and not a success.
    markOutcomeUnknown(event.commandId, event.errorCode);
}

Do not call markDelivered here. After succeeded, Apple's stamps arrive as receipt frames.

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