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.
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.
The pointer is deliberately narrow. It appears only on an unhandled internal_error from a public /v1/* route that is not /v1/feedback itself. It is absent from every 4xx, from 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.
Categories
| Status | Type | When |
|---|---|---|
| 400 | validation_error | Your request doesn't match the contract. Fix and retry. |
| 401 | authentication_error | Missing, malformed, or revoked API key. The signature or HMAC failed. |
| 402 | payment_required | Credit balance exhausted or project spend cap hit. Top up or raise the cap. |
| 403 | authorization_error | The key is valid but the required scope is missing. |
| 404 | not_found | Resource doesn't exist — or exists in another organization. Always 404, never 403. |
| 409 | conflict / idempotency_conflict | State transition not allowed, or idempotency-key replay with a different body. |
| 429 | rate_limited | API-key or phone-number throughput bucket exhausted. Honor Retry-After and error.retry_after_ms. |
| 500 | internal_error | Something went wrong on our side. Safe to retry idempotent calls. |
| 502 | provider_error | Meta, Salvy, or a platform-owned generation provider returned an error. The message carries the normalized provider detail. |
| 503 | internal_error | Webhook receiver misconfigured (missing app secret). Ops issue. |
Common codes worth handling explicitly
| Code | Meaning |
|---|---|
credit_balance_exhausted | Org balance ≤ -5 BRL. Top up and retry. |
account_suspended | The organization is suspended (trust-and-safety / abuse enforcement). Returns 403 at every auth boundary. Contact support to restore access — retries will not clear it. |
spend_limit_exceeded | Project monthly cap reached. Next period or raise the cap. |
kyc_required | Production Salvy phone provisioning requires an approved business identity and none is on file (never submitted, or rejected). Submit it in the dashboard (Settings → Business identity). Returns 403. Sandbox is never KYC-gated. |
kyc_pending | Business identity submitted but not approved yet (review in progress or resubmission requested). The operation unlocks on approval. Returns 403. |
contact_opted_out | Recipient has opted out — send blocked. Do not retry without an explicit override. |
invalid_phone_country_calling_code | A structured WhatsApp recipient has an unknown country calling code. Correct the field before retrying. |
invalid_phone_national_number | A structured WhatsApp recipient has an invalid national number for its calling code. |
invalid_phone_e164 | The structured calling code and national number do not form a valid E.164 phone. Legacy recipient.id acceptance is unchanged. |
idempotency_key_conflict | Same idempotency-key, different body hash. Use a fresh key. |
invalid_flow_json | Flow JSON failed structural validation before submission. |
automation_inbound_number_conflict | Another enabled inbound auto-reply automation is already bound to this phone_number_id. Only one per number per environment. Returns 409. |
phone_number_confirmation_mismatch | POST /v1/phone-numbers/{id}/transfer body field confirm_phone_number_id does not match the route id. Returns 400. |
phone_number_transfer_same_environment | Transfer target environment equals the source environment. Pick a different target. Returns 400. |
phone_number_not_transferable | The number is in a status that cannot be transferred (for example mid-provisioning or terminal). Returns 409. |
phone_number_limit_reached | The organization already holds the maximum number of concurrently active Salvy-provisioned numbers (production only; deployment default 2). Release an existing number to free its slot, or contact support to raise the limit. Returns 409. |
phone_number_not_removable | Dashboard remove of a phone number that is not yet terminal. Only failed, disconnected, or released numbers can be removed — disconnect or release first. Returns 409. |
template_not_approved | Template send or cost estimate references a draft, rejected, paused, disabled, or missing approved template. Submit and wait for approval first. |
template_param_mismatch | An outbound template send was rejected by Meta because the supplied parameters do not match the approved template (count or format). The raw Meta detail is on the failed message’s provider_error (see GET /v1/messages/:id). |
provider_connection_unauthorized | Meta rejected the environment’s stored connection credential (HTTP 401 / OAuthException 190) — the token expired or was revoked, and the connection is suspended. Every send fails with this code until an owner/admin re-authenticates the Meta connection with a permanent System User token (dashboard Connections page or POST /v1/provider-connections/:id/rotate). |
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. |
payment_provider_options_unsupported | Provider-specific payment options do not match the selected payment provider connection — send options only for the provider the environment resolves to. The connectionless sandbox default fixture does not emulate Abacate Pay checkout; select an Abacate Pay sandbox connection before sending provider_options.abacate_pay. |
payment_provider_disabled | The payment provider the environment resolves to is not currently offered by the platform. Configure an Abacate Pay payment connection instead. |
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. |
llm_route_not_found | No LLM route is configured for this environment. |
llm_cost_cap_reached | Daily BRL cap on the LLM route hit. Raises 402. |
Retry guidance
- Treat
5xxand network errors as retryable. Use anidempotency-keyon write paths. - Never retry
4xxwithout fixing the request — you'll just burn rate-limit tokens. 429responses includeRetry-Afterin seconds anderror.retry_after_msin the body. Honor it; a fixed short sleep will cause cascading retries.