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.
Discovering the API from an error
When a request’s method and path match no route at all, the answer is a 404 whose code is route_not_found— and on a public /v1/* path that envelope also carries an additive, optional discovery pointer naming the two documents an agent can read to find what the API actually serves:
{
"error": {
"type": "not_found",
"code": "route_not_found",
"message": "No route matches GET /v1/mesages.",
"request_id": "req_...",
"trace_id": "trc_...",
"discovery": {
"openapi": "/openapi.json",
"well_known": "/.well-known/tyxter.json"
}
}
}Check the path you called against /openapi.json, which lists every operation the API serves; /.well-known/tyxter.jsonis the capability manifest — the API, dashboard, and docs base URLs plus what the platform offers and where each capability is documented. The messagenames the method and the path we matched against, with the query string stripped — a key you accidentally put in the URL is never echoed back to you. Both pointer values are constant relative paths on the API host, never derived from the request host.
The pointer is as narrow as the feedback one. It appears only on route_not_found, and only for /v1/*targets — never on any other error, and never on a dashboard or BFF route. The field is additive, so an older SDK that does not know about it simply ignores it. route_not_found is a statement about the path, not about a record: a route that exists whose resource does not still answers its own code, such as message_not_found. It is unrelated to llm_route_not_found, which means no LLM route is configured for the environment.
Framework-level refusals arrive in the same envelope. A malformed JSON body on a route that expects JSON answers http_exception with the typechosen from the status — so a 400 reports validation_error, never internal_error — and a body over the route’s size limit answers payload_too_large whether the size was declared up front or only discovered mid-stream.
Categories
| Status | Type | When |
|---|---|---|
invalid_project_body | 400 validation_error: a project create body or list query did not match the strict project contract. Check the Projects API reference rather than retrying unchanged input. | |
invalid_api_key_request | 400 validation_error: an API-key creation request is malformed. Correct the request fields and retry; an optional project_id on the public route must be non-empty. See the API keys reference for target-project rules. | |
organization_not_found | 404 not_found: the authenticated caller's organization no longer exists when creating a project. The request cannot select or reveal another organization. | |
project_not_found | 404 not_found: the project is absent, archived, or belongs to another organization. Those cases are deliberately indistinguishable. | |
project_slug_taken | 409 conflict: the requested project slug already belongs to another project in your organization. Choose a different slug. | |
| 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 — or the organization has switched that capability off for the environment. |
| 404 | not_found | Resource doesn't exist — or exists in another organization. Always 404, never 403. An unmatched route (method + path matching no operation) answers the same status with route_not_found. |
| 409 | conflict / idempotency_conflict | State transition not allowed, an idempotency-key replay with a different body, or a replay that arrived while the first request is still in flight. |
| 413 | validation_error | The request body is over the limit for the route (payload_too_large). Send less — retrying the same body cannot succeed. |
| 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 | service_unavailable / internal_error | A required setting is missing on our side, so the endpoint is switched off — nothing about your request can clear it. Retry the same request unchanged once we have fixed it; no Retry-After is sent. The separate internal_error shape covers a misconfigured webhook receiver. |
Common codes worth handling explicitly
| Code | Meaning |
|---|---|
feedback_report_not_found | 404 not_found: the requested feedback report is missing, purged, dashboard-origin, or outside the API key's organization, project, and environment. These cases are intentionally indistinguishable. |
route_not_found | 404 not_found: the method and path match no operation. Check the path against /openapi.json — on a /v1/*path the envelope’s discovery pointer names it for you. Distinct from the per-resource 404 codes such as message_not_found, which mean the route exists and the record does not, and from llm_route_not_found, which is about an unconfigured LLM route rather than an HTTP path. |
webhook_endpoint_not_found | 404 not_found: a requested webhook endpoint is absent, deleted, or outside the caller's organization and environment. Those cases are intentionally indistinguishable. |
webhook_endpoint_disabled | 409 conflict: a direct endpoint test was requested for a disabled endpoint. Repair the receiver, then PATCH the endpoint to status: active before creating a fresh test. |
webhook_test_not_resendable | 409 conflict: a one-shot webhook.test event cannot be resent. Use POST /v1/webhook-endpoints/{id}/test for a fresh, endpoint-scoped probe instead. |
payload_too_large | 413 validation_error: the request body exceeds the limit for the route. A declared Content-Lengthis refused up front at 10 MB — or at 100 MB on the capability-token media blob upload. A JSON body streamed without a Content-Lengthis held to that same 10 MB; only a non-JSON (binary) upload gets a higher streaming backstop. Every path reports this one code, and the declared figures are the contract — upload large files through POST /v1/media/uploads and send the asset id instead of inlining the bytes. |
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. |
feature_disabled | An organization owner switched a whole capability family off for this environment — ai, payments, agentic_payments, media, broadcasts, or provisioning. Returns 403 on API keys and on MCP tool calls, in sandbox and production alike, and is raised before any resource lookup so it never reveals whether a record exists. Unlike insufficient_scope this is notfixable by minting a broader key — no key will work until an owner or admin re-enables the family in the dashboard. Reads and wind-down actions (cancel, revoke, release, delete) are never blocked. See the /feature-controls page. |
media_storage_not_configured | This deployment has no object-storage adapter, so storage-backed media operations return 503 service_unavailable. No request field can clear it. Retry the same idempotent operation unchanged after an operator configures storage; no Retry-After is sent. |
media_asset_not_downloadable | GET /v1/media/{asset_id}/download-urlwas called on an asset that exists in your environment but whose status has no servable bytes — pending, failed, expired, or deleted, which is also what an asset erased on a data-subject request returns. Only ready and consumed assets have downloadable bytes, and every other state answers 400 validation_errorrather than a URL. It is not retryable, because no request change alters the asset’s status. Distinct from media_asset_expired, which means the asset was downloadable and is now past its own expires_at, and from media_asset_not_found (404), which means no such asset in this environment — including a Meta provider handle used in place of a Tyxter asset id. Branch on the code, not on the message text. |
spend_limit_exceeded | Project monthly cap reached. Next period or raise the cap. |
data_export_disabled | The environment disables data export (data-retention policy data_export_enabled is false), so POST /v1/contacts/{id}/export is blocked. Re-enable it with PATCH /v1/data-retention. Returns 403, raised before the contact lookup so it never reveals whether a contact exists. |
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. The LLM and AI-agent completion routes ignore trace_id when comparing bodies, so retrying one of those with a new trace ID replays instead of conflicting. |
idempotency_key_in_progress | Same idempotency-key and body, but the original request has not finished yet. Wait and retry the same key — do not mint a new one. If a key keeps returning this long after the original request should have finished, that request either completed but failed to save its response, or was interrupted mid-flight. The key stays reserved for the rest of the 24-hour default idempotency window rather than risk running the work twice. After expiry, the same key is reclaimable and executes fresh. If you cannot wait, mint a new key only when you intend to run the operation again (it may charge again). |
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 production numbers that Tyxter provisions for it — 1 on Free, 3 on Standard, 10 on Growth by default. Only the provision route returns it: connecting a number from your own Meta account never does, and sandbox provisioning is never capped. Release a Tyxter-provisioned number to free its slot, or contact us and we will raise the limit for your organization. Returns 409. |
plan_phone_limit_reached | 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. Production-only, and evaluated before the Tyxter-provisioned limit above, so an organization at both limits sees this one. Returns 402. |
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_variable_position_invalid | A marketing or utility template was authored with a BODY component whose trimmed text starts or ends with a format-valid variable ({{n}} for POSITIONAL or {{name}} for NAMED). Meta refuses that shape, but only after review — hours later, as a rejection_reason on template.rejected. Tyxter now answers it synchronously with 400 at authoring time instead, on POST /v1/templates, PATCH /v1/templates/:id, and POST /v1/templates/:id/duplicate when the duplicate supplies a category or parameter_formatthat differs from the source template’s. error.param has the form components.0.text and indexes the offending component; the message opens with Meta’s own wording. Add words before or after the variable — a variable anywhere in the interior of the body is fine, and so is one at either edge of a HEADER or FOOTER. Trimming removes invisible characters as well as whitespace, at the two edges of the text only, so a zero-width space or bidi mark cannot mask an edge variable. New authenticationtemplates have no customer-authored BODY text: they use Meta's fixed OTP components, and free-form text is rejected earlier as invalid_template_request. The pure checker keeps its exemption only for legacy stored authentication JSON. On a PATCH the rule is applied to the merged template, so error.paramcan name a component the patch itself did not send — a patch that changes category or parameter_format is the usual way to meet that. Two gaps are deliberate: a draft saved before this rule existed, and a duplicate made without either a category or parameter_format override, are not re-checked, so POST /v1/templates/:id/submit still accepts them and Meta may reject them asynchronously. |
template_named_placeholder_unsupported | A default or explicit POSITIONAL authoring request used a named token in a BODY or text HEADER, such as {{full_name}}. This stable compatibility code keeps itscomponents.0.text error.param and its corrective choice: set parameter_format to NAMED and provide exact named examples, or rewrite sequential positional tokens with positional examples. A validNAMED template is supported in every category; the code does not reject it. The rule remains category-independent and runs before variable placement. |
template_not_approved | Template send or cost estimate references a draft, rejected, paused, disabled, or missing approved template. For an accepted send, Meta 132001 maps to template_not_approved, the terminal receipt. Check the exact template name and language, then confirm it is approved in the sending WhatsApp Business Account 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_param_mismatch | For modeled approved-template requirements, this is a synchronous 400 validation error: correct the named param (for example exactPOSITIONAL numeric or NAMED BODY variables, explicit text-HEADER/dynamic URL components, or header media) and resend. On a batch, inline recipient variables cover modeled BODY values in either format; a required text-HEADER or dynamic URL-button parameter has no batch source, and an audience has no per-recipient variable source, so both return param: template. No message was accepted, so there is no provider_error. The same code can also be recorded later when Meta rejects an accepted send because approval changed or the provider behavior is not modeled; that asynchronous case includes the failed message’s provider_error (see GET /v1/messages/:id). |
media_link_fetch_failed | Tyxter could not retrieve the file at the media.link you sent, so the message was never submitted to the provider — the host was unreachable or rejected as a private address, the request timed out, the URL answered a non-2xx status, redirected too many times, or returned nothing. Like every delivery failure this is recorded on the message rather than returned on the request: read it as error_code at GET /v1/messages/:id or on the message.failed webhook. Distinct from meta_media_download_failed, where Meta did the fetching. See the /delivery-errors page. |
no_provider_connection | The environment has no WhatsApp connection at all, so there was nothing to send through. Like every delivery failure this is recorded on the message rather than returned on the request: read it as error_code at GET /v1/messages/:id or on the message.failed webhook. Connect WhatsApp from the dashboard, then resend — retrying without connecting cannot succeed. Distinct from the three codes that mean a connection does exist: provider_connection_unauthorized, provider_connection_suspended and meta_registration_required. See the /delivery-errors page. |
sender_phone_number_unavailable | An accepted message named a sending phone that Tyxter cannot use. This is recorded on the message rather than returned on the request: read it as error_code at GET /v1/messages/:id or on the message.failed webhook. No fallback number was used and no provider call was made. Choose an active WhatsApp phone number linked to Meta, then resend. See the /delivery-errors page. |
invalid_template_payload | A template message reached the sender without both template.name and template.language, so there was no template to resolve. Recorded on the message, not returned on the request. Resend with both fields set; it is not retryable unchanged. See the /delivery-errors page. |
phone_number_rate_limit_exhausted | The send was rescheduled repeatedly because the sending phone number was over its throughput budget, and it was still over after the last reschedule. Nothing about the message is wrong — it lost a queue for capacity. Recorded on the message, not returned on the request. Spread the traffic over a longer window, send from more numbers, or raise the phone number’s throughput. See the /delivery-errors page. |
media_asset_resolution_failed | Tyxter could not resolve the media attached to a send and the failure carried no more specific cause. The expected media problems have their own codes and never land here — media_asset_not_found, media_asset_expired and media_asset_not_ready for the asset, media_link_fetch_failed, invalid_media_mime_type and media_too_large for a media.link— so this is the residual bucket. Recorded on the message, not returned on the request. A repeat is worth reporting with the message’s trace_id. See the /delivery-errors page. |
media_voice_requires_ogg_opus | A requested WhatsApp voice note resolved to known audio whose normalized MIME is not audio/ogg. The message fails before a provider call and records this code on the message rather than returning it from the request. Meta requires OGG/Opus mono audio; Tyxter does not inspect codecs or channels, or convert media. Use OGG/Opus mono audio or omit message.media.voice for ordinary audio. See the /delivery-errors page. |
meta_display_name_not_approved | Meta rejected a WhatsApp-provided (+1 555) number because its display name is not approved. This is recorded on the failed message, not returned from the send request. In WhatsApp Manager, go to Phone numbers and set the display name, then wait for Meta approval before sending again. See the /delivery-errors page. |
meta_payment_method_required | Meta health confirmed WABA code 141006: no valid payment method is available. A fresh stored block ends a queued send before Meta is called, 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. See the /delivery-errors page. |
meta_waba_inactive | Meta health confirmed WABA code 141008: the WhatsApp Business Account is inactive. A fresh stored block ends a queued send before Meta is called, 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. See the /delivery-errors page. |
meta_messaging_permission_missing | Meta health confirmed WABA or app code 141011: the requested WhatsApp messaging permission is absent. A fresh stored block ends a queued send before Meta is called, with provider_error: null. If a failed Meta send triggers reactive health confirmation, the same terminal receipt retains the original boundedprovider_error. Reconnect and grant the requested permissions, then ask support if it persists. See the /delivery-errors page. |
meta_waba_missing | Meta received the OAuth code but the browser relay omitted its WhatsApp Business Account, and the token proved zero or more than one account. Share exactly one account in Meta, then retry Finish setup on the pending connection. Candidate account IDs are never returned. Returns 400 with param: "waba_id". |
meta_phone_number_already_connected | The Meta WhatsApp phone number is already connected to another Tyxter environment. The response never identifies that tenant. Sign in to the environment that owns the number and disconnect it before trying again. Returns 409 with param: "phone_number_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 follows the organization-targeted /connect-whatsapp?organization_id=… link in the message and re-authenticates the Meta connection with a permanent System User token ( POST /v1/provider-connections/:id/rotate). |
provider_connection_policy_suspended | Meta acted against the WhatsApp Business Account itself — it was banned, rejected at account review, restricted, or carries the legacy scheduled_for_disable suspension reason — so the connection is suspended. Rotating the token, registering again, or finishing a stalled registration is refused with this code, because the stored credential is not what Meta acted on and replacing it changes nothing. Appeal the account at business.facebook.com/accountquality; the connection returns to connected once Meta clears it. Two suspension reasons are not refused — app_uninstalled and account_deleted — because connecting again really is the fix, and doing so clears the suspension. Returns 409. 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. |
provider_connection_suspended | The delivery-path twin of provider_connection_policy_suspended: the same account state, reached from the other side. Meta acted against the WhatsApp Business Account, so every send on that connection fails before any provider call. Like every delivery failure it is recorded on the message rather than returned on the request — read it as error_code at GET /v1/messages/:id or on the message.failed webhook. The message text names what Meta did and the one move that helps: an appeal at business.facebook.com/accountquality for a ban, review rejection, restriction, or a legacy scheduled-disable suspension, and reconnecting from the dashboard when the suspension is app_uninstalled or account_deleted. Read suspension_reason on the connection for the machine-readable reason. Distinct from provider_connection_unauthorized, which means the stored credential died and re-authenticating fixes it — here it cannot. See the /delivery-errors page. |
meta_connection_reauthorization_required | During WhatsApp signup, Meta could not see the WhatsApp Business Account or phone number with the authorization it was given (Meta error code 100, subcode 33) — typically after the account owner changed devices or Meta restricted the asset. Retrying the same signup fails identically: reconnect WhatsApp from the dashboard Connections page and grant access to the account and number again. Returns 400. |
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. |
provider_send_result_missing | Stale-send recovery found no provider message id and no evidence that a provider call started. The message is failed without resending; report repeats with its trace_id. See /delivery-errors. |
tts_provider_error | A TTS provider failed the pre-send audio render after retries. Provider name and an available status code are preserved separately in provider_error. See /delivery-errors. |
tts_render_failed | A non-provider TTS render step failed before WhatsApp was called. Report repeated failures with the message's trace_id. See /delivery-errors. |
sandbox_delivery_failure | A sandbox delivery-failure scenario reached its deterministic failed state. |
sandbox_recipient_opted_out | A sandbox opt-out scenario simulated a recipient who cannot receive the message. |
sandbox_template_rejected | A sandbox template-rejection scenario reached its deterministic failed state. |
provider_credential_setup_session_not_found | No setup session matches that id for your key. Returns 404 from GET /v1/provider-credential-setup-sessions/{request_id} and from the hosted setup page. A session belonging to another organization or environment answers exactly the same way, so the code never confirms that someone else holds it. |
provider_credential_setup_session_expired | The person finished the hosted setup page after the session’s expiry time. Returns 409, and the session is durably marked expired. No credential was stored and the deadline cannot be extended — create a new session and hand out a fresh link. |
provider_credential_setup_session_denied | Someone with permission already refused this handoff, and the refusal is final. Finishing it afterwards returns 409. If the customer changed their mind, create a new session rather than reopening this one. |
provider_credential_setup_session_already_completed | The session already stored a credential — usually a second browser tab or a repeated submit. Returns 409 instead of silently overwriting what is connected. Poll the session to read which provider it connected. |
provider_credential_setup_session_already_denied | A second refusal arrived for a session that is already refused. Returns 409; the first refusal stands and nothing changes. |
provider_credential_setup_target_not_configured | The handoff finished, but the credential its target names is still not stored — the provider check came back empty, or that environment has no connected provider connection to attach it to. Returns 409 and rolls the completion back, so the session stays pending rather than burning out. Connect the provider, then finish the same session again; you do not need a new one. |
provider_credential_setup_stt_sandbox_unsupported | A sandbox key asked for the openai.stt setup target. Returns 400 with param set to target, before any session, provider call, or idempotency record is written. Sandbox transcription runs on a deterministic simulator and needs no provider key; use a production key to connect your own. |
Transcription recovery
POST /v1/messages/{message_id}/transcription is an opt-in create operation, not a retry endpoint. invalid_transcription_request and transcription_source_not_audio are 400 validation errors: correct the request or use a stored inbound WhatsApp audio message. media_asset_expired is also a 400, but means the exact original consumed audio was valid and its retention window elapsed. A provider failure happens after acceptance and appears as a failed transcript receipt; it is not one of those request-time source errors. If the source is inbound audio whose media descriptor is still downloading, wait until its status reaches consumed before retrying create.
A pending or succeeded receipt created with a different language hint returns 409 transcription_language_conflict. Omit the hint or send the original two-letter language to replay that receipt; the create operation will not replace its language intent.
A create request that finds a failed receipt returns 409 transcription_retry_required. Use POST /v1/messages/{message_id}/transcription/retry with a non-blank Idempotency-Key. Missing it returns 400 idempotency_key_required; the same key with a different body returns 409 idempotency_key_conflict. Reuse the same key and body only to replay the stored 202. A pending receipt returns transcription_in_progress, a succeeded receipt returns transcription_already_completed, and a structurally unavailable original source returns transcription_source_unavailable; none is fixed by substituting another attachment. A retry with no receipt first returns 404 transcription_not_found — create it with the original route.
429 transcription_retry_rate_limited means the manual retry cooldown or rolling generation cap is active. Wait for both error.retry_after_ms and Retry-After, then replay the same logical retry command with the same Idempotency-Key. Use a fresh key only for a distinct retry command.
An accepted 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. Missing effective pricing is reported as transcription_billing_rate_unavailable. These terminal receipt codes perform no provider call, emit no usage, and never move a run that is already using your key onto Tyxter’s credential. Fix the configuration or pricing condition, then create a distinct manual retry.
Two terminal receipt codes are not about your credential at all. transcription_failed is the residual provider outcome: Tyxter retried and the provider still returned no transcript, including the case where the audio is larger than the provider accepts. transcription_not_configured means the environment has no transcription credential to run on at all — neither an override of yours nor a platform credential. Neither charges you for a transcript. The first is worth a distinct manual retry; the second is not, until the environment is configured.
stt_provider_config_disconnectedreaches only a transcription that was already attributed to the key you disconnected. Disconnecting withdraws your override, so every transcription that was not — including one still queued — runs on Tyxter’s platform credential and its stt.audio.second meter instead. stt_provider_config_invalid and stt_provider_credential_unreadable are different: the key is still there but unusable, so they keep failing closed until you rotate or replace it.
During the two-phase BYOK rollout, public openai.stt credential setup can return 503 transcription_byok_runtime_inactive before validation, persistence, or provider work. Retry the unchanged request with the same Idempotency-Key after activation. Dashboard STT writes can return 409 stt_provider_config_busy when their bounded configuration-lock wait expires; retry the exact same command with the same Idempotency-Key. Neither condition is permission to change the credential payload during replay.
Provider credential setup sessions
The hosted credential setup flow (POST /v1/provider-credential-setup-sessions and its complete/deny/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.
Retry guidance
- Treat
5xxand network errors as retryable. Use anidempotency-keyon write paths that accept one — check/openapi.json, where an operation that honors the header declares anidempotency-keyheader parameter. - 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.