# Errors, limits and conventions

Reference for how the BitCall API behaves across endpoints. For the endpoints themselves see [API.md](API.md); for a walkthrough see [Getting started](getting-started.md).

## Base URL and JSON conventions

- Base URL: `https://api.bitcall.stream`. WebSockets use `wss://api.bitcall.stream/v1/ws` and `wss://api.bitcall.stream/v1/presence/ws`.
- Authenticate with `Authorization: Bearer <key or token>`. API keys start with `ck_test_` (sandbox) or `ck_live_` (live). WebSocket endpoints also accept `?token=`.
- Send `Content-Type: application/json`. Bodies are limited to 1 MiB. Unknown fields are rejected with `400`, so do not send extra keys.
- Ids are prefixed strings: `app_…`, `room_…`, `call_…`, `cons_…`, `whd_…` (webhook delivery). Peer, consultant and user ids are yours and must match `^[A-Za-z0-9_.:@-]{1,64}$`.
- `metadata` objects hold string values only.
- Successful creates return `201`; deletes return `204` with no body; everything else returns `200`.
- CORS is enabled for `Authorization` and `Content-Type` headers, but API keys belong on your backend, not in browsers.

## The error envelope

Every API error is JSON:

```json
{ "error": { "code": 404, "message": "room not found" } }
```

`code` is usually the HTTP status as a number. Permission failures on portal and admin routes use a string code so clients can branch on it without parsing the message:

```json
{ "error": { "code": "forbidden", "message": "forbidden: requires apps.write" } }
```

Treat `code` as `number | string`. Unknown paths and wrong methods are answered by the router with a plain-text `404` or `405`, not the JSON envelope.

## HTTP statuses

| Status | When |
|--------|------|
| `400` | Malformed JSON, unknown field, body over 1 MiB; `peer_id` / `consultant_id` / `user_id` not matching the id pattern; `max_peers` above the app setting; `scope` not `peer` or `subscriber`; `mode` not `audio`, `video` or `chat`; negative `max_seconds` or `warn_at_seconds`. |
| `401` | `invalid or missing API key`; `invalid peer token`; `invalid consultant token`; `invalid credentials` on `/v1/ice`; `application disabled`; portal: `login required`, wrong password, disabled account or member. |
| `402` | `monthly minute quota exhausted for this application` on `POST /v1/rooms` and `POST /v1/consultations`. See Quotas. |
| `403` | `room limit reached for this application`; `live mode is not enabled for this account yet; contact support for approval`; `at most 10 apps per account`; portal and admin roles: `forbidden: requires <permission>` with `"code":"forbidden"`. |
| `404` | Room, call, consultant, consultation or app not found. Resources that belong to another app are also `404`. |
| `409` | `consultant is offline` / `busy`; `consultant does not offer <mode>`; `consultant has a pending request <id>`; `consultation is <status>` for an action that does not fit the state (for example accepting twice); deleting a consultant who is in a consultation; portal: email already registered. |
| `429` | Rate limit exceeded. Carries `Retry-After: 1`. |
| `500` | `internal error` or a storage failure. Safe to retry idempotent requests. |

## Rate limits

- Token bucket per API key: 20 requests per second sustained, burst of 40 (2x). Requests without a key are limited per client IP. A WebSocket connect is limited per token, so many clients behind one NAT do not share a bucket.
- Over the limit: `429 {"error":{"code":429,"message":"rate limit exceeded"}}` with `Retry-After: 1`. Wait one second and retry with backoff.
- These are the default values; your app's limits are those configured on the BitCall deployment.

## Quotas and usage

| Setting | Sandbox (`ck_test_`) | Live (`ck_live_`) | Notes |
|---------|----------------------|-------------------|-------|
| `monthly_minutes` | 200 | 0 (unlimited) | Hard cap per calendar month. `0` means no cap. |
| `included_minutes` | 0 | Account pricing | Minutes per month not charged. |
| `price_per_minute`, `currency` | 0 | Account pricing | Applied beyond `included_minutes`. |

- Usage is the relayed time of every room from its first peer joining to its last peer leaving, summed per calendar month (UTC).
- At the cap: `POST /v1/rooms` and `POST /v1/consultations` return `402`, the `usage.quota_reached` webhook fires once, and calls already running are not cut.
- Sandbox apps are never invoiced and always show `amount: 0`.
- Check usage with `GET /v1/me` (settings) and, with a portal session, `GET /v1/account` (`this_month` per app), `GET /v1/account/apps/{id}/usage?month=YYYY-MM` and `GET /v1/account/apps/{id}/invoice?month=YYYY-MM`.
- Invoice math: `billed_minutes = ceil(used_seconds / 60)`, `billable_minutes = max(0, billed_minutes − included_minutes)`, `amount = billable_minutes × price_per_minute`.

## Token lifetimes

| Token | Default TTL | How to change |
|-------|-------------|---------------|
| Peer token (`POST /v1/tokens`) | App setting `token_ttl_seconds` (3600) | `ttl_seconds` in the request, up to the app setting; larger values fall back to the default. |
| Consultation tokens (`accept`, `tokens`) | 2 hours (`tokens_expire_at`) | Re-issue with `POST /v1/consultations/{id}/tokens`. |
| Consultant presence token | 24 hours | `{"ttl_seconds": n}` on `POST /v1/consultants/{id}/token`. |
| TURN credentials (`GET /v1/ice`) | 24 hours | `?ttl=` lowers it. The SDK fetches fresh credentials on every `connect()`. |
| Portal session token | 24 hours | Log in again. |

A token names one room and one peer id. Tokens are verified when the socket is opened; an expired token is refused with `401`. Mint a fresh token before reconnecting after a drop.

## Room and peer limits

| Setting | Sandbox | Live | Enforced where |
|---------|---------|------|----------------|
| `max_peers_per_room` | 4 | 8 | `POST /v1/rooms` rejects a larger `max_peers` (`400`); a join beyond the room's limit gets a WebSocket `error` frame `room is full` and is closed. |
| `max_rooms` | 50 | 1000 | `POST /v1/rooms` returns `403 room limit reached for this application`. Delete or expire rooms (`ttl_seconds`) to stay under it. |
| Apps per account | 10 | 10 | `403` from the portal. |
| Chat | 4000 characters per message, 500 messages kept per room | same | Older messages are dropped. |
| Consultations | one pending request per consultant at a time | same | `409` until it is accepted, declined, cancelled or expires (45 s default). |

Rooms are not deleted when a call ends; a room with `ttl_seconds` disappears after expiry.

## Timestamps

All timestamps in bodies and webhook payloads are RFC 3339 strings in UTC, for example `2026-10-27T10:00:01Z` (sub-second precision may be present). Durations are integer seconds unless the field name says otherwise (`duration_ms`). The webhook header `X-Webhook-Timestamp` is Unix seconds.

## Pagination and filters

Lists are not cursor-paginated. Where a `limit` exists, the newest records come first:

| Endpoint | Parameters | Default / max `limit` |
|----------|------------|------------------------|
| `GET /v1/calls` | `limit` | 50 / 500, newest first. In-memory records for recent calls. |
| `GET /v1/consultations` | `status`, `consultant_id`, `user_id`, `limit` | 50 / 500, newest first |
| `GET /v1/consultants` | `status` (`online`, `busy`, `offline`) | no limit |
| `GET /v1/rooms` | none | no limit, unsorted |
| `GET /v1/rooms/{id}/messages` | `since_seq`, `limit` | 500 / 500, oldest first; returns `has_more` and `latest_seq`. |
| `GET /v1/account/apps/{id}/deliveries` | `state` (`pending`, `delivered`, `failed`), `limit` | 50 / 1000 |

For history beyond these windows, record webhook events in your own database.

## Webhooks: idempotency and retries

- Deliveries are retried on 5xx, 429, connection errors and timeouts (5 s) at 2 s, 10 s, 1 m, 5 m and 30 m. Any other 4xx stops retries. Respond `200` quickly and do the work asynchronously.
- `X-Webhook-Id` is stable across retries and replays of one event. Store it with a unique index and return `200` for duplicates.
- Events can arrive out of order under retry. Order by the payload `timestamp`, not by arrival.
- Verify `X-Webhook-Signature` (`v1=` HMAC-SHA256 of `timestamp + "." + raw body`) with the app's secret and reject timestamps older than five minutes. Details in [features/webhooks.md](features/webhooks.md).

## Retrying API calls

- `POST /v1/rooms`, `POST /v1/tokens` and `POST /v1/consultants/{id}/token` are safe to retry; each call creates a new resource or token.
- `PUT /v1/consultants/{id}` is idempotent.
- `POST /v1/consultations` is not idempotent: a retry after a timeout can create a second request and then fail with `409 consultant has a pending request`. On a timeout, list `GET /v1/consultations?status=requested&user_id=…` before retrying.
- `accept`, `decline`, `cancel`, `end` and `extend` return `409 consultation is <status>` when repeated; treat that as success if the status is the one you expected.
