POST /v1/commands/:type

The shared envelope every command uses — target, payload and idempotency.

Every action you take on iMessage is a command. They all share one endpoint, one request envelope and one response shape; the :type path segment selects the verb.

POST /v1/commands/{type}

Request

Headers

Prop

Type

Body

Prop

Type

The body is capped at 65,536 bytes. Both keys default to {}, so a command whose whole meaning is its type needs no body at all; a body you do send must be JSON, and one that is not an object — or whose target or payload is not one — is 422 validation_failed. See Addressing a conversation for the target shapes.

Command types

The published vocabulary is the {type} enum of the OpenAPI document. These have a page here:

TypePurposeReference
message.sendSend a text messagemessage.send
reaction.tapbackReact to a message in a conversation the contact has written intoreaction.tapback
name_photo.shareOffer your contact card in a one-to-one conversationname_photo.share
typing.startShow the typing bubble in a one-to-one conversation the contact has written intotyping.start
message.readMark a one-to-one conversation the contact has written into read on the linemessage.read
message.editChange the text of a message the line sent, within 15 minutesmessage.edit
message.unsendTake back a message the line sent, within 2 minutesmessage.unsend
name_photo.getRead the line's own name and photoname_photo.get
name_photo.setSet the line's own name and photoname_photo.set
poll.voteVote on a poll in a conversation, by the ids its poll.created event carriedpoll.vote
group.createStart a group chatgroup.create
group.updateRename a group chat or change its membersgroup.update
group.leaveLeave a group chat — cannot be undonegroup.leave
chat_background.setSet the background of a conversation, to a preset or two colours of your ownchat_background.set
chat_background.removeRemove the background of a conversationchat_background.remove

Messenger is not offered yet

messenger.interactive.send belongs to the Messenger channel, which is not offered yet, and no iMessage line carries it. Sent to an iMessage line, it is the second 404 below.

WhatsApp is not offered yet

The rest of the vocabulary belongs to the WhatsApp channel, which is not offered yet, and no iMessage line carries any of it: reaction.set, reaction.remove, whatsapp.template.send, whatsapp.interactive.send, whatsapp.location.send and whatsapp.contacts.send. Sent to an iMessage line, each is the second 404 below.

Two refusals, before anything is queued

A type outside the vocabulary is a 404 at once, before the body is read — there is no body that would have made it valid:

POST /v1/commands/message.delete — 404
{
  "error": {
    "code": "unsupported_command",
    "message": "`message.delete` is not a command this platform knows."
  }
}

A type in the vocabulary that no line of the resolved channel ever carries is a 404 too, raised once the line resolves and before anything is written, with details.reason: "not_on_channel", the channel and the capability the command needed. On an iMessage line, live or test, that is every type in the callout above.

Branch on details.reason, never on message. A type the channel carries and this particular line cannot is the third answer: a 202 that settles failed. Capabilities and GET /v1/whoami's lines[].capabilities say which is which before you send.

A type this line cannot carry out

A type the channel carries and this line or this environment cannot execute — group.create in the sandbox — is accepted with 202 and settles failed / unsupported_capability on the stream instead, because that one is worth retrying against a live key and the two 404s are not.

A group.update or a group.leave never gets that far in the sandbox: a test line has no group chat, so a well-formed call to either is 422 validation_failed at target.conversationId.

Response

202 Accepted:

{
  "commandId": "3f1a0c2e-8b47-4d19-9a5e-2c6f0b8d4e71",
  "status": "queued",
  "disposition": "queued",
  "lineId": "0192a7b0-3e4f-7051-8c6d-7e8f9a0b1c2d",
  "lineSelection": "reused_active_chat"
}

lineId is the line the command went out on, as GET /v1/whoami lists it; lineSelection is why that one — named, reused_active_chat or new_best_line, explained in The shape of every response. disposition is "adopted" when an identical idempotency key already existed, meaning no new work was created and commandId refers to the original command. status is "queued" on every 202, an adopted one included: it says the command was accepted, never how it ended — even when the original has long since settled. A repeated key with nothing to adopt is not a 202 at all: a message.send whose first attempt is still running, or that no transport ever took, is a 409 conflict — see Errors.

The response tells you the command was queued. It does not tell you the message was delivered. Watch the response stream for the command event that settles it.

Idempotency

Every command honours Idempotency-Key — a repeat is never run twice — except group.create, typing.start and message.read on a live line. A message.send is recorded against its key by the platform itself, before the line is asked, so a retried send is never posted twice on either target: it is adopted when a transport took the first attempt, and refused with 409 conflict when none did. The settlement's logicalActionId carries the key, or a copy of commandId for the one live command whose line mints its own identity: group.create. A message.send on the recipient path whose answer was lost is the exception: its message is left needs_reconcile, and its command frame carries the transport's own logicalActionId rather than your key. Correlate by commandId.

On a live line, group.create accepts an Idempotency-Key header and ignores it: the line mints a fresh command for every POST, so retrying one creates a second group. Wait for its settlement before you decide to retry. See Idempotency and retries.

What is checked before anything is queued

The body first: over 65,536 bytes is 413 payload_too_large, not JSON is 400 bad_request, and a body, target or payload that is not an object is 422 validation_failed. So is a target that names nobody, with details pointing at target.recipientExternalId, and an Idempotency-Key that is empty, over 200 characters, holds a control character or has whitespace at either end, at Idempotency-Key.

A message.send payload is checked against the line the send resolves to, in both environments: a key the line does not carry that carries a value — a kind or a misspelled field included — is 422 validation_failed at payload.<key>, and so is a send with no text, at payload.text. See What a line carries. A live iMessage line also refuses what its path cannot carry, with the same code and nothing written: on the recipient path, an email address — at the target field you wrote it at (target.recipientExternalId, or whichever of its spellings you used), at target.conversationId when it is the peer of a thread you named that takes that path — and a group conversation the transport has not named yet, at target.conversationId. A reply the path cannot quote is refused at target.replyToMessageId.

On an iMessage line, live or test, the platform also checks how more commands are addressed, and refuses each with 422 validation_failed before anything is sent:

  • a reaction.tapback or a name_photo.share names, at target.conversationId, a conversation the transport has named — which it does when the contact first writes into it;
  • a reaction.tapback names its message at payload.targetMessageExternalId, and a messageId there for a message the line sent, or for one another conversation holds, is refused;
  • a group.create names 2 to 32 other people at target.participantExternalIds, each an address the channel reads, counted once — the first is read as the command's recipient, and one the channel cannot read, the first included, is refused at target.participantExternalIds[i] (or the key and index you wrote);
  • a group.update or a group.leave names, at target.conversationId, a conversation the transport reported as a group chat — and an update's action and its name or participantExternalId are checked too, and a leave's payload must be empty;
  • a chat_background.set or a chat_background.remove names, at target.conversationId, a conversation with one person that the transport has confirmed is with that one person, or a group chat it reported — and a set's payload.preset and payload.colors are checked too, and a remove's payload must be empty;
  • a message.edit or a message.unsend names, at target.messageId, a message this line sent in that conversation, which has a transport id and is inside its window — and an edit's payload.text is checked too;
  • a name_photo.get or a name_photo.set names, at target.lineId, a line of this project in the key's environment — any other id is the 403 line_not_allowed — and a set's payload is checked too, its photo link fetched;
  • a message.send, or any command addressed to a recipient, that names a line at target.lineId names one of this project's lines in the key's environment (any other id is the 403 line_not_allowed), on the channel target.channel names if it names one, and — beside a conversationId — the line that conversation is on;
  • a typing.start names, at target.conversationId, a conversation the transport has confirmed is with that one person;
  • a message.read names the same, and an optional payload.externalId is checked too;
  • a poll.vote names, at target.conversationId, a conversation the transport has named, a group's included, and the poll and the choice at payload.pollExternalId and payload.optionExternalId; payload.remove and any other key are refused.

The platform checks no other command's payload, and in test nothing else does. On a live line the transport checks reaction.tapback and name_photo.share before it takes them, and that refusal comes back as a 422 validation_failed too, or a 400 bad_request, with the transport's word as the reason. A group.create payload is otherwise checked only when the line runs it. So in a reaction.tapback, name_photo.share or group.create payload, a misspelled field, an out-of-range value or a bad enum can return 202 and settle failed on the stream seconds later. When you are developing against a new payload shape, keep the stream open.

A sticker and a rich_link are checked by the platform as a text is — every bound at its own field, 422 validation_failed before anything is queued. What the line can still refuse is its own capability, settled failed with capability_constraint_violation on the stream.

Errors

StatusCodeCause
400bad_requestBody is not JSON, or the transport refused the request as malformed (details.reason is its word)
401invalid_credentialsNo key, or one that is unknown, revoked or expired
403line_not_allowedtarget.lineId is not a line of this project in the key's environment — another project's, the other environment's, or nobody's; details.reason is not_owned and details.lineId the id sent. GET /v1/whoami lists the lines the key can use
403recipient_not_allowedThe contact cannot be sent to — details.reason is opted_out, blocked, paused or outside_service_window
404unsupported_commandUnknown :type, or one the line's channel never carries (details.reason: not_on_channel)
404not_foundUnknown conversation, or another project's or environment's
409conflictEither the Idempotency-Key is taken and there is nothing to adopt — details.reason says how: idempotency_key_in_flight (the first attempt is still running: wait, and retry with the same key), idempotency_key_failed (it was refused and nothing was sent: send again under a new key), idempotency_key_unresolved (its outcome is unknown: check the conversation first) or idempotency_key_reused (the key already named a different command — in test, and on a live line depending on the type and the transport) — or the transport refused the command for a reason of its own, such as group_has_no_messages_yet: a member change into a group with no message yet — send a message into it first. A transport's own reason may add details.retryAfter: the seconds to wait before trying again. See Idempotency and retries
409project_archivedThe project is archived
413payload_too_largeBody over 65,536 bytes
422validation_failedA field is wrong; details names it — a non-object body, an Idempotency-Key outside its rule, a message.send key the line does not carry, an address or reply a live iMessage line cannot carry, or a conversation, message, participant, line or payload an iMessage reaction.tapback, name_photo.share, group.create, group.update, group.leave, message.edit, message.unsend, name_photo.get, name_photo.set, poll.vote, chat_background.set or chat_background.remove cannot be addressed by
429rate_limited600 requests per minute, per project and environment by default; GET /v1/whoami reports your project's own — see Rate limits. The Retry-After header says how long to wait (a transport's own rate limit may give details.retryAfter instead)
429sandbox_budget_exhaustedThe test world's daily budget is spent
500internal_errorAn unhandled failure, reported; details.requestId names the log line when present
503no_lineNo active line on the channel
503no_line_capacity · capacity_exhaustedEvery line is full — capacity_exhausted is the same refusal at version 2026-10-15 or later
503line_unavailableThe line cannot send right now — details.reason is line_paused, line_released, relay_down, relay_unconfigured, channel_not_available or sandbox_world_missing (from 2026-10-15, line_retired in place of line_released, transport_down in place of relay_down and transport_unconfigured in place of relay_unconfigured; see Versions)
503upstream_unavailableThe transport did not take the command — details.reason is its word, such as connector_unavailable (the line's device is offline) or outbound_paused (sending is paused), or timeout, network or status_5xx when no usable answer came back
503service_busyThe platform had no database connection free in time and did nothing; wait details.retryAfter seconds and send the same request

Full descriptions and retry guidance: Error codes.

On this page