---
title: "Errors and debugging"
description: "Every status code the speech APIs return, what causes it, and how to recover."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.navana.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Errors and debugging

## Error shape

A failure comes back as JSON carrying a machine-readable code, a
human-readable message, and the status:

```json
{
  "error": "invalid_transaction_id",
  "message": "A valid UUID is required for the transaction ID.",
  "code": 400,
  "timestamp": "2026-09-09T19:36:33+05:30"
}
```

| Field | Description |
| --- | --- |
| `error` | Stable code identifying what went wrong. Branch on this. |
| `message` | Human-readable detail. Log it, but do not match on it, since wording can change. |
| `code` | Mirrors the HTTP status. |
| `timestamp` | When the error was generated, RFC 3339. |

Some responses carry a `status` field set to `error` alongside these.

> **Synthesis errors are a different shape**
>
> That shape is transcription's. Text-to-speech returns `{"error": "<message>"}`
> and nothing else, with the message as prose rather than a stable code. Over
> the synthesis WebSocket, errors are `error` frames carrying a `code` you can
> branch on. Both are on
> [Stream synthesis](/api-reference/synthesize-streaming#errors) and
> [Synthesize speech](/api-reference/synthesize#errors).
>
> The two services also use different statuses for the same underlying
> problem, which the sections below spell out. One error handler for both will
> get it wrong.

On a WebSocket, failures before the socket opens come back as a normal HTTP
response on the upgrade, so you get a readable status rather than a silent
close. Once the socket is open, a failure arrives as a frame carrying an
`error` field. Check for it on every frame you parse, before reading `text`.

## Every status code

  <Accordion>
    <AccordionTrigger>400: Bad request</AccordionTrigger>
    <AccordionContent>
      The request is malformed. Deterministic, so do not retry without changing
      something.

      Common causes:

      - A `transaction_id` that is not a valid UUID. Any other string is
        rejected, even though it may look reasonable.
      - A model name that does not exist, usually a typo. See
        [Models and languages](/speech-to-text/models).
      - Malformed JSON in a streaming config frame.
      - A missing required field, such as `model` or `audio_file`.
    </AccordionContent>
  </Accordion>

  <Accordion>
    <AccordionTrigger>401: Unauthorized</AccordionTrigger>
    <AccordionContent>
      The API key is missing, malformed, or does not match any account.

      On synthesis this is the *only* auth failure status: a key that is
      wrong, revoked, out of credit, or missing the `tts:synth` scope all come
      back as `401`. Transcription splits those across `402` and `403`.

      Check, in this order:

      - The key was actually sent, as an `X-API-Key` header — or, for
        text-to-speech streaming, as the `hello` frame's `auth_token` field.
        See [Authentication](/authentication).
      - The whole `prefix.secret` value was sent, not just the prefix.
      - No trailing newline or whitespace crept in from a shell variable.
      - The key has not been revoked. **Creating a new key revokes the previous
        one**, so a caller that missed a rotation starts failing here. See
        [API keys](/api-keys).
    </AccordionContent>
  </Accordion>

  <Accordion>
    <AccordionTrigger>402: Insufficient balance</AccordionTrigger>
    <AccordionContent>
      The account does not have enough credits to process the request.

      **Transcription only.** Synthesis returns `401` for an empty balance,
      the same as for any other refused key.

      **Do not retry a `402`.** The same request fails identically until the
      balance is topped up. Top up on the **Billing** page in the app, or
      enable auto-recharge so the next call succeeds without manual
      intervention.

      For streaming, this can also end a connection that was already running,
      so keep some headroom rather than running the balance down to zero. On
      the synthesis socket the same situation arrives as an
      `insufficient_funds` frame.
    </AccordionContent>
  </Accordion>

  <Accordion>
    <AccordionTrigger>403: Forbidden</AccordionTrigger>
    <AccordionContent>
      Either the account is inactive, or the key does not carry the scope this
      endpoint requires.

      **Transcription only.** Synthesis returns `401` for both.

      An inactive account needs support. A scope problem needs a different
      key, though every key created today is granted all scopes.
    </AccordionContent>
  </Accordion>

  <Accordion>
    <AccordionTrigger>500: Internal server error</AccordionTrigger>
    <AccordionContent>
      An unexpected server-side failure. Not something the caller can fix by
      changing the request.

      Retry once with backoff. If it persists, capture your `transaction_id`
      and the `call_id` if you got one, and report them. Those are what tie
      your failure to the server-side record.
    </AccordionContent>
  </Accordion>

  <Accordion>
    <AccordionTrigger>503: Service unavailable</AccordionTrigger>
    <AccordionContent>
      The service is unavailable or temporarily overloaded.

      **Retryable**, with exponential backoff, jitter, and a cap on attempts.
      Unlike a `400`, the same request may well succeed shortly afterwards.
    </AccordionContent>
  </Accordion>

## Error codes

The `error` field carries one of these. Authentication and balance failures are
covered by the statuses above.

| Code | Status | Meaning |
| --- | --- | --- |
| `invalid_request` | `400` | The request arguments could not be parsed. |
| `invalid_transaction_id` | `400` | `transaction_id` is not a valid UUID. |
| `model_not_found` | `400` | The model you named does not exist. Check it against [Models and languages](/speech-to-text/models). |
| `invalid_hotwords_format` | `400` | `hotwords` could not be parsed as JSON. |
| `failed_to_get_audio_file` | `400` | No audio file was present in the request. |
| `failed_to_read_audio_file` | `400`, `500` | The upload could not be read. Check the file is not truncated or corrupt. |
| `audio_conversion_failed` | `400` | The uploaded audio could not be decoded. |
| `invalid_audio_file_format` | `400` | The upload is not an audio format the service can read. |
| `audio_processing_failed` | `400`, `500` | The audio could not be processed. |
| `failed_to_transcribe_audio` | `500` | Transcription failed after the audio was accepted. |
| `config_already_initialized` | `400` | Streaming only. A second `config` frame was sent on one connection. |
| `internal_server_error` | `500` | An unexpected failure. |

## Errors on a stream

Streaming fails in two places, and they behave differently.

**Before the socket opens**, the upgrade is rejected as a normal HTTP response.
Every status above applies, and you can read it the same way you would any
failed request.

**After the socket opens**, a failure arrives as a frame with an `error` field:

```python
async for raw in socket:
frame = json.loads(raw)
if "error" in frame:
    raise RuntimeError(frame)
...
```

Frames you can see here include `{"error": "unexpected connection close"}`,
`{"error": "read error: …"}`, and the idle timeout below.

> **The connection closes after 15 seconds of silence**
>
> The server holds a 15 second read deadline, reset every time it receives a
> message from you. Send nothing for 15 seconds and you get
> `{"error": "timeout occurred while waiting for message"}` and the connection
> closes.
>
> This catches people holding a socket open between utterances. If your audio
> source can go quiet for longer than that, expect to reconnect.

> **Do not double-report a cutoff**
>
> An error frame is followed by the connection closing. Treat that close as
> part of the same failure rather than a second, distinct one, or every cutoff
> gets logged twice.

## Debugging workflow

1. **Capture your transaction_id and call_id**

   You generate `transaction_id` on every request, and the response carries a
   `call_id`. Log both on success and failure. They are the handles that tie
   your logs to the server side.
2. **Isolate the key**

   Make the smallest possible call with the same key. If a tiny file also
   fails `401`, the problem is the key. If it succeeds, the problem is the
   request.
3. **Read the status, then the message**

   A `400` means fix the request. A `402` means top up. A `503` means retry.
   Only the message tells you which field was wrong.
4. **Check the app**

   Usage & Analytics shows whether requests are arriving at all, and Billing
   shows your balance.

## Common integration mistakes

| Symptom | Likely cause |
| --- | --- |
| `400` on a request that looks correct | `transaction_id` is not a valid UUID, or the model name has a typo. |
| `401` right after creating a key | Creating a key revokes the previous one, and a caller still holds the old value. |
| `401` with a key that works elsewhere | A trailing newline from a shell variable, or only the prefix was sent instead of the whole `prefix.secret` value. |
| Confident but wrong transcript | The model's language does not match the audio, or the declared `sample_rate` does not match what you are sending. |
| Transcripts stop arriving but the socket stays open | You are waiting on a fixed delay instead of the `eos` frame. |
| Every stream cutoff logged twice | The close after an error frame is being counted as a second failure. |
| Stream cut off with credits apparently remaining | The balance ran out mid-connection. Keep headroom. |

## Related

- [Authentication](/authentication) — Where the key goes for each API.
- [Transcribe a file](/api-reference/transcribe) — The non-streaming endpoint's own errors.
- [Stream transcription](/api-reference/transcribe-streaming) — The streaming endpoint's own errors.

Source: https://docs.navana.ai/api-reference/errors/index.mdx
