Every HTTP error code and every asynchronous settlement code, with what to do about each.
Errors reach you in two places, and the difference matters.
Synchronous errors are HTTP responses to your request. The command was
never queued. The body is one envelope on every route:
{ "error": { "code": "<code>", "message": "<a sentence for a person>", "details": … } }
Switch on code — a closed set, each value mapped to exactly one HTTP status.
On a 422 validation_failed, details is an array of { path, message, … }
entries, one per field. On every other refusal that has details, it is an
object whose reason says which refusal this is — the 403, 404, 409
and 503 below all carry one.
Asynchronous errors arrive later on the response
stream as a command event with status: "failed" and
an errorCode, or as a message.failed webhook. The command was queued,
dispatched, and then something went wrong on the way to iMessage.
A client that only handles the first kind will believe every message was
delivered.
Every code a /v1 route can answer with, and the status it answers with it.
The OpenAPI document lists, per operation, exactly which of these that
operation can produce.
The body is not JSON, or the transport refused the request as malformed — details.reason is then its own word. A body that is JSON but not an object, and a command that names nobody, are 422 instead.
target.lineId is not a line of this project in the environment of the key — another project's, the other environment's, or nobody's; the API does not say which, because a key's lines are exactly the list GET /v1/whoami hands it. details.reason is not_owned and details.lineId the id sent.
Pick a line from GET /v1/whoami, or leave target.lineId out.
recipient_not_allowed
The contact cannot be sent to, and details.reason says why: opted_out (they sent STOP or UNSUBSCRIBE, and every send is refused until they opt back in), blocked or paused (set on the contact in the console), or outside_service_window (a channel whose window for free-form messages has closed; details.channel and details.serviceWindowExpiresAt say which and when). details.contactId names them. It is never about the address's form: an address a line cannot send to is a 422.
Do not retry an opted_out. Honouring it is the platform's job and yours.
The :type segment is not a command the gateway accepts — or it is one the line's channel never carries, which says details.reason: not_on_channel with the channel and the capability it lacks.
Check the spelling against the command list. Contrast failed / unsupported_capability on the stream, which is a type the channel carries and this line cannot execute.
not_found
No such conversation, message or attachment — or it belongs to another project or the other environment.
The cases are deliberately indistinguishable. Verify the id came from your own stream, with a key of the same environment.
The project this key belongs to has been archived in the console.
Restore it, or use a key from a live project.
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); idempotency_key_failed (it was a message.send no transport took — it was refused, and nothing was sent); idempotency_key_unresolved (a message.send whose outcome is unknown — its message is needs_reconcile, or its attempt died before any answer and has not been reconciled yet); 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, where it may instead be adopted as the first) — 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. With failed and unresolved, details.messageId is the message the first attempt wrote and details.errorCode the code it settled with (null until it is reconciled), and failed adds details.failureClass. The reason is an open string: a transport may supply its own, and when it refused the command only for now it adds details.retryAfter, the seconds to wait before trying it again.
In flight: wait, then send again with the same key. Failed: send again under a new key. Unresolved: check the conversation before any resend — repeating it could deliver it twice. Reused: send the original body, or use a new key.
A field is wrong, and details says which: [{ "path": "target.recipientExternalId", "message": "…" }]. On message.send that includes, in both environments, a payload key the line does not carry and a missing text (payload.<key>), and on a live iMessage line's recipient path an email address (the target field you wrote it at, or target.conversationId for a thread on that path whose peer is one). On an iMessage line, live or test, it includes a reaction.tapback or name_photo.share into a conversation the transport has not named (target.conversationId), a tapback naming by messageId a message the line sent or another conversation holds (payload.targetMessageExternalId), and a group.create whose participants the channel cannot take or number outside 2 to 32 (target.participantExternalIds, or target.participantExternalIds[i] for one the channel cannot read). On every command it includes an Idempotency-Key outside its rule (Idempotency-Key) and an unknown x-mapier-version, whose entry lists the versions this build answers to in supported. A transport's own refusal carries its word in the entry's reason. Other commands' payloads are checked where they run — see What is checked before anything is queued.
Too many requests for this route's budget — your project's command ceiling (per environment) on POST /v1/commands/{type}, this key's budget on the other routes. A Retry-After header says when the window reopens.
The project has no active line in this environment.
Request a line from the console's Lines page.
no_line_capacity
Every line is at its contact capacity. details.reason is all_lines_full.
Request another line. A new contact needs a slot; existing ones are unaffected.
capacity_exhausted
The same refusal as no_line_capacity, in the transport-neutral words a request at x-mapier-version: 2026-10-15 or later receives instead. You get one of the two, never both.
As for no_line_capacity.
line_unavailable
The line cannot send right now; details.reason says why: line_paused (with details.lineId), line_released (with details.lineId, or details.conversationId when the thread's line has gone), relay_down, relay_unconfigured, channel_not_available or sandbox_world_missing. From 2026-10-15, line_retired, transport_down and transport_unconfigured replace line_released, relay_down and relay_unconfigured. The reason is an open string.
line_paused and relay_down (transport_down) clear operationally — retry with backoff and the same key. line_released (line_retired) and channel_not_available do not: a released line's thread takes nothing more, so start a new one with the recipient.
upstream_unavailable
The transport behind the line did not take the command, or answered with something this platform could not use; details.reason carries the transport's own word — on an iMessage line connector_unavailable when the line's device is offline, outbound_paused when sending is paused — or timeout, network or status_5xx when no usable answer came back.
Retry with backoff and the same key — except a live message.send, whose key is spent: it was recorded and settled before this answer, so a same-key retry sends nothing and answers 409 conflict (idempotency_key_failed, or idempotency_key_unresolved when no usable answer came back). Resend it under a new key, after checking the conversation when details.reason is timeout, network or status_5xx.
service_busy
The platform had no database connection free for this request within its own wait. The failure is ours and clears on its own, and nothing the request asked for was done. details.retryAfter (and Retry-After) says how many seconds to wait. Any route can answer it.
Wait that long, then send the same request — repeating it is safe. See Rate limits.
These appear as errorCode on a command frame that did not settle
succeeded — failed, cancelled or expired, and ambiguous or
no_effect when the transport could not prove what the command changed — and on
a message.failed webhook. errorCode is the transport's own word, verbatim:
the tables below are an iMessage line's.
Beside every asynchronous errorCode rides a failureClass, from a closed set
that is the same for every transport. Branch on it, and read errorCode for the
detail or to quote to support. It is null unless the status is failed,
cancelled or expired.
failureClass
What it means
target
Who or what the command is aimed at cannot take it.
content
The payload was refused — fix it, then send.
capability
This line cannot carry the command.
line
The line cannot send right now.
rate_limited
Wait, then send.
not_run
It never ran — sending again is safe.
transport
The transport failed on its side.
unknown
Read errorCode.
The iMessage codes, grouped by what you should do:
The referenced message is no longer in the history the transport holds for that conversation — it aged out, or the conversation the transport named is a group's and the message was sent one to one — or a group was addressed right after its membership changed, before its next message: wait for that message, then send again under a new Idempotency-Key.
payload_conflict
The payload contradicts the recorded command identity.
unsupported_capability
The line cannot carry out this command.
capability_not_negotiated
The line's device has not announced this capability. See Capabilities.
capability_constraint_violation
The line cannot carry this command as sent: a value outside its bounds, or a feature its device has not announced.
invalid_session_capabilities
A fault on the line's side, not in your request.
command_claim_invalid
The change does not apply to the target as the line last saw it — for a group.update, an add of someone already in the group or a remove of someone who is not.
These arrive on a command frame whose status is
ambiguous more often than failed — and a send they end leaves its message
needs_reconcile, the message.needs_reconcile webhook. Do not blindly
resend; you may duplicate a message that was in fact delivered.
errorCode
Meaning
outbound_echo_unconfirmed
iMessage accepted the send, but nothing confirmed it went out.
connector_crash_after_effect_start
The line's device stopped mid-effect.
gateway_timeout
No answer within the deadline.
gateway_response_lost
The response was lost in transit.
tapback_unverified · poll_vote_unverified
The reaction or vote could not be verified afterwards.
This list is not a closed set. Treat an unrecognised errorCode as a generic failure rather
than assuming it cannot occur — new codes can appear without a version change.
503 service_busy — honour retryAfter, then send the same request.
Any other 503 — exponential backoff and the same key, except after a live
message.send's upstream_unavailable: that key is spent, and the resend
needs a new one. See Handling errors.
500 — retry once with the same Idempotency-Key, then escalate.
409 conflict with idempotency_key_in_flight — wait, then retry with the
same key. The other three reasons are not worth repeating as they stand; the
409 table says what each needs.
Any other 4xx — do not retry. The request will fail identically.
A failed settlement — consult the tables above. Only the first two
categories are safe to resend, and always under a new idempotency key,
whether or not you changed the payload. A key stays bound to its command once
that command has settled, so a same-key resend sends nothing: it is adopted,
returning 202 with the original commandId — or, for a message.send no
transport ever took, refused with 409 conflict.
Retries are only safe from double-sending if you supply an Idempotency-Keyand send the same one on every attempt — and never for group.create,
typing.start or message.read on a live line, which ignore it. See
Idempotency and retries — this is a real trap, and
it does not apply to every command.