Skip to main content

Log Error Codes

When a send fails, the message carries a machine-readable errorCode, derived on the server from what the platform recorded about the failure. You find it: errorCode is null when there is no error, and always null on DELIVERED, READ, PLAYED and CANCELED. It is not errorMessage: branch on errorCode, and treat errorMessage as text for people — its wording is not a contract and can change.
The set grows. The tables below list every value errorCode can take today, and new codes are added as more failures get a name of their own. Treat a code you do not recognise as “needs review”, not as a parsing error.
The code is computed each time the message is read, not stored with it. So a code added to this page also shows on messages that failed before the code existed, as long as what was recorded about the failure already named it. Two changes work the other way. They change what is recorded, so they apply only to messages that fail after their release; a message that failed earlier keeps the code it had, usually SEND_FAILED:
  • the send-time alignment of Meta codes, except four legacy codes that are corrected on read (see Refused at send time in the Meta section below);
  • the codes the worker now records where it used to store only free text — MESSAGE_CONTENT_UNAVAILABLE, TEMPLATE_RENDER_FAILED, META_CREDENTIALS_MISSING, WHATSAPP_INSTANCE_NOT_CONNECTED, WHATSAPP_INSTANCE_NOT_CONFIGURED, META_TEMPLATE_OR_TEXT_MISSING, BUTTON_NOT_SENDABLE, FLOW_BUTTON_INDEX_INVALID, MEDIA_DOWNLOAD_FAILED, MEDIA_TRANSCODE_FAILED, PROVIDER_UNREACHABLE, META_DELIVERY_UNKNOWN — and the new cases of existing codes described in their rows (FEATURE_NOT_SUPPORTED, META_MEDIA_UPLOAD_ERROR, NUMBER_NOT_FOUND_REMOVED, DELIVERY_TIMEOUT, INVALID_DESTINATION and the two template-variable codes).

Platform and delivery

Template and message composition

The template or message could not be built or sent as it is. Most of these are also the code of a 4xx refusal from POST /v1/messages/send; here they are the same refusal landing on a stored message — through a Chatwoot reply or the worker.

Chatwoot agent replies

A reply an agent wrote in Chatwoot that could not be delivered is recorded as a FAILED message on the number, with origin: "CHATWOOT". On an API mirror inbox, every code in this table except CHATWOOT_SEND_EXCEPTION is a refusal that is not retried, and the agent sees it in Chatwoot too: the reply is marked failed with the reason on the bubble, and a private note in the conversation says why. On a native WhatsApp Cloud inbox none of these codes applies: Chatwoot sends the reply to Meta itself, through the Meta layer. A reply Meta refuses outright is not recorded at all. A reply Meta accepts and later fails shows as FAILED with errorCode: null.

Unpaid extra-number charge

NUMBER_BLOCKED is also the code of a message that was not sent because the number’s extra-number charge is unpaid. An extra number is charged when it first connects. When that charge is refused, the number stays connected, but sending from it is blocked until the charge is paid. This applies to every send, not only to Chatwoot replies:
  • POST /v1/messages/send and POST /v1/messages/{id}/resend answer 409 NUMBER_BLOCKED with reason: "extra_charge_unpaid". Nothing is queued.
  • A message that was already queued when the block started is not sent. It becomes FAILED with errorCode: "NUMBER_BLOCKED", and it is not retried.
reason comes on the 409, not on the stored message. To confirm the cause of a FAILED message, read extraChargeUnpaidSince on the number (GET /v1/numbers/{id}): it is set while the charge is unpaid. What to do: pay the charge, then send the message again. Any of these lifts the block:
  • top the wallet up, or save a card — both retry the charge automatically;
  • Pay now on the dashboard’s Numbers page (workspace owner only);
  • POST /v1/numbers/{id}/pay.
Reconnecting the number does not clear this block. On the Free plan none of the three applies, because Free never buys an extra number by itself: buy the extra number yourself or move up a plan. See Free plan.

Media preparation on official numbers

Before a media message goes to Meta, Pilot Status downloads the file and, when needed, converts it.

Unofficial (web) numbers

Official numbers — Meta Cloud API

On an official number, Meta’s native webhook envelope carries the numeric error in statuses[].errors[].code. The Meta code column is how you join that number to errorCode. — means there is no Meta error number behind the code: the platform refused the send before it reached Meta (META_MEDIA_TOO_LARGE, for example, is a size check), no answer came back to read one from (META_DELIVERY_UNKNOWN), or — for META_TOKEN_EXPIRED — no number maps to it any more. When Meta sends no number, or one this table does not map, the failure is classified from Meta’s error text, and falls back to META_SEND_FAILED when the text does not match either.
Meta can refuse a send in two ways: at the send request itself, before a wamid exists, or later, in statuses[]. Where the send-time path names a code for a Meta number, it is the same code the webhook gives (the column above). Where it does not, the send comes out as META_SEND_FAILED, with Meta’s own reason in errorMessage.A send-time refusal carries information a status webhook never does — a subcode, Meta’s wording, whether the call was a group operation — so a few answers are more specific:Messages that failed before this alignment are corrected on read where the old code is unambiguous. Four codes the send-time path no longer writes each came from exactly one Meta number, so an older message that carries one is read with today’s code: META_SPAM_RATE_LIMITED (4, 80007) as META_RATE_LIMITED, META_TEMPLATE_BAD_PARAM (131052) as META_MEDIA_DOWNLOAD_ERROR, META_BAD_REQUEST (plain 100) as META_INVALID_PARAM, and META_UPLOAD_SESSION_INVALID (131000) as META_GENERIC_ERROR. Two stay as they were stored, because the old code is ambiguous: an older send-time 131048 still reads META_NO_CONVERSATION (which also means webhook 131026), and an older send-time 131053 still reads META_TEMPLATE_NOT_APPROVED (which also means our own pre-check).

Catch-all codes

SEND_FAILED is kept on purpose where there is no verdict to name: the provider’s own error text on an unofficial number — on the last attempt, or in a failure status the provider reports — when it matches none of the codes above; a network failure while uploading media to Meta, other than an unreachable provider (the message itself was never sent, so it is a plain failure, not META_DELIVERY_UNKNOWN); and an unexpected internal error. A more specific code there would be a guess.
BUTTON_NOT_SENDABLE, CHATWOOT_SEND_EXCEPTION, FEATURE_NOT_SUPPORTED, FLOW_BUTTON_INDEX_INVALID, FLOW_REQUIRES_META_NUMBER, MEDIA_DOWNLOAD_FAILED, MEDIA_REHOST_FAILED, MEDIA_TRANSCODE_FAILED, META_CREDENTIALS_MISSING, META_DELIVERY_UNKNOWN, META_MEDIA_UPLOAD_ERROR, META_TEMPLATE_OR_TEXT_MISSING, NUMBER_BLOCKED, NUMBER_NOT_FOUND_REMOVED, PAYMENT_ORDER_PARTIAL_SEND, PROVIDER_UNREACHABLE, TEMPLATE_RENDER_FAILED and WHATSAPP_INSTANCE_NOT_CONNECTED can be recorded with the underlying error appended after the code. The errorCode you receive is still exact, and that appended provider or exception text stays internal: it is not copied into errorMessage.
A CANCELED message (canceled via DELETE /v1/messages/cancel) is not a delivery error and does not carry one of these codes.

Webhook error codes vs API error codes

On an official number there is no canonical message.* event at all: a failure arrives in Meta’s native envelope as statuses[].errors[].code, which the Meta code column above maps. On an unofficial (web) number message.failed is dispatched, but its code is not the errorCode on this page. It predates it, uses its own values, and most failure paths send no code at all. The payload is left unchanged on purpose: existing subscribers branch on it. To reconcile, use the API value. Query GET /v1/messages/{messageId} with the message’s id or correlationId. Its errorCode is the one that matches this page, GET /v1/messages and the Logs screen. On a message that failed before the release, the last four rows can still show the older value — usually SEND_FAILED, sometimes DELIVERY_TIMEOUT. Rows with no code carry the reason as text in data.errorMessage. The rows with a top-level code, and the retry-deadline row, send a flat body instead of { event, data }: internalMessageId, correlationId, destinationNumber, status, error and timestamp sit at the top level.