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:
| Type | Purpose | Reference |
|---|---|---|
message.send | Send a text message | message.send |
reaction.tapback | React to a message in a conversation the contact has written into | reaction.tapback |
name_photo.share | Offer your contact card in a one-to-one conversation | name_photo.share |
typing.start | Show the typing bubble in a one-to-one conversation the contact has written into | typing.start |
message.read | Mark a one-to-one conversation the contact has written into read on the line | message.read |
message.edit | Change the text of a message the line sent, within 15 minutes | message.edit |
message.unsend | Take back a message the line sent, within 2 minutes | message.unsend |
name_photo.get | Read the line's own name and photo | name_photo.get |
name_photo.set | Set the line's own name and photo | name_photo.set |
poll.vote | Vote on a poll in a conversation, by the ids its poll.created event carried | poll.vote |
group.create | Start a group chat | group.create |
group.update | Rename a group chat or change its members | group.update |
group.leave | Leave a group chat — cannot be undone | group.leave |
chat_background.set | Set the background of a conversation, to a preset or two colours of your own | chat_background.set |
chat_background.remove | Remove the background of a conversation | chat_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:
{
"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.tapbackor aname_photo.sharenames, attarget.conversationId, a conversation the transport has named — which it does when the contact first writes into it; - a
reaction.tapbacknames its message atpayload.targetMessageExternalId, and amessageIdthere for a message the line sent, or for one another conversation holds, is refused; - a
group.createnames 2 to 32 other people attarget.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 attarget.participantExternalIds[i](or the key and index you wrote); - a
group.updateor agroup.leavenames, attarget.conversationId, a conversation the transport reported as a group chat — and an update's action and itsnameorparticipantExternalIdare checked too, and a leave's payload must be empty; - a
chat_background.setor achat_background.removenames, attarget.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'spayload.presetandpayload.colorsare checked too, and a remove's payload must be empty; - a
message.editor amessage.unsendnames, attarget.messageId, a message this line sent in that conversation, which has a transport id and is inside its window — and an edit'spayload.textis checked too; - a
name_photo.getor aname_photo.setnames, attarget.lineId, a line of this project in the key's environment — any other id is the403 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 attarget.lineIdnames one of this project's lines in the key's environment (any other id is the403 line_not_allowed), on the channeltarget.channelnames if it names one, and — beside aconversationId— the line that conversation is on; - a
typing.startnames, attarget.conversationId, a conversation the transport has confirmed is with that one person; - a
message.readnames the same, and an optionalpayload.externalIdis checked too; - a
poll.votenames, attarget.conversationId, a conversation the transport has named, a group's included, and the poll and the choice atpayload.pollExternalIdandpayload.optionExternalId;payload.removeand 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
| Status | Code | Cause |
|---|---|---|
400 | bad_request | Body is not JSON, or the transport refused the request as malformed (details.reason is its word) |
401 | invalid_credentials | No key, or one that is unknown, revoked or expired |
403 | line_not_allowed | target.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 |
403 | recipient_not_allowed | The contact cannot be sent to — details.reason is opted_out, blocked, paused or outside_service_window |
404 | unsupported_command | Unknown :type, or one the line's channel never carries (details.reason: not_on_channel) |
404 | not_found | Unknown conversation, or another project's or environment's |
409 | conflict | Either 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 |
409 | project_archived | The project is archived |
413 | payload_too_large | Body over 65,536 bytes |
422 | validation_failed | A 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 |
429 | rate_limited | 600 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) |
429 | sandbox_budget_exhausted | The test world's daily budget is spent |
500 | internal_error | An unhandled failure, reported; details.requestId names the log line when present |
503 | no_line | No active line on the channel |
503 | no_line_capacity · capacity_exhausted | Every line is full — capacity_exhausted is the same refusal at version 2026-10-15 or later |
503 | line_unavailable | The 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) |
503 | upstream_unavailable | The 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 |
503 | service_busy | The 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.
Related
Core concepts
Targets, commands, settlements and capabilities in prose.
Addressing a conversation
The three target kinds, and which commands accept which.
Idempotency and retries
When a repeated key is adopted, and the one live command that ignores it.
Command settlement events
Where the real outcome of a 202 arrives.
GET /v1/lines/available
Which of your numbers somebody new would get right now, so your app can show it to a person who is about to text you.
message.send
Send text into a conversation, or start a new one with a recipient — with an effect, a subject and a quoted reply once the contact has written, and a photo by link, a sticker, a rich link, text and photos as one message, a request for the contact's location, or a poll.