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

# Errors & limits

> PyAI error envelopes (OpenAI-style and RFC 7807 problem+json), stable error codes, rate limits, retry guidance, and Idempotency-Key semantics.

## First-call failures

These are the envelopes a new key hits most often. None of them are transient
except the sandbox daily cap. Do not retry the same request unchanged.

| What you did | HTTP | `code` | Fix |
| - | - | - | - |
| No `Authorization` header, or a revoked key | 401 | `invalid_api_key` / `missing_api_key` | Mint or rotate a key; send `Bearer $PYAI_API_KEY` |
| Sandbox key called Clone or hosted knowledge | 403 | `insufficient_scope` | Read `scopes` on `GET /v1/me`. Signup keys include `speak:clone` and `kb:manage`; sandbox mint keys do not |
| `pyai_live_` key, org has no prepaid credit | 402 | `credit_exhausted` | Add credit in the console. Sandbox keys never return this |
| Hear `language` outside `en/es/fr/de/hi/it/pt/nl` | 400 | `unsupported_language` | Send a published code, or omit the field for English |
| Sandbox daily unit cap exhausted | 429 | `daily_cap_exceeded` | Wait for reset. Cap is on `GET /v1/me` as `limits.daily_unit_cap` |

## Error shapes

PyAI returns two error shapes depending on which layer produced the error. Branch
on the **stable code**, never the human message.

### Data plane (gateway), OpenAI-compatible

Auth, scope, rate-limit, and billing errors use the OpenAI envelope:

```json theme={null}
{
  "error": {
    "message": "Rate limit exceeded.",
    "type": "rate_limit_error",
    "code": "rate_limit_exceeded",
    "param": null
  }
}
```

### Control plane, RFC 7807 problem+json

Request-validation and resource errors (e.g. `400`, `404`, `409`) use
`application/problem+json`. The stable code is the **last path segment of
`type`**:

```json theme={null}
{
  "type": "https://api.pyai.com/problems/idempotency_conflict",
  "title": "Conflict",
  "status": 409,
  "detail": "Idempotency-Key was reused with a different request body.",
  "request_id": "req_..."
}
```

## Error code reference

| HTTP | Code | Meaning | Retry? |
| - | - | - | - |
| 401 | `invalid_api_key` | Unknown, inactive, or revoked key (gateway) | No |
| 401 | `missing_api_key` | No bearer credential on the request | No |
| 401 | `unauthorized` | Missing/invalid/revoked key (some control-plane routes) | No |
| 403 | `insufficient_scope` | Key lacks the required scope (gateway data plane) | No |
| 403 | `forbidden` | Key lacks the required scope (some control-plane routes) | No |
| 403 | `origin_not_allowed` | Publishable-token origin not allow-listed | No |
| 400 | `invalid_session_label` | `session_label` on an Omni URL is malformed (control chars / too long) | No, fix the label |
| 400 | `unsupported_language` | Hear received a language outside `en/es/fr/de/hi/it/pt/nl` | No. Send a published code. Omit only for English |
| 400 | `input_too_long` | Speak `input` is over 2000 characters | No, split the text |
| 422 | `unsupported_voice` | Cast was given a voice outside its capabilities | No, refresh `GET /v1/cast/capabilities` |
| 422 | `unsupported_emotion` | Cast was given an emotion outside the advertised palette | No, refresh capabilities |
| 422 | `unsupported_intensity` | Cast was given an intensity outside `subtle` / `balanced` / `bold` | No, refresh capabilities |
| 404 | `unknown_job` | No such Cast render job or Dub job for this org | No, check the `job_id` |
| 409 | `job_not_done` | The render/dub is still running | Yes, poll the status URL |
| 410 | `audio_expired` | The rendered artifact has been collected | No, submit the job again |
| 402 | `credit_exhausted` | Org out of prepaid credit | No, add credit |
| 402 | `key_budget_exceeded` | Per-key monthly cap reached | No, raise budget |
| 402 | `insufficient_quota` | Plan quota exhausted | No, upgrade |
| 409 | `idempotency_conflict` | `Idempotency-Key` reused with a different body | No, use a new key |
| 429 | `rate_limit_exceeded` | Per-key/IP rate limit | Yes, honor `Retry-After` |
| 429 | `concurrency_limit_exceeded` | Too many concurrent realtime sessions | Yes, wait + retry |
| 429 | `daily_cap_exceeded` | Sandbox/publishable daily cap | Yes, after reset |
| 429 | `capacity_exceeded` | The synthesis lane is at capacity (not your rate limit) | Yes, honor `Retry-After` |
| 400 | `input_too_long` | `input`/`text` exceeds the per-request character ceiling | No, split the text and send each part |
| 429 | `signup_limit_reached` | This network created its allowance of free accounts inside the rolling window | Not immediately. Sign in to the existing account, retry after the window, or contact support |
| 429 | `sandbox_limit_reached` | This network minted its allowance of sandbox keys inside the rolling window | Not immediately. Reuse a key you hold, retry after the window, or create an account |

### Product-specific setup codes

| HTTP | Code | Meaning | Action |
| - | - | - | - |
| 400 | `unsupported_parameter` | A known but inactive/invalid field was sent | Remove the field; do not retry unchanged |
| 400 | `invalid_request` | The request is valid JSON but not accepted (for example `trace: true` together with `diarize` or `channel`) | Remove the conflicting field; do not retry unchanged |
| 402 | `recap_not_enabled` | Recap processing was requested before organization enablement | Enable Recap, then retry the logical operation |
| 402 | `trace_not_enabled` | An async transcription requested Trace without the required entitlement | Enable Trace for transcription or remove `trace: true` |
| 403 | `cast_emotion_unavailable` | The Cast emotion is not enabled for the account | Render controls from `GET /v1/cast/capabilities` |
| 422 | `unsupported_voice` | The selected voice is not accepted by Cast | Choose a voice from Cast capabilities |
| 503 | `natural_unconfigured` | Natural-tier synthesis is not enabled on this deployment; Natural voices never fall back to a Standard voice | Select a Standard voice, or contact support if the Natural tier should be enabled |
| 429 | `natural_busy` | Natural-tier synthesis is at capacity | Retry shortly, honor `Retry-After` |
| 429 | `multilingual_busy` | Multilingual synthesis is at capacity | Retry shortly, honor `Retry-After` |

## Account-creation limits

These bound how many *new* free accounts and anonymous sandbox keys one source
network may create. They are not per-key rate limits and they never affect
traffic from a key you already hold.

| Path | Limit per source network | Code on refusal |
| - | - | - |
| `POST /auth/signup`, `POST /auth/google` (free account) | 2 per rolling 24 h | `signup_limit_reached` |
| `POST /v1/sandbox/keys` (anonymous key, no email or card) | 2 per rolling 24 h | `sandbox_limit_reached` |

Refusals are `429` with an actionable `detail` naming the limit, the window, and
the way forward. The window is **rolling**, not a fixed daily reset: the cap
frees up as your oldest account or key ages past 24 hours, so a shared egress
recovers on its own. Do not hammer the endpoint waiting for it — retry after the
window, or take one of the routes below.

If you are behind a shared egress (an office, a VPN, a CI runner) and need more,
email [support@pyai.com](mailto:support@pyai.com) to have the network raised.
Teams on a company domain get a much larger allowance once the mailbox is
proven — sign up with **Continue with Google** rather than email + password, so
the domain is verified rather than merely claimed.

Already have an account? Signing in is never capped. Only brand-new tenant
creation is.

## Rate limits

Every key has a per-second rate limit (with burst) and a cap on concurrent
realtime sessions, set by your plan. Exceeding either returns `429` with a
`Retry-After` header (seconds to wait). Back off and retry; the official SDKs do
this automatically. Call `GET /v1/me` to read the `rps`, `burst`,
`concurrency`, and quota posture applied to the calling key.

### Per-request input ceilings

Synthesis takes a bounded amount of text per request. `POST /v1/audio/speech`
and the Clone synthesis routes accept up to **4,096 characters** in `input` /
`text`; longer bodies are refused with `400 input_too_long` before any audio is
rendered, and the message names both the limit and the length you sent. Split
long copy into requests and concatenate the audio.

## Idempotency

JSON `audio_url` requests to `POST /v1/transcription/jobs` accept an
`Idempotency-Key` header so a retried request can't create a duplicate job:

* **Same key + same body** → the original response is replayed (no new job).
* **Same key + different body** → `409 idempotency_conflict`.

Multipart audio uploads are not deduplicated. Do not assume an
`Idempotency-Key` makes a retried binary upload safe.

<Tip>
  Send a fresh idempotency key (e.g. a UUID) per logical operation, and reuse it
  when retrying that exact operation after a network blip.
</Tip>

## Async transcription failures

Submission failures use the stable request codes above. After a job is accepted,
a terminal processing failure is returned by
`GET /v1/transcription/jobs/{id}` as:

```json theme={null}
{
  "job_id": "job_...",
  "status": "failed",
  "error": "Hear is temporarily unavailable. Please retry."
}
```

The job's `error` is a normalized human-readable message, not a stable
machine-readable code. Branch on `status == "failed"` and decide whether to
submit a new idempotent operation; do not parse the message. See
[Transcribe recordings with timestamps](/guides/async-transcription-jobs) for
job lifecycle, signed webhooks, input limits, and retention.

## Pagination

List endpoints are cursor-paginated, newest first. Pass `limit` (1-100, default
20\) and the previous page's `next_cursor` as `cursor`. `next_cursor` is `null`
on the last page.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.