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
202withdisposition: "adopted", and sends nothing — or, for amessage.sendno transport ever took, is refused with409 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 a409that says why — on every command butgroup.create,typing.startandmessage.readon a live line, which ignore the key, so agroup.createretry 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
| Status | Did it happen? | Safe to retry? |
|---|---|---|
succeeded | Yes | No need |
failed | No | Only if failureClass says it can help, under a new Idempotency-Key |
expired | No | Yes, under a new Idempotency-Key, once the line is healthy |
cancelled | No | Not automatically |
ambiguous, no_effect | Unknown | Not 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
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.
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.
Related
The response stream
Why every asynchronous outcome arrives on one SSE connection, and how to start consuming it.
Receipts and delivery verdicts
Delivered, read and not delivered — the stream's receipt and delivery_failed frames, the webhooks that carry the same facts, and the question you can ask about one message now.