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.tapbackwithalready_reacted— that reaction was already on the message.reaction.tapbackwithremoveset andnot_reacted— there was nothing to remove.name_photo.sharewithnot_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
| Status | Did it happen? | Safe to retry? |
|---|---|---|
succeeded | Yes | No need |
no_effect | Already true | No need |
failed | No | Only if errorCode says the cause was transient |
expired | No | Yes, once the connector is healthy |
cancelled | No | Not automatically |
ambiguous | Unknown | Only 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
ambiguousmeans — 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
commandIdandqueuedAtsomewhere 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.