# API basics

> Authentication, requests and responses, ids, pagination, retries, rate limits and CORS. The conventions every endpoint follows.

Read this once; every endpoint in the [API reference](/api-reference) follows it.

## Base URL and authentication

All endpoints live at `https://api.justonair.com`. Send your API key as a Bearer token:

```bash
curl https://api.justonair.com/v1/streams \
  -H "Authorization: Bearer $JOA_API_KEY"
```

API keys start with `joa_live_`. Create them in the [dashboard](https://app.justonair.com) under **API keys**; each is shown once. An account can have 20 active keys. A missing or wrong key gets `401 unauthorized`.

Keep keys on your server. The API doesn't accept them from browsers (see [CORS](#cors)), and a key in a web page or an app would let anyone run streams on your credit. The public endpoints under `/v1/embed/` need no key: they are what viewers' browsers call.

`GET https://api.justonair.com/` needs no key and answers with links to these docs, `openapi.json` and `llms.txt`.

## Requests and responses

- Bodies are JSON: send `Content-Type: application/json`. Unknown fields are refused with `400 invalid_request`, which names the field, so a typo never passes silently.
- On `PATCH`, a field you leave out is unchanged and `null` clears it.
- Every object has an `object` field saying what it is (`stream`, `list`, `chat_message`, …).
- Times are ISO 8601 in UTC: `2026-10-09T18:05:12.000Z`.
- Money is a decimal string in USD, never a float: `"0.016790"`. Parse it with a decimal type.
- Fields that are always there but have nothing to say are `null`, not missing. New fields can appear at any time; ignore the ones you don't know. What changed when: [Changelog](/changelog).

## Ids

Ids are strings with a prefix that says what they are. Treat them as opaque.

| Prefix | What |
|---|---|
| `str_` | Stream (also its folder on the CDN) |
| `msg_` | Chat message |
| `vsn_` | Viewer session |
| `ban_` | Chat ban |
| `mod_` | Chat moderator |
| `whk_` | Webhook endpoint |
| `dlv_` | Webhook delivery |
| `evt_` | Webhook event (the `webhook-id` header) |
| `whsec_` | Webhook signing secret |
| `req_` | Request id (see [below](#request-ids)) |
| `joa_live_` | API key |

## Pagination

Lists answer `{"object": "list", "data": [...], "has_more": true}`, newest first. Pass `limit` (up to 100) and, for the next page, the id of the last item as `starting_after`:

```bash
curl "https://api.justonair.com/v1/streams?limit=100&starting_after=str_e1bzk3dxw9z9allei6n7" \
  -H "Authorization: Bearer $JOA_API_KEY"
```

The chat feed pages by position instead: pass the previous answer's `next_after` as `after`. See [Chat and reactions](/chat#the-feed).

## Retries and idempotency

`POST /v1/streams` takes an `Idempotency-Key` header: any unique string, such as a UUID, up to 255 characters. A retry with the same key and the same body within 24 hours returns the first answer (with `Idempotent-Replayed: true`) instead of creating a second stream. The same key with a different body gets `422 idempotency_key_reused`. Send one on every create; then a timeout is always safe to retry.

`GET`, `PATCH` and `PUT` are safe to repeat as they are: they read or set a state. Ending a stream that has already ended returns it unchanged. Other `POST`s (a webhook endpoint, a moderator link, a playback token) take no key: on a timeout, list or read before you create again.

When an error goes away by waiting, the response has a `Retry-After` header (seconds) and usually `error.details.retry_after_seconds`: `rate_limited`, `limit_creation_rate`, `no_capacity` and the chat limits. Limits that waiting doesn't fix, such as `limit_pending_streams`, have none, so a generic client won't retry them in a loop. Retry a 5xx with backoff.

## Rate limits

API-key calls have no per-key request limit today. What's limited is what you create: streams waiting and created per hour, ingest hours, and the rest on [Limits](/limits), each with its own error code. Poll a stream every 3–5 seconds at most, or use [webhooks](/webhooks) instead.

The public endpoints under `/v1/embed/` are limited per viewer address (`429 rate_limited`), and chat posts and reactions have their own limits: [Chat limits](/chat#limits).

If a per-key limit is added, it will answer `429 rate_limited` with `Retry-After`, which clients that follow this page already handle.

## Errors

Every error has the same shape, with a `code` that never changes:

```json
{
  "error": {
    "code": "limit_pending_streams",
    "message": "At most 2 streams may be waiting to go live at once. Start or delete one first.",
    "details": { "limit": 2, "current": 2 },
    "request_id": "req_8k2m0q4b7c1d8e3a9f5x"
  }
}
```

Program against `code`; show `message` to people. Every code: [Errors](/errors). In [openapi.json](/openapi.json), each error response lists the codes it can carry in `x-error-codes`.

## Request ids

Every response has an `X-Request-Id` header, and every error body has the same value in `error.request_id`. Log it, and quote it when you write to us: it finds your request in our logs.

## CORS

| Endpoints | From a browser |
|---|---|
| `/v1/embed/…` (player, chat, reactions, viewer sessions) | Any site, no cookies. |
| Everything that takes an API key | No: call them from your server. |

## Webhooks

Instead of polling, get `stream.live`, `stream.ended`, `recording.ready` and more POSTed to your server, signed in the [Standard Webhooks](https://www.standardwebhooks.com) format. See [Webhooks](/webhooks).
