# Errors and limits

Every status code Sandchest returns and what to do about it, plus the size limits, the rate limit, and the deadlines you should design around.

Every failure is the same shape — `{ "error": "..." }` with a status code that
means something. This page maps each code to a cause and a fix, then lists the
limits worth knowing before you hit them.

## The error shape

```json
{ "error": "Your credit balance is exhausted. Add credits to continue transcribing." }
```

That is the whole body. Messages are stable and safe to match on, and they never
echo your request back — no URLs, no vocabulary, no credentials.

## Status codes

| Code | Meaning | What to do |
| --- | --- | --- |
| `400` | Invalid request: an unknown option, an invalid value, an unsupported feature, or an unknown transcript id on a `/v2` endpoint | Read the message — it names the problem. Do not retry unchanged. |
| `401` | The API key is missing, malformed, revoked or expired | Check the `Authorization` header. Rotate the key if it was revoked. |
| `402` | The workspace is out of credits | Add credits, or enable auto top-up. See [Billing](/docs/billing). |
| `403` | Declared by the API contract; no endpoint currently returns it | Nothing. Handle it as a permanent failure if you are being thorough. |
| `404` | Unknown transcript id — native `/api/v1` endpoints only | Check the id. The `/v2` endpoints return 400 in the same situation. |
| `409` | `Idempotency-Key` conflict | The key was used for a different request, or belongs to a deleted transcript. Use a new key. |
| `413` | The body exceeds a size limit | 100 MiB for uploads, 64 KB for transcript creation. Split or shrink. |
| `429` | Rate limited | Back off and retry. Windows are one minute long. |
| `503` | Inference, billing, storage or rate limiting is temporarily unavailable | Retry with backoff. This is transient, and safe to retry with an `Idempotency-Key`. |

## Errors that are not HTTP errors

A transcript can fail *after* it was accepted. The create call returns `200`, and the
failure shows up on the object:

```json
{
  "id": "tr_9k2mQvR7pKd3sTn1yBhL",
  "status": "error",
  "error": "Parakeet does not support the requested language: ja",
  "words": null,
  "text": null
}
```

Always branch on `status` before reading `words`. Common causes:

| `error` says | Cause |
| --- | --- |
| `Parakeet does not support the requested language: …` | The code is accepted by the API but outside the deployed model's coverage. See [Languages](/docs/languages#models). |
| `language_detection cannot be performed on files with no spoken audio.` | Silent or music-only audio with detection on. Pin a `language_code` instead. |

Failed transcripts are **not billed** — metering only counts completed audio.

## Size limits

| Limit | Value | Response when exceeded |
| --- | --- | --- |
| Upload body | 100 MiB (`SANDCHEST_MAX_UPLOAD_BYTES`) | 413, `{"error":"The uploaded audio file exceeds the configured size limit."}` |
| Transcript request body | 64 KB | 413, `{"error":"The transcription request exceeds the 64 KB size limit."}` |
| `Idempotency-Key` | 200 characters | 400, `{"error":"Idempotency-Key must contain at most 200 characters."}` |
| `webhook_url` | 8192 characters | 400, `{"error":"Invalid webhook URL or authentication parameters."}` |
| Webhook auth header value | 1000 characters | 400, same message |
| Word-search phrase | 5 words | 400, `{"error":"Phrases are currently restricted to five words at most."}` |
| List `limit` | 1–200 | 400, `{"error":"'limit' must be less than or equal to 200"}` |
| List `created_on` | Within the last 90 days | 400, `{"error":"'created_on' cannot be more than 90 days in the past"}` |
| Multichannel channels | 32 | The transcript fails with a channel-count error |

An empty upload body is rejected too: 400 with
`{"error":"The uploaded audio file is empty."}`.

Self-hosted deployments set their own upload limit — see
[Self-hosting](/docs/self-hosting).

## Rate limits

**120 requests per minute per workspace** by default, counted in fixed one-minute
windows across every endpoint. Over the limit:

```json title="429 Too Many Requests"
{ "error": "Too many requests. Please retry shortly." }
```

`POST /v2/upload` also sets a `retry-after` header with the number of seconds until
the current window resets. Other endpoints do not, but the window is always at most
60 seconds, so waiting a minute always clears it.

```ts
async function withRateLimitRetry<T>(call: () => Promise<T>): Promise<T> {
  for (let attempt = 0; ; attempt++) {
    try {
      return await call();
    } catch (error) {
      if (!(error instanceof SandchestError) || error.status !== 429 || attempt >= 5) throw error;
      await new Promise((resolve) => setTimeout(resolve, Math.min(60_000, 2 ** attempt * 1000)));
    }
  }
}
```

The limit counts requests, not audio. Polling a long transcript every 75 milliseconds
will hit it long before your uploads do — poll every second or two, or use a
[webhook](/docs/webhooks) and stop polling entirely.

Self-hosted deployments change it with `SANDCHEST_RATE_LIMIT_PER_MINUTE`.

## Deadlines

| Deadline | Value |
| --- | --- |
| SDK polling timeout | 30 minutes (`pollingTimeout`), then `SandchestError` with status `408` |
| SDK polling interval | 75 ms by default; raise it for long files |
| Webhook attempt timeout | 10 seconds per attempt |
| Webhook attempts | Up to 10, roughly 10 seconds apart |
| Uploaded audio retention | 24 hours (`SANDCHEST_AUDIO_RETENTION_HOURS`) |

The SDK's polling timeout is a client-side deadline. When it expires the transcript
keeps going — fetch it later by id.

## Retrying safely

Retries are only safe when they cannot create a second transcript. Send an
`Idempotency-Key` on every creation request you might retry:

```bash
curl -sS https://stt-api.sandchest.com/v2/transcript \
  -H "Authorization: $SANDCHEST_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: job-8842" \
  -d '{"audio_url":"'"$UPLOAD_URL"'"}'
```

- Same key, same request → the original transcript, and no second charge.
- Same key, a different request → 409.
- No key → a second transcript, and a second charge.

Derive the key from something stable in your own system — a job id, a recording id, a
content hash. Not a random value, or a retry after a crash will not match.

| Code | Retry? |
| --- | --- |
| `400`, `402`, `403`, `404`, `409`, `413` | No. Fix the request first. |
| `401` | No, unless you have just rotated the key. |
| `429` | Yes, after the window resets. |
| `503` | Yes, with exponential backoff. |
| Network error or timeout | Yes — with an `Idempotency-Key`. |

## Verify

Provoke each class of failure and check you get the code you expect:

```bash
API="https://stt-api.sandchest.com"

# 401 — no key
curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript?limit=1"

# 400 — an option that does not exist
curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript" \
  -H "Authorization: $SANDCHEST_API_KEY" -H "Content-Type: application/json" \
  -d '{"audio_url":"https://example.com/a.mp3","not_a_real_option":true}'

# 400 — an unknown transcript id on the compatibility endpoint
curl -sS -o /dev/null -w '%{http_code}\n' "$API/v2/transcript/tr_does_not_exist" \
  -H "Authorization: $SANDCHEST_API_KEY"

# 404 — the same id on the native endpoint
curl -sS -o /dev/null -w '%{http_code}\n' "$API/api/v1/transcripts/tr_does_not_exist" \
  -H "Authorization: $SANDCHEST_API_KEY"
```

```text title="expected output"
401
400
400
404
```

## Next

[Billing](/docs/billing) — what a second of audio costs and how credits work.
