# Errors, retries and limits

> One error envelope everywhere; what to retry (429, 503 unavailable) and how to make a retry safe (Idempotency-Key); credit, plan limits and the 402s.

| Endpoint | What it does |
|---|---|
| `GET /v1/billing` | Get the account's billing |
| `POST /v1/broadcasts` | Create a broadcast |

Every failed call answers with the same JSON envelope, and the SDKs turn it into one error type (`VocalStackError`) with the same fields. Most errors are final; a few say "try again", and the SDKs already do that for you where it is safe.

```json
{
  "defined": true,
  "code": "insufficient_credit",
  "status": 402,
  "message": "this account has no credit left; add credit to continue",
  "data": {
    "retryable": false,
    "details": {
      "balance": -0.42,
      "required": 0.075,
      "currency": "usd",
      "plan": "payg",
      "topUpUrl": "https://…"
    }
  },
  "requestId": "01J…"
}
```

## 1. Read the error

`code` is what to branch on; `message` is for people; `data.retryable` says whether sending the same request again can succeed; `data.details` carries what the code needs; `requestId` (also the `X-Request-Id` header) is what support asks for. In the SDKs: `err.code`, `err.status`, `err.message`, `err.retryable`, `err.details`, `err.retryAfter` (seconds, from `Retry-After`) and `err.headers`.

| Status and code | What to do |
|---|---|
| 400, 422 `invalid_options`, `invalid_body`, `invalid_audio` | Fix the request: the message names the field. |
| 401 `invalid_token`, `key_expired`, `key_revoked` | Check `VOCALSTACK_API_KEY`; use the replacement key, or create one. |
| 402 `insufficient_credit` | The account's credit is out: `details.balance` (dollars), `plan`, `topUpUrl`, and `required` when the cost was known up front (a file of known length). Add credit; nothing was charged. |
| 402 `quota_exceeded` | A plan limit is reached: `details.metric` (`concurrent_transcriptions`), `limit`, `used`, `resetsAt` (null: it frees up as transcriptions end), `plan`, `topUpUrl`. |
| 403 `forbidden`, `key_from_browser`, `suspended` | A missing scope (the message names it), a key sent from a web page (keep it on your server), or a suspension. Retrying never helps. |
| 404, 409 `not_found`, `conflict`, `invalid_state` | No such thing, a clash (a link code taken, another device recording), or not now (`stop` on a recording that is not paused; a second subscription checkout while one is active). |
| 409 `payment_method_required` | Auto top-up (`PUT /v1/billing/auto-top-up`) and the customer portal need a saved payment method: make a payment (`POST /v1/billing/checkouts`) or add a card (`POST /v1/billing/payment-methods/manage`) first. |
| 409 `payment_method_in_use`, `billing_details_required`, `auto_top_up_off` | A card that pays the subscription cannot be removed (add another and make it the default first); an invoice needs a billing name and address (`PUT /v1/billing/details`); auto top-up is off, so there is nothing to run now. |
| 404 `promo_code_invalid` | The promo code does not exist, has expired, is used up, was redeemed by this account already, or gives nothing for this purchase. |
| 400 `range_too_long` | A usage report covers at most 92 days: ask for a shorter range. |
| 429 `rate_limited` | Wait `Retry-After` seconds. Retried for you. With `details.metric: "creates_per_hour"` it is the plan's creation rate (new transcriptions an hour), not a burst limit: `limit` and `resetsAt` say when. |
| 503 `unavailable` | The service is waking (its database was asleep). Send it again after `Retry-After`. Retried for you when it is safe. |

## 2. Retry what can be retried

The SDKs retry a `429 rate_limited` and a `503 unavailable` after `Retry-After`, twice by default (`maxRetries` / `max_retries` on the client), but a `503` only for a call that is safe to send twice: `GET`, `HEAD`, `DELETE`, or any call carrying an `Idempotency-Key`. A create without one is not retried, because the first attempt may have worked. Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`: slow down before the 429. `GET /v1/billing` shows the plan, its rates per hour of audio, the credit balance and the limits (transcriptions at once, new transcriptions an hour, requests a minute, the largest file, the longest live recording).

## 3. Make creates safe to retry

Send `Idempotency-Key: <a fresh UUID>` with a create (`POST /v1/broadcasts`, `POST /v1/transcriptions`, `POST /v1/transcriptions/live`, starting a recording…). Repeated within 24 hours with the same key and the same body, it returns the first answer (with `Idempotent-Replayed: true`) instead of creating a second thing; the same key with a different body is `422 idempotency_key_reused`, and a repeat while the first is still running is `409 conflict`. Keys are per account. In the SDKs: `{ idempotencyKey }` as the call options (TypeScript), `idempotency_key=` (Python).

## 4. Sockets

A refused WebSocket upgrade carries the same envelope over HTTP: `401 invalid_token`, `410 session_expired` (a realtime session's `expiresAt` passed: create a new one), `503 unavailable` (retry after `Retry-After`). A fleet with every machine busy can instead hang the connect or answer a bare 503 with no envelope: the SDKs report that as `capacity_exceeded`, and as `engine_unavailable` when the machine started warming up and then went quiet. On an open socket, an `error` frame carries `code`, `message` and `retryable`; `slow_consumer` means you read too slowly and were closed (commits are never dropped, drafts are). There is no resume: create a new session.

## Complete code

Each program runs as it is once `VOCALSTACK_API_KEY` holds one of your API keys.

### TypeScript SDK: `errors.ts`

```typescript
import { VocalStack, VocalStackError } from '@vocalstack/sdk';

// 429s, and 503s on calls that are safe to repeat, are retried after Retry-After.
const vs = new VocalStack({ baseUrl: 'https://api.vocalstack.com', apiKey: process.env.VOCALSTACK_API_KEY, maxRetries: 4 });

try {
  // The key makes this create safe to retry: a repeat returns the first broadcast.
  const broadcast = await vs.broadcasts.create({ name: 'Town hall' }, { idempotencyKey: crypto.randomUUID() });
  console.log(broadcast.shareUrl);
} catch (err) {
  if (!(err instanceof VocalStackError)) throw err;
  switch (err.code) {
    case 'insufficient_credit':
    case 'quota_exceeded': {
      const d = err.details as { metric?: string; balance?: number; required?: number; topUpUrl?: string } | undefined;
      console.error(err.message, d?.metric ?? '', d?.balance ?? '', d?.required ?? '', d?.topUpUrl ?? '');
      break;
    }
    case 'rate_limited':
    case 'unavailable':
      console.error(`still ${err.code} after the retries; try again in ${err.retryAfter ?? 1} s`);
      break;
    default:
      console.error(err.status, err.code, err.message, err.headers?.get('x-request-id') ?? '');
  }
}

const billing = await vs.billing.get();
console.log(billing.plan?.name, `$${billing.balance} of credit`, `${billing.limits?.concurrentTranscriptions ?? '∞'} at once`);
```

### Python SDK: `errors.py`

```python
import asyncio
import os
import uuid

from vocalstack import VocalStack, VocalStackError


async def main() -> None:
    # 429s, and 503s on calls that are safe to repeat, are retried after Retry-After.
    async with VocalStack("https://api.vocalstack.com", api_key=os.environ["VOCALSTACK_API_KEY"], max_retries=4) as vs:
        try:
            # The key makes this create safe to retry: a repeat returns the first broadcast.
            broadcast = await vs.broadcasts.create(name="Town hall", idempotency_key=str(uuid.uuid4()))
            print(broadcast.share_url)
        except VocalStackError as e:
            if e.code in ("insufficient_credit", "quota_exceeded"):
                print(e.message, e.details)
            elif e.code in ("rate_limited", "unavailable"):
                print(f"still {e.code} after the retries; try again in {e.retry_after or 1} s")
            else:
                print(e.status, e.code, e.message)

        billing = await vs.billing.get()
        print(billing.plan, f"${billing.balance} of credit", billing.limits)


asyncio.run(main())
```

### HTTP + WebSocket: `errors.sh`

```bash
#!/usr/bin/env bash
# curl and jq, no SDK: a create with an Idempotency-Key, retried on 429 and 503.
set -euo pipefail
API=https://api.vocalstack.com
AUTH="Authorization: Bearer $VOCALSTACK_API_KEY"
KEY=$(uuidgen) # the same key on every attempt: a repeat returns the first answer

for attempt in 1 2 3 4 5; do
  HEADERS=$(mktemp)
  STATUS=$(curl -sS -o body.json -D "$HEADERS" -w '%{http_code}' "$API/v1/broadcasts" \
    -H "$AUTH" -H "Content-Type: application/json" -H "Idempotency-Key: $KEY" \
    -d '{"name":"Town hall"}')
  case $STATUS in
    2??) jq -r .shareUrl body.json; exit 0 ;;
    429|503)
      WAIT=$(awk 'tolower($1)=="retry-after:"{print $2+0}' "$HEADERS")
      echo "$STATUS $(jq -r .code body.json): retrying in ${WAIT:-2} s" >&2
      sleep "${WAIT:-2}" ;;
    *)
      jq -r '"\(.status) \(.code): \(.message) (request \(.requestId))"' body.json >&2
      jq '.data.details' body.json >&2
      exit 1 ;;
  esac
done
exit 1
```


---

All guides: https://api.vocalstack.com/llms.txt. Every operation: https://api.vocalstack.com/llms-full.txt. The OpenAPI document, with every WebSocket frame under `x-websocket`: https://api.vocalstack.com/v1/openapi.json.
