Platform

Errors

Every non-2xx response uses the same JSON envelope so your integration only parses one error shape.

Envelope

{
  "error": {
    "type": "validation_error",
    "code": "invalid_message_request",
    "message": "to is required.",
    "param": "to",
    "request_id": "req_...",
    "trace_id": "trc_..."
  }
}

type is the category (stable). code is the specific reason (also stable; treat it as a string). message is human-readable and may change. param is set on validation errors. Rate-limit errors also include retry_after_ms. trace_id is the single field to include in a support ticket.

Credit exhaustion

All non-2xx public API responses use one error envelope with type, code, message, request_id, trace_id, and optional details. Retry network errors and 5xx responses, using an idempotency key on the write paths that accept one — an operation honors the header when /openapi.json declares an idempotency-key header parameter for it. A 409 idempotency_key_conflict means the same key was replayed with a different body: use a fresh key. A 409 idempotency_key_in_progress usually means the original request with that key is still in flight: wait and retry the same key rather than minting a new one. A key can also remain in this state after work completed without saving its response or was interrupted mid-flight; the API keeps that reservation for the rest of the 24-hour default idempotency window rather than risk running the work twice. Once the key expires it is reclaimable and a same-key request executes fresh. If you cannot wait for expiry, mint a new key only when you intend to run the operation again, since it may charge again. Do not retry 4xx validation, authentication, permission, or idempotency-conflict errors without changing the request.

A 402 payment_required response with credit_balance_exhausted includes error.retryable: false. Stop automatic retries until credit recovery. Top up through the existing billing flow and confirm sufficient credit before trying again; recovery does not clear other authorization or sending restrictions. There is no timed Retry-After or retry_after_ms for this error. The SDK exposes TyxterApiError.retryable; legacy responses without the field yield undefined, which is not permission to retry. Rate-limit responses retain their separate retry_after_ms and Retry-After guidance.

Reporting an unexpected failure

When a public /v1/* call fails with an unhandled internal_error — something genuinely unexpected on our side, not a typed operational error — the envelope carries an additive, optional feedback pointer telling an integrating agent where to report it:

{
  "error": {
    "type": "internal_error",
    "code": "internal_error",
    "message": "An internal error occurred.",
    "request_id": "req_...",
    "trace_id": "trc_...",
    "feedback": {
      "endpoint": "/v1/feedback",
      "method": "POST"
    }
  }
}

Post the trace_id (and, if you have it, the request_id and the code) to POST /v1/feedback as the related_error so the report correlates to the failure. The endpoint is a constant relative path — always /v1/feedback, never derived from the request host. POST /v1/feedback itself refuses a missing or blank Idempotency-Key with 400 missing_idempotency_key, and a body that fails its strict schema or content that cannot be safely redacted with 400 invalid_feedback_report_request; neither stores a report.

The pointer is deliberately narrow. It appears on an unhandled internal_error from a public /v1/* route that is not /v1/feedback itself, and on the two payment-fee refusals of POST /v1/payments and POST /v1/agentic/payments: 402 insufficient_balance and 503 payment_fee_pricing_unavailable. It also appears on 400 payment_cancellation_unsupported from POST /v1/payments/{payment_id}/cancel, on 409 phone_number_rental_approval_required from a production POST /v1/phone-numbers/provision, and on 503 phone_number_rental_preview_unavailable from that route and POST /v1/phone-numbers/orders. The plan routes carry it on 402 payment_method_required from POST /v1/billing/plan/subscribe and on 404 subscription_not_found from POST /v1/billing/plan/change and POST /v1/billing/plan/cancel. It is absent from other 4xx, from other typed operational 5xx such as provider_error, from rate-limit and idempotency responses, and from all dashboard errors. The field is additive, so an older SDK that does not know about it simply ignores it. See the /api-reference/feedback page for the request shape and the receipt.

Discovering the API from an error

Every error the API answers uses that envelope, including the ones the framework raises before any handler runs. A request whose method and path match no route at all answers 404 not_found with error.code route_not_found and a message naming the method and the path it was matched against; the query string is stripped from that message, so a key or token you accidentally put in the URL is never echoed back to you in the response body. On public /v1/* routes the same envelope carries an additive, optional error.discovery pointer naming the two documents an agent can read to find what the API actually serves: openapi, the full operation list at /openapi.json, and well_known, the capability manifest at /.well-known/tyxter.json. An agent that guessed a path can therefore correct itself from the error alone. Both values are constant relative paths on the API host and are never derived from the request Host header. The pointer is deliberately narrow: it appears only on route_not_found and only for /v1/* targets, and is absent from every other error and from dashboard routes, so treat its absence as normal rather than as a signal. Do not confuse route_not_found with the per-resource 404 codes such as message_not_found, which mean the route exists and the record does not, nor with llm_route_not_found, which means no LLM route is configured for the environment and has nothing to do with HTTP paths.

A request body over the limit for its route answers 413 validation_error with error.code payload_too_large, whether the oversized size was declared in Content-Length up front or only discovered while the body streamed in — both paths report the identical code. A declared Content-Length is refused at 10 MB on every route except the capability-token blob upload PUT /v1/media/blobs/{token}, which accepts 100 MB. A JSON body streamed without a Content-Length is held to that same 10 MB; only a non-JSON binary upload gets a higher streaming backstop, so treat the declared-size figures as the contract: send large files through POST /v1/media/uploads and reference the asset instead of inlining the bytes. Any other framework-level refusal — a malformed JSON body on a route that expects JSON, for instance — arrives in the same envelope with error.code http_exception and a type chosen from the status, so a 400 reports validation_error rather than internal_error. Branch on error.code as usual; retry posture follows the status.

Salvy BYOK connections

Salvy account registration is available only when the rollout capability and an eligible active plan are enabled. salvy_byok_unavailable is a temporary 503; retry the unchanged request after activation. salvy_byok_subscription_required is a 403; choose an eligible paid plan. The registration body must include the exact continuation terms version or it returns salvy_byok_terms_required. A second non-disconnected account in one environment returns salvy_connection_already_exists; rotate the existing credential or disconnect it before re-enrolling.

Salvy rotation and discovery are asynchronous. Malformed requests return invalid_provider_connection_request or invalid_provider_connection_token_rotation. Registration, rotation and discovery each require an Idempotency-Key; a missing or blank one returns idempotency_key_required. A missing or foreign connection id returns provider_connection_not_found, and a disconnected or otherwise unusable connection returns salvy_connection_unavailable. Provider authentication and transport outcomes are recorded on the connection operation snapshot as salvy_credentials_invalid or salvy_provider_unavailable for the integrating agent to poll; a still-current queued operation can also record salvy_connection_unavailable when its loaded stored credential generation or credential blob is unavailable. Reconnect or re-enroll the Salvy account, then retry the operation. These outcomes do not expose the submitted key.

Selected Salvy phone import is synchronous and only accepts a cached active resource from the caller's connected account. Its body must be valid or it returns invalid_connect_request; its continuation terms must be exactly salvy_byok_v1 or it returns salvy_byok_terms_required; and it requires an Idempotency-Key or it returns idempotency_key_required. A missing or foreign selected resource returns salvy_resource_not_found without exposing another tenant's inventory; an inactive resource returns salvy_number_unavailable. If another request already imported that connection/resource pair, salvy_number_already_imported is the stable conflict, including when a second request raced for the last plan phone slot. If that selected E.164 already belongs to a phone in the environment, import returns phone_number_conflict. A production import past the plan's phone-number allowance returns 402 plan_phone_limit_reached. Import does not call Salvy, so these errors do not describe a provider action to poll.

Categories

The response status and type describe the broad category. The stable code gives the precise remediation; fix the request or account state named by that code rather than retrying unchanged input.

StatusTypeWhen
400validation_errorYour request does not match the contract. Fix it and retry.
401authentication_errorThe API key is missing, malformed, revoked, or failed signature/HMAC verification.
402payment_requiredCredit is exhausted, a spend cap is hit, or the balance cannot cover an up-front charge. Top up or raise the cap.
403authorization_errorThe key lacks a required scope, or the organization has disabled the capability family.
404not_foundThe resource does not exist or belongs to another organization; unmatched routes use route_not_found.
409conflict / idempotency_conflictA state transition is not allowed, or an idempotency replay conflicts with an earlier body or in-flight request.
413validation_errorThe request body exceeds the route limit (payload_too_large).
429rate_limitedAn API-key or phone-number throughput bucket is exhausted; honor Retry-After and error.retry_after_ms.
500internal_errorSomething went wrong on our side; retry idempotent calls.
502provider_errorMeta, Salvy, or a platform-owned generation provider returned an error with normalized detail.
503service_unavailable / internal_errorA required setting is missing or a webhook receiver is misconfigured; nothing in the request clears it.
400validation_errorinvalid_project_body, invalid_api_key_request, and other validation codes identify the field-level correction.
404not_foundorganization_not_found and project_not_found intentionally do not reveal another organization.
409conflictproject_slug_taken means the requested slug is already used in the organization.

Common codes worth handling explicitly

The following codes recur across public routes and carry a direct next step. Their status and type are included where they are useful to a caller:

  • project_not_found is 404 not_found on any API-key call when the key's own project has been deleted: a key stops working when its project is deleted, so switch to a key from an active project. A route that takes a project id keeps its own meaning for the code: an absent, archived, or foreign target project.
  • feedback_report_not_found is 404 not_found when the report is missing, purged, dashboard-origin, or outside the API key's organization, project, and environment; those cases are intentionally indistinguishable.
  • spend_limit_exceeded means the project's monthly cap was reached; wait for the next period or raise the cap.
  • insufficient_balance is 402 payment_required when the prepaid balance cannot cover a production charge taken before the work starts. On POST /v1/payments and POST /v1/agentic/payments it cannot cover the payment's initiation fee plus its completion fee hold, with no negative-balance grace; nothing is created or charged, the error carries the error.feedback pointer, and the same Idempotency-Key succeeds after a top-up. Sandbox payments never check the balance. Those two routes can also answer 402 credit_balance_exhausted, the production credit hard-block described under Credit exhaustion: it is not retryable until credit recovers and carries no error.feedback pointer.
  • payment_fee_pricing_unavailable is 503 service_unavailable from POST /v1/payments and POST /v1/agentic/payments when the payment-service fee prices cannot be read. No payment is accepted, in sandbox or production, and nothing is charged. The cause is on our side: retry later with the same Idempotency-Key, and report it through the error.feedback pointer the error carries.
  • data_export_disabled is 403 when the data-retention policy has data_export_enabled set to false; re-enable it with PATCH /v1/data-retention before POST /v1/contacts/{id}/export.
  • kyc_required is 403 when production Salvy phone provisioning has no approved business identity; submit it in the dashboard. Sandbox is never KYC-gated.
  • kyc_pending is 403 while a submitted business identity is still under review; provisioning unlocks after approval.
  • contact_opted_out means the recipient opted out and the send is blocked; do not retry without an explicit override.
  • invalid_flow_json means Flow JSON failed structural validation before submission.
  • automation_inbound_number_conflict means another enabled inbound auto-reply automation already owns the phone_number_id; only one may be active per number and environment.
  • phone_number_confirmation_mismatch means the confirm_phone_number_id in a transfer body does not match the route id; phone_number_transfer_same_environment means the target equals the source; phone_number_transfer_environment_kind_mismatch means the target is a different environment kind, because a production number moves only between production environments and a sandbox number only between sandbox environments; and phone_number_not_transferable means the number is mid-provisioning or otherwise terminal. Correct the transfer request or choose a transferable number. On POST /v1/phone-numbers/{id}/transfer, invalid_transfer_request is 400 validation_error when the body does not match the request schema (a required id missing or empty, reason longer than 500 characters, or an unknown field), and error.param names the field; target_environment_not_found is a non-enumerating 404 not_found when the target project and environment are not in the caller's organization; and feature_disabled is 403 when the target environment disabled the provisioning feature family, as well as when the API key's own environment did.
  • phone_number_not_removable means a dashboard removal was requested before the number became terminal, or for a disconnected Tyxter number that is still rented from the provider; disconnect or release a live number first, and release a disconnected Tyxter number before removing it.
  • phone_number_not_found is a non-enumerating 404 not_found when a phone is absent, belongs to another scope, or no longer has the active managed attachment required by Salvy registration; do not infer which case occurred.
  • invalid_groups_query is 400 validation_error from GET /v1/groups/eligibility and GET /v1/groups (WhatsApp Business groups, beta) when a parameter is missing, blank, out of range or unknown (for example a status outside the group statuses); param names it. Correct the query and retry.
  • invalid_group_request is 400 validation_error from POST /v1/groups/eligibility-checks, POST /v1/groups and POST /v1/sandbox/groups/{group_id}/participants (WhatsApp Business groups, beta) when the body is not a JSON object, a required field is missing or blank, a field has the wrong type, or the body carries an unknown field; param names it. On POST /v1/groups, join_approval_mode approval_required is refused with this code because it is not offered yet. Correct the body and retry.
  • group_not_found is a non-enumerating 404 not_found from GET /v1/groups/{group_id}, DELETE /v1/groups/{group_id}, POST /v1/groups/{group_id}/invite-link/reset and POST /v1/sandbox/groups/{group_id}/participants when no WhatsApp Business group with that Tyxter id exists in the key's organization, project and environment; Meta's group id is never a Tyxter id. Do not infer which case occurred.
  • group_invite_link_reset_in_progress is 409 conflict from POST /v1/groups/{group_id}/invite-link/reset while a reset worker is running. Nothing is written; retry after the worker finishes. A same-key replay still returns the original accepted response.
  • group_not_active is 409 conflict from DELETE /v1/groups/{group_id} when the group is still pending: Tyxter has not learned whether Meta created it, so there is nothing to delete yet. Nothing is written. Read the group and delete it once its status is active or failed; a deleting group accepts reconciliation under a fresh request and a deleted group is returned unchanged, never refused. POST /v1/groups/{group_id}/invite-link/reset returns it for any group that is not active (pending, failed, deleting or deleted): only an active group has an invite link Meta can reset. Nothing is written. The sandbox POST /v1/sandbox/groups/{group_id}/participants returns it for any group that is not active: participants can be simulated only on an active group.
  • group_participant_limit_reached is 409 conflict from the sandbox-only POST /v1/sandbox/groups/{group_id}/participants when a simulated join would make a ninth participant, the cap Meta documents for a WhatsApp Business group. Nothing is written. Simulate a removal first. Tyxter sets no participant limit of its own in production.
  • sandbox_group_participant_sandbox_only is 400 validation_error from POST /v1/sandbox/groups/{group_id}/participants called with a key that is not a sandbox key. In production, a group's participants change only when Meta reports a join or a removal; use a tx_sandbox_ key to simulate them.
  • personal_whatsapp_group_not_supported is 422 validation_error from GET /v1/groups/{group_id}, DELETE /v1/groups/{group_id}, POST /v1/groups/{group_id}/invite-link/reset and POST /v1/sandbox/groups/{group_id}/participants when the group_id path segment looks like a personal WhatsApp group: an app group id ending in @g.us, or an invite link from chat.whatsapp.com percent-encoded into that segment (the SDK encodes it). Authenticated GET and DELETE also return this teaching 422 under the documented invite-host slash-tail routes, without echoing the tail or changing Group state; reset and simulation POST still return 404 route_not_found for an unencoded link. Tyxter does not connect to, read, join or import personal WhatsApp groups; the message links the page that explains the difference. Use the id POST /v1/groups returned.
  • group_phone_number_not_eligible is 422 validation_error from POST /v1/groups when the stored eligibility answer for the phone number is not_eligible; the message lists the reasons Meta reported. Resolve them, request a new check with POST /v1/groups/eligibility-checks, and retry once GET /v1/groups/eligibility no longer answers not_eligible. Any other eligibility answer admits the create. On the same route, sender_phone_number_unavailable (400) means the phone number cannot host a group: it is not an active WhatsApp phone number linked to Meta, for example because it was released or disconnected.
  • A WhatsApp Business group (beta) can also carry a failure.code that no HTTP response returns: the route answers 202, a Tyxter worker does the work, and the failure is recorded on the group (failure on GET /v1/groups/{group_id}) with failure.message saying why. It is also on the group's group.create_failed, group.delete_failed or group.invite_link_reset_failed event, except in two cases. A delete call to Meta that fails without a definite answer (for example a timeout or a Meta server error) is recorded on the group only: the group stays deleting with failure.code provider_error, and no group.delete_failed or group.deleted event is produced unless Meta later reports the outcome, so read the group instead of waiting for an event. An invite-link reset that fails while the group is deleting is reported only on its group.invite_link_reset_failed event, and the group keeps the delete's failure. group_provider_rejected means Meta refused the create, the delete or the invite-link reset; failure.message carries Meta's reason and failure.provider Meta's error detail. A refused create leaves the group failed (fix what Meta's reason names and create a new group), a refused delete returns it to active (delete it again once the cause is fixed), and a refused reset keeps the stored invite link (reset again). provider_error means a delete or invite-link reset call to Meta failed without a definite refusal (for example a timeout; a delete then stays deleting), Tyxter sent a reset but could not record Meta's answer, Tyxter could not send a delete or a reset at all (for example the environment has no connected Meta provider or its stored Meta credentials cannot be read; an unsent delete returns the group to active with a group.delete_failed event, so delete it again once the cause is fixed), or Tyxter could not send a create because the environment has no usable Meta connection or its stored Meta credentials cannot be read. After a reset that failed without a definite refusal or whose answer was not recorded, the stored invite link may no longer work; reset again. A create the worker could not send also ends failed with sender_phone_number_unavailable (the phone number can no longer host a group, for example it was released or disconnected after the create was accepted) or group_phone_number_not_eligible (its stored eligibility answer became not_eligible); create the group again once the cause is fixed. A create left unsent for any other reason (for example the worker cannot reach Tyxter's database) stays pending. failure.provider is null for a create or reset Tyxter did not send. The WhatsApp Business groups API reference lists every case.
  • invalid_cursor is 400 validation_error from a cursor-paginated list such as GET /v1/groups when the cursor is malformed; pass next_cursor back unchanged, or start again without a cursor.
  • invalid_query is 400 validation_error from a list such as GET /v1/phone-numbers, GET /v1/billing/phone-management or GET /v1/billing/phone-renewals when a query parameter fails validation, for example an unknown status value or a limit out of range; error.message names the failed check. Fix the parameter and retry.
  • billing_whatsapp_required is a production-only 403 authorization_error from phone rental order creation (POST /v1/phone-numbers/orders and a production POST /v1/phone-numbers/provision) when billing-alert consent is required; complete the owner verification in the dashboard.
  • idempotency_key_in_progress means an identical write is still being processed; wait and replay the same request with the same key rather than creating a new operation.
  • llm_cost_cap_reached means the configured daily LLM cost cap was reached; the completion is rejected before a provider call and llm.cost_cap_reached is emitted once per crossing.
  • media_asset_not_downloadable means the requested media asset has no servable bytes; use a ready or consumed asset instead of retrying the same download request.
  • media_storage_not_configured means this deployment has no object-storage adapter; an operator must configure storage before storage-backed media operations can succeed.
  • meta_phone_number_already_connected means the Meta phone is already connected to another Tyxter environment; disconnect it there before registering it again.
  • meta_waba_missing means Meta did not provide exactly one WhatsApp Business Account during setup; select one account and retry setup.
  • payment_provider_disabled means the environment's selected payment provider is unavailable; configure an offered payment connection before retrying.
  • phone_number_limit_reached means the organization has reached its Tyxter-provisioned production-number limit; release a provisioned number or request a higher limit.
  • phone_renewal_not_found means the requested renewal is outside the caller's organization, project, or environment; inspect the caller's own renewal list instead.
  • invalid_provision_request is 400 validation_error from POST /v1/phone-numbers/provision and POST /v1/phone-numbers/orders when the body is malformed: ddd must be a 2-digit Brazilian area code, display_name at most 255 characters, an order's purpose at most 500, and no other field is accepted. error.param names the field; correct it and retry.
  • phone_number_order_not_found is 404 not_found from GET /v1/phone-numbers/orders/{order_id} when the order is unknown or belongs to another organization, project or environment; the two cases are intentionally indistinguishable. Use the id POST /v1/phone-numbers/orders returned, with a key of the same project and environment.
  • phone_number_rental_preview_unavailable is 503 service_unavailable from POST /v1/phone-numbers/orders and a production POST /v1/phone-numbers/provision when the rental price or a charge date cannot be determined right now. No order was created and nothing was charged. The cause is on our side: retry later, and report it through the error.feedback pointer the error carries.
  • phone_number_rental_approval_required is 409 conflict from a production POST /v1/phone-numbers/provision. The request created a rental order awaiting approval, as POST /v1/phone-numbers/orders does, and nothing was rented or charged. error.details.order always carries the order's id, approval_url and expires_at, and the error carries the error.feedback pointer. Hand approval_url to an organization owner or admin before expires_at; no API key can approve an order: each production number is a phone line held in the organization's name, so a person there, an owner or admin, must authorize the rental. Then read GET /v1/phone-numbers/orders/{order_id} about once a minute until it reaches a final status: fulfilled (the number became active), failed (provisioning failed after the approval), declined or expired. The 409 already created the order, so do not also create one with POST /v1/phone-numbers/orders: that would be a second order, and both requests spend the shared provision rate limit. A replay of the same Idempotency-Key spends a rate-limit token and always answers the 409 stored at creation, whose approval_url and expires_at are the order's at that moment, so take the order's current status from GET /v1/phone-numbers/orders/{order_id}, not from a replay; without a key every request creates a new order. Sandbox provisioning never returns it. Migration note (October 2026): a production POST /v1/phone-numbers/provision used to rent the number at once and answer 202; it now answers this 409, with no deprecation window. Reading the order needs the phone_numbers:read scope, which a key that only had phone_numbers:write for provision lacks. A replay of an Idempotency-Key first used before the change still returns its original 202, and numbers rented before the change need no approval.
  • phone_number_order_declined, phone_number_order_expired and phone_number_order_terms_changed are the failure.code of a rental order read from GET /v1/phone-numbers/orders/{order_id}. phone_number_order_declined: an owner or admin declined the order. phone_number_order_expired: nobody approved it within 24 hours of its preview. phone_number_order_terms_changed: an approval attempt found that the price, a charge date or the rental terms had changed since the order was created; the change is detected only when someone attempts the approval, so until then a read still shows awaiting_approval. Each ended order rented and charged nothing; create a new order and hand its approval_url to an owner or admin.
  • sandbox_payment_status_sandbox_only: The sandbox payment status fixture was called with a production key. Use only sandbox environments.
  • sandbox_payment_status_terminal: A sandbox payment is already terminal and cannot be changed to a different terminal status.
  • template_generation_not_configured: Platform-owned template generation credentials are missing.
  • template_generation_provider_error: Template draft provider call failed. Safe to retry with the same idempotency key.

Structured WhatsApp phone recipients use stable validation codes: invalid_phone_country_calling_code, invalid_phone_national_number, and invalid_phone_e164. These are non-retryable until the structured phone fields change. The compatible singleton recipient.id form accepts only an optional leading +, ASCII spaces (U+0020), parentheses, dots, and hyphens around 8–15 ASCII digits; unsupported formatting returns invalid_phone_e164 with param: recipient.id.

API-key create validation uses invalid_api_key_request (400 validation_error) when the request is malformed. Correct the request fields and retry. On public POST /v1/api-keys, an optional project_id must be non-empty; the API keys reference defines the same-organization target-project rules.

Template parameter preflight code: template_param_mismatch is a synchronous 400 validation_error when modeled requirements of the resolved approved TemplateVersion do not match the request. Correct the precise param (for example message.template.variables, message.template.components, message.template.header_media, recipients.0.variables, or template.header_media) and resend. POSITIONAL BODY bindings require exact numeric keys; NAMED BODY bindings require exact parameter-name keys. NAMED text HEADER and dynamic URL-button values use explicit text parameters with matching parameter_name. On a batch, inline recipient variables cover modeled BODY values in either format, but text-HEADER and dynamic URL-button parameters have no batch source; an audience has no per-recipient variable source at all. Those cases return param: template. No message was accepted, so there is no provider_error; the request also creates no batch, idempotency record, billable work, queue job, or provider attempt. The same code can still be recorded later on an accepted message when approval changes or Meta rejects an unmodeled provider detail; that asynchronous case is documented on the delivery-errors page and includes the failed message's provider_error. For an accepted send, Meta 132001 maps to template_not_approved, the terminal receipt. Meta cannot resolve the exact template name/language as approved in the sending WhatsApp Business Account, so check that exact name/language and approval before sending again. Meta does not identify why it could not resolve the template. Compatibility note (August 2026). Historical 132001 rows may carry template_param_mismatch; new receipts carry template_not_approved.

Template authoring code: template_variable_position_invalid is a synchronous 400 validation_error when a marketing or utility template is authored with a BODY component whose trimmed text starts or ends with a format-valid variable — {{n}} for POSITIONAL or {{name}} for NAMED. It answers create, merged PATCH, and a duplicate carrying a category or format override; a duplicate that supplies a category or parameter_format that differs from the source template is checked against the merged shape. error.param has the form components.0.text. Add wording on the offending side. Only BODY outer edges are checked and invisible characters cannot mask them. New authentication templates have no customer-authored BODY text: Meta's fixed OTP components are validated earlier; the pure checker exemption is only for legacy stored authentication JSON. Submit snapshots rather than re-validates the stored template.

Template authoring code: template_named_placeholder_unsupported is the stable compatibility 400 validation_error when a default or explicit POSITIONAL BODY or text HEADER uses a named token such as {{full_name}}. It carries error.param such as components.0.text and tells the caller to set parameter_format to NAMED with exact named examples, or to rewrite sequential POSITIONAL tokens and examples. A valid NAMED template is supported in every category; this code does not reject it. The compatibility check is judged before variable placement and remains category-independent.

account_suspended is a stable error.code returned as 403 authorization_error at every auth boundary when the organization is suspended (trust-and-safety / abuse enforcement). It is NOT retryable — retries will not clear it and only burn rate-limit tokens. There is no API recovery path: a dashboard owner or admin must contact support to restore access. Treat it as a hard stop, not a transient failure. Preflight reads such as GET /v1/account fail with this code while suspended, so an agent that hits account_suspended should stop and hand off to the dashboard owner rather than poll the operation.

feature_disabled is a stable error.code returned as 403 authorization_error when an organization owner has switched a whole capability family off for the environment. Seven families can be disabled — ai, payments, agentic_payments, media, broadcasts, provisioning, and groups (WhatsApp Business groups) — and the block applies to API keys and to product MCP tool calls, in sandbox and production alike. It is evaluated at the environment level before any resource lookup, so it never reveals whether a record exists. Do not confuse it with insufficient_scope: that one means this key is too narrow and a better key fixes it, while feature_disabled means no key will work until an organization owner or admin re-enables the family in the dashboard, so retrying with broader scopes fails identically. Reads always pass, and wind-down mutations — cancel, revoke, release, delete — are never blocked, so a disabled family can always be inspected and unwound. There is no public API for changing a feature control; administration is dashboard-session only. Full detail on the feature-controls page.

Phone-number capacity codes are two different limits with two different fixes. 402 plan_phone_limit_reached means the organization is at its subscription plan's production phone-number allowance, which counts active production numbers of both kinds — provisioned by Tyxter and brought from your own Meta account (Free 1, Standard 3, Growth no fixed limit). Upgrade the plan or release a number; it answers POST /v1/phone-numbers/provision, POST /v1/phone-numbers/orders, POST /v1/phone-numbers/connect and POST /v1/phone-numbers/import-salvy alike. 409 phone_number_limit_reached is the narrower one: the organization already holds the maximum number of concurrently active production numbers that Tyxter provisions for it (1 on Free, 3 on Standard, 10 on Growth by default), so only POST /v1/phone-numbers/provision and POST /v1/phone-numbers/orders can return it, and connecting a number from your own Meta account never does. Release a Tyxter-provisioned number to free its slot, or contact us and we will raise the limit for your organization. Both are production-only — sandbox provisioning and sandbox orders are capped by neither — and the plan allowance is evaluated first, so an organization at both limits sees the 402. A production order is checked against both limits when it is created and again when an owner or admin approves it.

billing_whatsapp_required is a production-only 403 authorization_error from POST /v1/phone-numbers/orders and POST /v1/phone-numbers/provision only when the default-off PHONE_PROVISIONING_REQUIRE_BILLING_WHATSAPP deployment policy is enabled. A current organization owner or admin must verify their WhatsApp identity through the dashboard and explicitly consent to transactional billing alerts. This is an operational billing contact, not the legal contact phone stored in KYC; a changed WhatsApp identity or departed member must consent again. Follow the organization-targeted dashboard URL in the error. The check runs before a rental order is written and again when an owner or admin approves the order. Existing rentals remain valid, sandbox provisioning is not gated, and POST /v1/phone-numbers/connect (BYON) is unaffected.

phone_renewal_not_found is a 404 not_found from GET /v1/billing/phone-renewals/{cycle_id}. The cycle is missing from the API key's exact organization, project, and environment, or belongs to another scope; these cases intentionally return the same response. Do not probe different ids or scopes. GET /v1/billing/phone-renewals and GET /v1/billing/phone-renewals/{cycle_id} are deprecated: for a number's current month, read the renewal field of GET /v1/phone-numbers/{id}.

Webhook listen-session recovery codes: 409 webhook_listen_session_active_exists means this production environment already has an active listen session. This conflict response requires error.details.active_session.{id, expires_at, created_at}; it is not generic provider or row data. Outside grace, only the owning API key may reuse and poll that id. With 30 seconds or less remaining, only that owner may POST /v1/webhook-events/listen-sessions again and must use the returned new id because replacement disables the old one. A sibling must not poll that id or loop POST: it waits until error.details.active_session.expires_at or coordinates with the owner to disable. This conflict does not consume a create rate-limit token. webhook_listen_session_required means a production listen or retrieve request omitted a current listen_session_id: the owning key creates or recovers a session and passes its id; sandbox keys listen directly.

Payment provider option mismatch code: payment_provider_options_unsupported means the provider_options block does not match the active payments provider. The connectionless sandbox_default mode cannot accept provider_options.abacate_pay — its deterministic fixture does not emulate Abacate Pay checkout — and any provider_options block sent for a provider other than the active one raises this code. Register and select the matching sandbox connection, then re-read GET /v1/provider-connections/status and send only the option block matching channels.payments.active_mode. The status read requires provider_connections:read; a key without that scope receives 403 insufficient_scope.

Payment provider availability code: payment_provider_disabled (400 validation_error, param provider) means the environment's payment connection resolves to a provider the platform does not currently offer. It is not retryable — configure an Abacate Pay payment connection for the environment instead.

Code-only WhatsApp signup recovery: meta_waba_missing (400 validation_error, param waba_id) means Meta's successful token inspection identified zero or more than one WhatsApp Business Account after the browser relay omitted the WABA. It is retryable only after sharing exactly one account in Meta, then retrying Finish setup on the pending connection. Tyxter keeps the encrypted credential for that retry but never returns candidate account IDs or raw Meta responses.

WhatsApp phone ownership conflict: meta_phone_number_already_connected (409 conflict, param phone_number_id) means the Meta phone is already connected to another Tyxter environment. The response never names the other organization, project, or environment. Sign in to the environment that owns the number and disconnect it before retrying registration, OAuth completion, or Finish setup. An OAuth attempt remains pending with this stable error_code instead of surfacing a database error or 500.

WhatsApp account suspension code: provider_connection_policy_suspended (409 conflict) means Meta acted against the WhatsApp Business Account itself — banned it, rejected it at account review, restricted it, or wrote the legacy scheduled_for_disable suspension reason — so the connection is suspended. The credential operations refuse it: rotating the access token at POST /v1/provider-connections/:id/rotate, registering again at POST /v1/provider-connections/meta, and finishing a stalled registration at POST /v1/provider-connections/:id/meta/complete-registration all answer this code. It is not retryable with a different token, because the stored credential is not what Meta acted on — appeal the account at business.facebook.com/accountquality, and the connection returns to connected once Meta clears it. The reasons app_uninstalled and account_deleted alone permit connecting again, and an eligible successful connection clears that suspension; definitive or remembered identity enforcement can still refuse admission. A connected connection with current SCHEDULE_FOR_DISABLE evidence is different: its nullable waba_ban_date is advisory and sends continue until Meta actually disables the account.

WhatsApp account suspension, delivery path: provider_connection_suspended is the same account state as provider_connection_policy_suspended reached from the other side. It is not an API response code — it is recorded on the message as error_code, readable at GET /v1/messages/{message_id} and delivered inline on the message.failed webhook, and it is stamped before any provider call, so a send on a connection Meta has acted against fails immediately instead of being attempted. The message text names what Meta did and the single move that helps: appeal the account at business.facebook.com/accountquality when it was banned, rejected at account review, restricted, or legacy-suspended for disablement, and reconnect WhatsApp from the dashboard when the suspension is app_uninstalled or account_deleted, where connecting again really is the remedy. Read suspension_reason on the connection (GET /v1/provider-connections/{connection_id}) for the machine-readable reason, and the whatsapp channel's reason on GET /v1/provider-connections/status, which reports policy_suspended for this case and token_suspended when the credential merely died. Do not confuse it with provider_connection_unauthorized: that one means the stored credential expired or was revoked and the message carries an organization-targeted /connect-whatsapp?organization_id=… link for re-authentication, while here re-authenticating provably changes nothing, because the credential is not what Meta acted on. Retrying the send cannot succeed until the account state changes. A connected SCHEDULE_FOR_DISABLE row is advisory instead: its nullable waba_ban_date means sends continue until Meta actually disables the account. Full detail on the delivery-errors page.

Meta identity restriction: meta_identity_enforced (409 conflict) means Meta reports a banned or review-rejected WhatsApp Business Account, or Tyxter remembers an authoritative business or phone match banned within the preceding 30 days in your organization. The affected Meta connection cannot proceed. Your Tyxter account and dashboard remain available. Resolve the identity's standing and available review options in Meta Business Support Home. Do not repeatedly reconnect it while the restriction is unresolved. This refusal does not promise a Tyxter-managed appeal or reinstatement.

WhatsApp signup reauthorization code: meta_connection_reauthorization_required (400 validation_error, param waba_id or phone_number_id) means Meta could not see the WhatsApp Business Account or phone number with the authorization granted during signup — Meta error code 100 with subcode 33, typically after the account owner changed devices or Meta restricted the asset. It is not retryable with the same authorization. The asset may be deselected, inaccessible or restricted by Meta. Review its standing in Meta Business Support Home first; after any restriction is resolved, reconnect from the dashboard Connections page and grant access again.

Hosted Meta signup session codes cover POST /v1/meta-signup-sessions and GET /v1/meta-signup-sessions/{session_id}, the embeddable flow described on the provider-connections page. Two are 400 validation_error and neither is retryable until the request or the key changes: meta_signup_sandbox_unsupported (param environment_id) means the API key is a sandbox key, and hosted signup is production-only because sandbox WhatsApp uses the Tyxter simulator instead of real Meta credentials — retry with a production key; invalid_meta_signup_session_request covers both endpoints — on create it means the body failed validation, or return_url is not an allowlisted origin, carries credentials, carries a URL fragment, or is not HTTPS in production, and on the read it means the path id is not a well-formed session id (param session_id). A rejected origin and an origin your organization has not been granted return the identical error on purpose, so send an origin your account manager has allowlisted rather than probing variants. Creating without the required Idempotency-Key header is the usual idempotency_key_required, and replaying one with a changed body the usual idempotency_key_conflict — neither is signup-specific. Two are 409 conflict: on POST /v1/meta-signup-sessions, meta_signup_connection_exists means the environment already has a non-disconnected Meta WhatsApp connection — use dashboard Embedded Signup to repoint the existing connection without disconnecting or releasing it, or use a different environment for a new hosted session; meta_signup_session_pending means work is already under way for that environment, raised both by create when an active session already exists and by the hosted page when a completion is in progress — poll the existing session rather than creating another, or wait for it to reach expired after its 30-minute lifetime (a completion already running at that moment keeps it processing for up to 10 more minutes, and create keeps answering this code until the session settles on completed or expired).

Two more hosted signup codes come from the read path and the deployment. meta_signup_session_not_found is 404 not_found for an unknown session id, one outside the calling key's organization and environment, or one already past the retention window — sessions are operational records, not permanent ones, so treat a 404 on an old id as expected rather than as data loss. It is not retryable, and the identical answer for cross-tenant ids is deliberate so session ids cannot be probed. meta_signup_host_not_configured is 503 service_unavailable: hosted signup is switched off for this deployment because no host URL or return-origin allowlist is configured, or the host URL is not HTTPS in production. No request body can clear it, so this one is retryable, but only after an operator fixes the deployment — retry the same request unchanged. It deliberately carries no Retry-After header and no retry_after_ms, because an operator fix has no expressible ETA, so apply your own backoff instead of waiting for a hint.

Media storage deployment code: media_storage_not_configured is 503 service_unavailable when this deployment has no object-storage adapter. Storage-backed media operations cannot succeed until an operator configures storage: POST /v1/media/uploads, POST /v1/media/uploads/{asset_id}/complete, GET /v1/media/{asset_id}/download-url, DELETE /v1/media/{asset_id}, both capability-token /v1/media/blobs/{token} methods, POST /v1/messages when its body carries media.inline, POST /v1/sandbox/inbound-messages when its body carries type "media", and DELETE /v1/contacts/{id}. Nothing in the request can clear the condition. Retry the same idempotent operation unchanged only after the deployment is fixed; no Retry-After header or retry_after_ms is sent because there is no machine-known repair time. Contact erasure is the one conditional member of that list: it checks storage only after finding an inbound attachment whose bytes may still exist, so erasing a contact who never received media succeeds on a deployment without storage, while one who did fails with this code rather than reporting bytes gone that are not.

Media download code: media_asset_not_downloadable is 400 validation_error (param asset_id) on GET /v1/media/{asset_id}/download-url, and means the asset is yours but its status has no servable bytes — pending, so the upload never completed; failed, so an inbound download exhausted its attempts; expired; or deleted, which is also the answer for an asset whose bytes were erased on a data-subject request. It is not retryable, because nothing about the request can change the asset's status. Only ready and consumed assets have downloadable bytes, and every other state answers a stable code rather than a URL. Do not confuse it with two neighbours on the same route. media_asset_expired, also 400 validation_error, is a different state, not a synonym: the asset is still ready or consumed and is past its own expires_at, so it was downloadable and its retention window closed. media_asset_not_found is 404 not_found and means no asset with that id exists in this environment — including one owned by another organization, which answers identically so ids cannot be probed, and including a Meta inbound image.id or audio.id, which is a provider handle rather than a Tyxter media asset id. Branch on the code, never on the status text in the message. These are API response codes on the download route; the same-named media codes listed above for the delivery path are stamped on the message instead and never appear in a response envelope. The dashboard preview surface reports these states as media_asset_not_previewable, which is not a public API code — do not branch on it.

Typing indicators

Typing requests use POST /v1/messages/{message_id}/typing and return stable target errors before enqueue: typing_indicator_target_not_inbound (400) requires an inbound message, typing_indicator_unsupported_channel (400) requires WhatsApp, and typing_indicator_target_expired (400) requires a target received within 30 days. typing_indicator_target_unavailable (409) means the target lacks its provider reference; use a normal reply or simulate a fresh inbound message. message_not_found (404) hides missing and out-of-scope targets. The receiving phone also needs current service: phone_service_suspended (403) means restore funding or its connection; sender_phone_number_unavailable (400) means it cannot be resolved to an available sender. Managed sandbox phones follow the same policy; unbound simulated recipients remain supported. Dispatch checks service again, so a later refusal drops the indicator without a failure webhook. Each accepted request independently asks to show the indicator, including after an earlier indicator expires; the route does not support Idempotency-Key.

Transcription recovery

Transcription request codes cover POST /v1/messages/{message_id}/transcription, POST /v1/messages/{message_id}/transcription/retry, and GET /v1/messages/{message_id}/transcription. invalid_transcription_request is 400 validation_error and means the strict request body is invalid; optional fields are a two-letter ISO 639-1 language hint and bounded prompt/keywords context. transcription_source_not_audio is 400 validation_error and means the message is not a stored inbound WhatsApp audio attachment in consumed state — for example it is outbound, another channel, still downloading, or its inbound download failed. Correct the request or wait for a still-downloading attachment; do not retry blindly. media_asset_expired is different: it is 400 validation_error only when the exact original audio remains the valid consumed source but its retention window lapsed. phone_service_suspended is 403 authorization_error on create and a fresh-key retry when current inbound phone-service authority denies newly starting paid work; a completed receipt or accepted idempotent replay retains its existing result. An unknown or out-of-scope message id answers message_not_found without disclosing ownership.

Webhook endpoint test codes: webhook_endpoint_not_found is the non-enumerating 404 not_found response when the endpoint is absent, deleted, or outside the caller organization and environment. webhook_endpoint_disabled is 409 conflict: repair the receiver and PATCH that endpoint to status: active before a fresh direct test. A persisted one-shot webhook.test event cannot be replayed; single resend returns 409 webhook_test_not_resendable, and the replacement is POST /v1/webhook-endpoints/{id}/test, not another resend. Bulk resend skips directly selected test events and status selections exclude them; an explicit event_type: webhook.test selection returns 400 invalid_bulk_webhook_resend. The test route is endpoint-scoped at five requests per minute; a 429 rate_limited response includes Retry-After and error.retry_after_ms.

Webhook listen code: webhook_listen_event_not_found is the non-enumerating 404 not_found response for an absent or unavailable listen event, including one outside the caller's organization, project or environment, outside a valid production listen session's event window, or omitted by automatic forwarding suppression. Check the event id, caller scope and production session window. Service recovery does not make an automatically suppressed source available to listen.

The create operation has intrinsic state replay, not an idempotency key. A pending or succeeded receipt replays when supplied normalized intent matches the newest generation. A different language returns 409 transcription_language_conflict; different prompt or keywords return 409 transcription_hints_conflict. Omitted fields replay unchanged. A failed receipt returns 409 transcription_retry_required and the create route never starts another provider attempt. Send POST /v1/messages/{message_id}/transcription/retry with a non-blank Idempotency-Key to reopen that failed receipt. 400 idempotency_key_required means the header is missing, blank, or whitespace. Reuse the same key and body to replay the stored 202; the same key with another body returns 409 idempotency_key_conflict. 404 transcription_not_found means create the receipt first. 409 transcription_in_progress means it is already pending, 409 transcription_already_completed means it has already succeeded, and 409 transcription_source_unavailable means the original source is deleted, redacted, no longer consumed by this message, or otherwise structurally unavailable. The last condition is not the same as expired audio, and neither condition is repaired by uploading a replacement attachment. 429 transcription_retry_rate_limited carries both error.retry_after_ms and Retry-After; wait, then replay the same logical retry command with the same Idempotency-Key. Use a fresh key only for a distinct retry command.

Three transcription outcomes are stamped on the transcript row instead of being returned as an envelope, so read them as error_code on GET /v1/messages/{message_id}/transcription with status "failed", never as an HTTP response. transcription_failed means the speech provider rejected or could not complete the audio after bounded retries, including audio the provider refused as too large; its message distinguishes the cases. transcription_not_configured means this environment has no speech-to-text provider configured, so no retry helps until an operator configures one. transcription_source_not_audio can also appear here with a different meaning than the request-time code: the attachment stopped qualifying between acceptance and the worker run. A failed receipt now has the explicit retry path above; that path preserves its original source and bounded rate limits, so it does not double-bill a stored idempotent replay.

A BYOK run that cannot use its pinned configuration fails closed with stt_provider_config_not_found, stt_provider_config_disconnected, stt_provider_config_invalid, or stt_provider_credential_unreadable. stt_provider_config_disconnected reaches only a transcription that was already attributed to the credential you disconnected. Disconnecting removes your override, so every transcription that was not — including one still queued — runs on Tyxter's platform credential and the stt.audio.second meter instead. Missing effective pricing is stamped as transcription_billing_rate_unavailable. An unexpected config encountered while the deploy-coordinated BYOK rollout fence is still inactive is stamped as transcription_byok_runtime_inactive. These are also failed-transcript receipt codes rather than HTTP envelopes. They perform no audio read or provider call, emit no usage, and never switch a run that is already using your credential over to Tyxter's platform credential. Fix the configuration, pricing, or deployment condition, then create a distinct manual retry.

The same transcription_byok_runtime_inactive code is a synchronous 503 service_unavailable on the public openai.stt provider-credential setup create and completion paths while a deployment is between its safe migration and activation phases. It is raised before body validation or provider verification and creates no setup/idempotency mutation, credential-validation attempt, or provider call. Retry the same request and Idempotency-Key after the deployment operator completes activation. Dashboard STT writes can also return 409 stt_provider_config_busy when their bounded two-second database coordination wait expires; replay the exact command with the same idempotency key. The failed reservation is released only for this proven pre-persist contention, so the retry can enter normally.

Inbound attachment failure codes are stamped on the inbound media asset, not returned as an envelope. Read them as failure.code inside data.content.media on the message.received webhook, alongside a human-readable failure.message. inbound_media_download_failed is the residual bucket for a provider download that kept failing or bytes that could not be written after the bounded retries. It no longer includes a known quota refusal. media_storage_quota_exceeded is the same registered condition uploads return, reused on the inbound descriptor when the environment cannot reserve the attachment bytes. That failure is terminal on the first worker attempt because capacity cannot clear without customer action; the second and third provider downloads do not run. In both cases the message itself is still delivered — the body is never withheld because its attachment did not arrive — and the asset_id stays stable, so the descriptor remains matchable on replay. There is no retry endpoint for the download. For media_storage_quota_exceeded, inspect GET /v1/media/storage-usage, free room by deleting an existing asset with DELETE /v1/media/{asset_id} or ask for a higher quota, then ask the sender to resend. Branch on failure.code, never on failure.message.

Provider credential setup sessions

The hosted credential setup flow (POST /v1/provider-credential-setup-sessions and its complete, deny, and get routes) has a small lifecycle vocabulary. An unknown or foreign-tenant session id returns 404 provider_credential_setup_session_not_found. A session past its deadline returns 409 provider_credential_setup_session_expired; create a new session rather than replaying the old link. Completing or denying a session that already finished returns 409 provider_credential_setup_session_already_completed or 409 provider_credential_setup_session_already_denied, and completing one the customer denied returns 409 provider_credential_setup_session_denied; none of these change state, so read the session to see where it landed. Completing a session whose target the environment no longer has configured returns 409 provider_credential_setup_target_not_configured. Creating an openai.stt setup session on a sandbox environment returns 400 provider_credential_setup_stt_sandbox_unsupported — sandbox transcription is simulated and never uses a customer credential.

Three codes belong to the browser half of the flow. Your backend never handles them directly — the hosted page does — but they appear in support traces. invalid_meta_signup_session_token is 401 authentication_error and means the launch bearer is missing or wrong, the session id is unknown, or the API key that created the session has since been revoked or expired; note that a correct bearer on a session whose 30 minutes have elapsed is not this code. That case is meta_signup_session_expired, 409 conflict, because the bearer is checked before the clock. meta_signup_session_already_completed is 409 conflict and is narrower than it sounds: simply retrying a completion that already succeeded returns 200 with the original result, so this code fires only when a concurrent completion won the race for the same session between the Meta exchange and the write. In every case the durable completion signal your server should act on is the signed meta_signup_session.completed webhook, not any of these.

The following delivery-path codes can be stamped before any provider call — read these receipts as error_code at GET /v1/messages/{message_id} or inline on the message.failed webhook, and expect no provider_error, because nothing was sent. no_provider_connection means the environment has no WhatsApp connection at all; connect WhatsApp from the dashboard and resend, and do not confuse it with provider_connection_unauthorized, provider_connection_suspended, or meta_registration_required, all three of which mean a connection does exist. sender_phone_number_unavailable also appears synchronously on typing as described above. On a failed message receipt it means an accepted message named a sending phone Tyxter cannot use; it does not use the connection primary as a fallback or reveal why the sender was unavailable, so choose an active WhatsApp phone linked to Meta and resend. invalid_template_payload means a template message reached the sender without both template.name and template.language; resend with both set. phone_number_rate_limit_exhausted means the send was rescheduled repeatedly because the sending phone number was over its throughput budget and was still over after the last reschedule — nothing about the message is wrong, so spread the traffic, send from more numbers, or raise the number's throughput. media_asset_resolution_failed is the residual media bucket: Tyxter could not resolve the media attached to the send and the failure carried no more specific cause, since the expected cases already have their own codes (media_asset_not_found, media_asset_expired, media_asset_not_ready, media_link_fetch_failed, invalid_media_mime_type, media_too_large). media_voice_requires_ogg_opus means a requested WhatsApp voice note resolved to known audio whose normalized MIME is not audio/ogg; use OGG/Opus mono audio or omit message.media.voice for ordinary audio. Tyxter does not inspect codecs or channels, or convert media. Full detail on the delivery-errors page.

Meta display-name delivery code: meta_display_name_not_approved is recorded on a failed message when Meta returns 131037 for a WhatsApp-provided (+1 555) number. It is a terminal receipt, not an API response, retry instruction, or phone-number pause: in WhatsApp Manager, go to Phone numbers and set the display name, then wait for Meta approval before sending again. The raw Meta detail remains in provider_error; full detail is on the delivery-errors page.

WABA send capability, payment: meta_payment_method_required means Meta health confirmed WABA code 141006, with no valid payment method. A fresh stored block ends a queued send before a Meta call, with provider_error: null. If a failed Meta send triggers reactive health confirmation, the same terminal receipt retains the original bounded provider_error. Add a payment method in WhatsApp Manager → Billing & payments, then try a new send. Full detail is on the delivery-errors page.

WABA send capability, inactive account: meta_waba_inactive means Meta health confirmed WABA code 141008, so the WhatsApp Business Account is inactive. A fresh stored block ends a queued send before a Meta call, with provider_error: null. If a failed Meta send triggers reactive health confirmation, the same terminal receipt retains the original bounded provider_error. It is not a connection or phone-number suspension, and inbound messages remain available. Reactivate the WABA in WhatsApp Manager or ask Meta support, then try a new send. Full detail is on the delivery-errors page.

WABA send capability, permission: meta_messaging_permission_missing means Meta refuses to let Tyxter send from this WhatsApp Business Account. It ends a message whose send Meta refused with HTTP 403, OAuthException and numeric code 200, and it also appears when Meta's account health reports code 141011. Meta returns that same refusal when the account has no payment method or is inactive; when Tyxter confirms either of those, you get meta_payment_method_required or meta_waba_inactive instead. A known account block can also end a queued send before Meta is called, with provider_error: null; otherwise the message keeps Meta's bounded detail in provider_error. The connection stays active, and media preparation, Instagram and later status updates are not reclassified. In Meta Business Settings, open Users > System users, select the Tyxter system user, and make sure it has full control of this WhatsApp account, including messaging. If it already does, reconnect WhatsApp with every requested permission checked, then contact Tyxter support if Meta still refuses. Full delivery detail is on the delivery-errors page.

Media link retrieval code: media_link_fetch_failed means Tyxter could not retrieve the file at the media.link you sent, so the message was never submitted to WhatsApp. It is not an API response code — it is recorded on the message as error_code, readable at GET /v1/messages/{message_id} and delivered inline on the message.failed webhook, the same place every other delivery failure surfaces. Causes are a host that does not resolve or refuses the connection, a target rejected as a private or internal address, a timeout, a non-2xx status, more than three redirects, or an empty body; transient causes are retried automatically and stamp the code only once attempts are exhausted. Do not confuse it with meta_media_download_failed, which means Meta could not fetch or use media handed to it — here no provider was ever contacted. Make the URL publicly reachable without authentication (https recommended, plain http accepted), serving the file bytes directly within 30 seconds, or skip URLs entirely by uploading at POST /v1/media/uploads and sending by asset_id. It applies to the media message type only — an interactive header carrying a link is delivered as a link, and flow headers are text-only. If the retrieval succeeds but the bytes are refused, you get invalid_media_mime_type (no Content-Type header, or one outside WhatsApp's accepted set for that kind) or media_too_large instead. Full detail on the delivery-errors page.

Delivery recovery code: provider_send_result_missing means either stale-send recovery found a message left in sending with a null provider message id and zero Meta attempts after its immutable creation age reached the effective queue-expiry bound, or a provider-media upload effect has no completion/cache receipt. While the provider-safe stale shape is strictly younger than the bound, Tyxter returns it to durable queued work and schedules another attempt after 60 seconds. The uncompleted upload effect fails immediately: another upload could duplicate paid provider work. If a Meta attempt started but no response or error was persisted, Tyxter leaves the message unreplayed because the provider outcome is ambiguous and another send could create a billed duplicate. Report repeated occurrences or a message that remains in sending with the message trace_id. It is a message error_code, not an API envelope code, and carries no provider_error.

TTS delivery codes are bounded. tts_provider_error means the configured speech provider rejected or could not complete the pre-send render after retries; provider name and an available status_code are preserved separately in the message provider_error. tts_render_failed means storage or another non-provider render step failed. Neither means WhatsApp rejected the send, because the message never reached WhatsApp.

Sandbox scenario failure codes are sandbox_delivery_failure, sandbox_recipient_opted_out, and sandbox_template_rejected. They are deterministic simulated terminal outcomes, carry no provider_error, and surface on the message read and message.failed webhook exactly where production delivery codes do.

Retry guidance

  • Treat 5xx and network errors as retryable. Use an idempotency-key on write paths that accept one — check /openapi.json, where an operation that honors the header declares an idempotency-key header parameter.
  • Never retry 4xx without fixing the request; an unchanged request only burns rate-limit tokens.
  • 429 responses include Retry-After in seconds and error.retry_after_ms in the body. Honor it; a fixed short sleep can cause cascading retries.

Phone list and detail remain available with phone_numbers:read during credit exhaustion. Read the roster without email, then use existing billing-scope recovery; provisioning, connecting and sending retain their credit checks.

Customer Salvy management policy

phone_service_suspended is the stable 403 authorization decision exposed by the phone-management service policy: a live customer Salvy attachment is disconnected, has changed credential generation, lacks current paid/funded authority, or is terminally fenced. It means an unstarted use must not begin; it does not alter the free conversion, disconnect, release, or recovery controls. Outbound message, batch, typing, message-bound render work, and inbound paid-effect admission use this policy. Automation materialization and every unstarted automation effect apply the same decision. Customer-webhook inbound forwarding also applies it: denied automatic forwarding stays suppressed after service recovery, and listen omits those sources. Explicit replay of retained history checks current policy without reviving automatic forwarding. Already-started deliveries may finish or be reclaimed after a crash. Provider-specific Salvy callbacks are separate from this customer-webhook forwarding policy.

Recipient marketing guards

Mandatory Tyxter guards apply to marketing in sandbox and production. Identical template name, language, parameters and media content remain blocked until an inbound message after the previous attempt; there is no timed duplicate expiry within retained message history. Reuploading identical bytes under a new asset ID does not change content. A read receipt is not engagement. Different content on a healthy sender is allowed. A seven-day sender/recipient cooldown applies only when phone or template quality is YELLOW/RED or a policy warning occurred within seven days. An inbound message after the latest marketing attempt exempts duplication and that cooldown, even after the 24-hour service window closes. General opt-outs and Meta marketing stop preferences take priority; only a Meta marketing resume clears that provider preference. Meta 131049 requires at least 24 hours before retrying even after engagement; two consecutive 131026 refusals or a latest 130472 holdout require new inbound engagement. Utility and authentication are outside these marketing guards; general consent still applies. HTTP 400 recipient_duplicate_message or recipient_frequency_capped includes error.details.guard_reason, error.details.next_allowed_at and error.feedback. next_allowed_at is null for pending sends or recovery requiring engagement or consent; never schedule a retry from null. Missing or unreadable policy, unclassifiable pending templates, or new marketing media without verified hashes returns 503 recipient_cadence_policy_unavailable. Legacy media equality without a retained hash remains unknown and is logged; no migration invents historic media equality. Queued templates are checked again before dispatch. Batch failure exports expose guard_reason and next_allowed_at when the failure receipt is retained. Recorded provider rejections count as attempts. Guard skips create no provider attempt or message-processing charge and do not renew the cooldown. Customers cannot disable or shorten these protections. They do not guarantee delivery or improved sender quality. Batch guard skips are included in recipient_guard_skipped_count and failed_count. Export failures for each recipient and recovery reason. Payment cancellation refusal: payment_cancellation_unsupported is 400 validation_error for transparent Pix or an unsupported merchant provider. No cancellation intent, provider call or fee mutation is accepted. The response carries error.feedback; hosted checkout cancellation is supported, while transparent Pix requires a provider-confirmed terminal outcome such as expiry.

Payment cancellation request validation: invalid_payment_request is 400 validation_error when the cancellation body is not an empty JSON object. payment_link_not_ready is 409 conflict while no provider payment ID is available; wait for provider link generation, then retry.

Meta 131050 is recorded as meta_marketing_opted_out: wait for the recipient to resume WhatsApp marketing for this business. Ordinary inbound and general API opt-in do not clear this preference. media_content_changed means uploaded bytes changed after completion; create and complete a fresh media upload.

Reactions

POST /v1/messages reaction targets must be in the caller's WhatsApp conversation. reaction_target_invalid (400) means the target is on another channel, has the wrong direction, or is itself a reaction; choose an inbound WhatsApp message. reaction_target_expired (400) means it is older than 30 days; choose a recent target. reaction_target_unavailable (409) means the target has no provider ID; retrying the same unavailable target does not repair it. Missing, foreign or different-conversation targets return message_not_found (404). The normal service_window_required rule also applies. All failures carry error.feedback. The same target codes can appear on a failed accepted reaction if its target becomes unavailable before dispatch.

Quoted replies

reply_target_unavailable (409) means reply_to.message_id identifies a message without a provider message ID. Choose a received inbound message or wait until an accepted outbound target has been sent. Missing, foreign, different-channel, or different-conversation targets return message_not_found (404). Both errors carry error.feedback and can also fail an accepted reply if its target becomes unavailable before dispatch.

Meta 131031 maps to meta_business_account_locked on failed messages. Appeal at Meta Account Quality; the account or its parent portfolio may be locked. See account-lock troubleshooting for 141014, warning checks, and sibling-WABA emails.

provider_connection_not_rotatable (409 conflict) means a disconnected Meta connection cannot accept a token rotation. Use connection registration when its Meta identity is eligible; token replacement does not reconnect a disconnected row. Rotation can also refuse meta_identity_enforced for definitive or remembered identity restriction.