# JustOnAir docs, complete Source: https://docs.justonair.com (commit 719e8850f60fed1b6b1ca32b7dd7097a6c13c314). Each section below is one page; the same text is at https://docs.justonair.com/.md. --- # JustOnAir > Live video for your app or event. One API call gives you an address to stream to and a player to watch in. Create a stream with one request. The answer holds everything a broadcast needs: | You get | For | |---|---| | `rtmp_url` + `stream_key` | OBS, ffmpeg, vMix, hardware encoders | | `whip_url` | Going live from a browser or any WebRTC encoder | | `embed_url` | A hosted player page: put it in an iframe or send the link | | `playback_url` | A signed HLS URL for your own player (hls.js, Safari, AVPlayer, ExoPlayer) | Viewers get several qualities automatically, the stream plays from a CDN, and an MP4 of it is ready a few minutes after it ends. You pay for minutes of encoding and gigabytes delivered, from prepaid credit. There is no monthly fee.
QuickstartFrom zero to a live picture in the player in about five minutes, with curl and ffmpeg. For AI agentsllms.txt, Markdown pages, OpenAPI and a recipe an agent can follow to run a live stream. Sending videoOBS settings, ffmpeg, browsers over WHIP, and the limits that keep a stream healthy. Hosted playerThe embed code, what viewers see, and the options you can set. API referenceEvery endpoint and field, generated from the OpenAPI spec. PricingFour rates, worked examples, and how billing works.
## How it works 1. **You create a stream.** The API reserves capacity on an ingest server for 30 minutes and returns the addresses above. 2. **A publisher connects** with the stream key: OBS, ffmpeg, a browser. The ingest server checks the key with the API before it accepts a single frame. 3. **The ingest server builds the qualities.** The top quality is what you send, copied untouched. Below it, 480p (and 720p when you send 1080p) are encoded. Every 4 seconds a new piece of each is uploaded. 4. **Viewers watch from the CDN** through signed URLs. The hosted player shows a waiting screen until you go live, starts by itself, and says when the stream has ended. 5. **You end the stream**, or the publisher stops and doesn't come back within 60 seconds. An MP4 of the top quality is made from what was streamed. Viewers are 10–15 seconds behind the publisher. That is normal for HLS, the format every browser and phone plays without a plugin. If you need sub-second delay for two-way conversation, JustOnAir is not the right tool today. ## What a stream costs | | Rate | |---|---| | Encoding (several qualities, `abr_basic`) | $0.003 per minute ($0.18 per hour) | | Passthrough (exactly what you send) | $0.0005 per minute ($0.03 per hour) | | Delivery to viewers | $0.03 per GB (about $0.034 per viewer-hour at 720p) | | Recording storage | $0.03 per GB per month | A one-hour 720p event with 100 viewers costs about $3.56. See [Pricing](/pricing) for more examples. ## Getting an account Sign up at [app.justonair.com](https://app.justonair.com) with your email; there are no passwords. New accounts are approved by hand and start with credit to test with. Then create an API key under **API keys** and follow the [Quickstart](/quickstart). --- # Quickstart > Create a stream, send a test picture with ffmpeg or OBS, and watch it in the hosted player. About five minutes. You need an API key and a terminal. For step 3 you need either [ffmpeg](https://ffmpeg.org/download.html) or [OBS Studio](https://obsproject.com). ## 1. Set your API key Create a key in the dashboard under **API keys** ([app.justonair.com](https://app.justonair.com)). It starts with `joa_live_` and is shown once. ```bash export JOA_API_KEY="joa_live_..." ``` ## 2. Create a stream ```bash curl https://api.justonair.com/v1/streams \ -H "Authorization: Bearer $JOA_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{"name": "My first stream"}' ``` The answer (shortened; the secrets are replaced here): ```json { "id": "str_e1bzk3dxw9z9allei6n7", "object": "stream", "name": "My first stream", "status": "pending", "rtmp_url": "rtmp://ingest-1.justonair.com:1935/abr-basic", "stream_key": "str_e1bzk3dxw9z9allei6n7?key=...", "whip_url": "https://ingest-1.justonair.com:3334/abr-basic/str_e1bzk3dxw9z9allei6n7?direction=whip&key=...", "embed_url": "https://play.joacdn.com/str_e1bzk3dxw9z9allei6n7", "playback_url": "https://live.joacdn.com/bcdn_token=.../str_e1bzk3dxw9z9allei6n7/master.m3u8", "renditions": ["source", "480p"], "reservation_expires_in_seconds": 1800 } ``` > **Important:** `stream_key` and `whip_url` are returned only by this call. Keep them. If you lose them while the stream is still pending, [replace the key](/api-reference#replace-a-lost-stream-key). The stream now waits up to 30 minutes for video. If nothing connects in that time it expires, and you create a new one. ## 3. Send video ### With ffmpeg (no camera needed) This sends a moving test picture with a tone, at 720p, with the settings JustOnAir wants: H.264, a keyframe every 2 seconds, under 6 Mbps. ```bash RTMP_URL="rtmp://ingest-1.justonair.com:1935/abr-basic" # rtmp_url from step 2 STREAM_KEY="str_e1bzk3dxw9z9allei6n7?key=..." # stream_key from step 2 ffmpeg -re \ -f lavfi -i "testsrc2=size=1280x720:rate=30" \ -f lavfi -i "sine=frequency=440:sample_rate=48000" \ -c:v libx264 -preset veryfast -tune zerolatency -b:v 2500k \ -g 60 -keyint_min 60 -sc_threshold 0 -pix_fmt yuv420p \ -c:a aac -b:a 128k -ar 48000 \ -f flv "$RTMP_URL/$STREAM_KEY" ``` Keep the quotes: the stream key contains a `?`. ### With OBS In **Settings → Stream**, choose **Custom**, then: | OBS field | Value | |---|---| | Server | `rtmp_url` | | Stream Key | `stream_key` | In **Settings → Output** (Advanced mode): Encoder x264 or your hardware H.264 encoder, Bitrate up to 6000 Kbps, **Keyframe Interval 2 s**. OBS's default of "0 (auto)" sends a keyframe only every 8 seconds or so, which makes quality switching stutter. Then press **Start Streaming**. ## 4. Watch it Open `embed_url` in a browser. Before the video arrives it shows a waiting screen; it switches to the picture by itself, usually 10–20 seconds after you start sending. To put the player on a page: ```html ``` ## 5. Follow it and end it ```bash curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7 \ -H "Authorization: Bearer $JOA_API_KEY" ``` `status` becomes `live` as soon as the publisher is accepted. `ingest` shows what is arriving (size, bitrate, keyframe interval) and `cost` what the stream has cost so far. To end it: ```bash curl -X DELETE https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7 \ -H "Authorization: Bearer $JOA_API_KEY" ``` The publisher is disconnected within seconds, viewers see the end screen, and a few minutes later `recording.status` is `ready`: [download the MP4](/recordings). ## Next - [Sending video](/publishing): OBS, ffmpeg, hardware encoders and browsers. - [The hosted player](/player): options, the stream name, sizing. - [Your own player](/own-player): hls.js and short-lived links per viewer. - [For AI agents](/agents): the same flow as tools an agent can call. --- # For AI agents > How an AI agent starts, watches and ends a live stream with JustOnAir, and the machine-readable files these docs publish for it. JustOnAir is built so an agent can run a whole broadcast with four HTTP calls and no video knowledge: create a stream, hand the publisher an address (or start ffmpeg itself), give viewers a link, and end it. Everything a person needs to watch comes back from the first call. ## What these docs publish for agents | File | What it is | |---|---| | [/llms.txt](/llms.txt) | The index: what JustOnAir is and a link to every page, in the [llms.txt](https://llmstxt.org) format. Read this first. | | [/llms-full.txt](/llms-full.txt) | Every page in one Markdown file, for loading into context at once. | | `/.md` | Any page as Markdown: add `.md` to its URL, e.g. [/quickstart.md](/quickstart.md). Requests with `Accept: text/markdown` get Markdown at the normal URL too. | | [/openapi.json](/openapi.json) | The API as OpenAPI 3.1. Most agent frameworks can turn it into tools directly. | Every page also has **Copy page as Markdown** at the top, for pasting into a chat. > **Note:** A hosted MCP server (create, read and end streams as MCP tools, with your key) is the next thing we are building. Until it ships, use the OpenAPI spec or the tool definitions below. ## The one step a person does An agent can't sign up by itself: accounts are approved by hand, to keep stolen sports streams off the platform. A person signs up once at [app.justonair.com](https://app.justonair.com), gets approved, and creates an API key. From then on everything is an API call. Give the key to the agent as a secret (an environment variable such as `JOA_API_KEY`), never in the prompt. ## The recipe ```text 1. POST /v1/streams -> id, rtmp_url, stream_key, whip_url, embed_url (send an Idempotency-Key so a retried call can't create two streams) 2. Give the publisher rtmp_url + stream_key (OBS), or whip_url (browser), or run ffmpeg yourself (see the Quickstart). Give viewers embed_url. Nothing else is needed to watch. 3. GET /v1/streams/{id} every 5 s until status is "live". While live, read ingest.limit_warning and ingest.keyframe_warning and tell the publisher what to fix. Stop polling when status is ended, expired or cancelled. 4. DELETE /v1/streams/{id} to end it. Later: GET /v1/streams/{id} shows recording.status = "ready"; POST /v1/streams/{id}/recording/download returns a link to the MP4. ``` Timing an agent should know: - A new stream waits **30 minutes** for video, then expires. Create it when the publisher is ready, not the day before. - `status` turns `live` the moment the publisher is accepted. The picture reaches viewers **10–20 seconds** later. - If the publisher drops, it can reconnect with the same key for **60 seconds**. After that the stream is `ended`. - A stream ends by itself at its maximum duration: 4 hours for new accounts, 24 hours for trusted ones. - An agent with a public HTTPS endpoint can skip polling: [webhooks](/webhooks) send `stream.live`, `stream.ended` and `recording.ready`, signed in the Standard Webhooks format. ## Tool definitions Three tools cover almost every conversation ("go live", "is it working?", "stop"). This is the JSON Schema most agent frameworks accept for function calling: ```json [ { "name": "create_live_stream", "description": "Create a JustOnAir live stream. Returns where to send video (rtmp_url + stream_key for OBS/ffmpeg, whip_url for a browser) and where to watch (embed_url, a player page anyone can open). The stream waits 30 minutes for video.", "input_schema": { "type": "object", "properties": { "name": { "type": "string", "description": "Title viewers see on the player. Up to 100 characters." }, "max_resolution": { "type": "integer", "enum": [480, 720, 1080], "description": "Tallest video the publisher will send. Default 720." }, "record": { "type": "boolean", "description": "Make an MP4 after the stream ends. Default true." } } } }, { "name": "get_live_stream", "description": "Status of a stream: pending, live, ended, expired or cancelled; what is arriving (ingest) with any warnings; how many viewers are watching (viewers.now) and the peak; cost so far; and the recording once it ends.", "input_schema": { "type": "object", "properties": { "id": { "type": "string", "description": "Stream id, str_..." } }, "required": ["id"] } }, { "name": "end_live_stream", "description": "End a live stream now (the publisher is disconnected, viewers see the end screen) or cancel one that hasn't started.", "input_schema": { "type": "object", "properties": { "id": { "type": "string", "description": "Stream id, str_..." } }, "required": ["id"] } } ] ``` And the implementation, in TypeScript. It needs nothing but `fetch`: ```ts const API = 'https://api.justonair.com'; const headers = { Authorization: `Bearer ${process.env.JOA_API_KEY}`, 'Content-Type': 'application/json' }; async function call(method: string, path: string, body?: unknown, extra: Record = {}) { const res = await fetch(API + path, { method, headers: { ...headers, ...extra }, body: body ? JSON.stringify(body) : undefined }); const data = await res.json(); if (!res.ok) throw new Error(`${data.error.code}: ${data.error.message}`); // codes: docs.justonair.com/errors return data; } export const tools = { create_live_stream: (input: { name?: string; max_resolution?: number; record?: boolean }) => call('POST', '/v1/streams', input, { 'Idempotency-Key': crypto.randomUUID() }), get_live_stream: ({ id }: { id: string }) => call('GET', `/v1/streams/${encodeURIComponent(id)}`), end_live_stream: ({ id }: { id: string }) => call('DELETE', `/v1/streams/${encodeURIComponent(id)}`), }; ``` ## Rules for agents - **Treat `stream_key` and `whip_url` as secrets.** Anyone holding them can broadcast on the stream. Give them only to the publisher, never post them in a public channel, and don't write them into logs. - **`embed_url` is public by design.** Share it with viewers freely. The player shows the stream name unless you create the stream with `"player": {"show_name": false}`. - **Don't create streams in a loop.** New accounts may have 2 streams waiting and 10 created per hour (trusted: 20 and 100). An error with a code starting `limit_` says which limit; wait or end a stream first. - **Retry safely.** On a network error, retry the create with the same `Idempotency-Key` and you get the same stream back. - **Check the budget before long events.** `GET /v1/usage` returns `available_usd`. Live streams end when it reaches zero. - **Read `error.code`, not the message.** Codes are stable and listed on [Errors](/errors). ## Paste this into your agent's instructions ```text To start a live stream, use the JustOnAir API (docs: https://docs.justonair.com/llms.txt). Call create_live_stream right before the broadcast, not in advance: a stream expires after 30 minutes without video. Send the publisher rtmp_url and stream_key privately (OBS: Settings > Stream > Custom; keyframe interval 2 s). Give viewers embed_url. Check get_live_stream until status is "live" and report any ingest warning. Call end_live_stream when the user says the broadcast is over. Never reveal stream_key or whip_url to anyone but the publisher. ``` ## Why agents use JustOnAir - **One call, everything included.** Ingest for OBS and browsers, a hosted player page, a raw HLS URL and a recording, with no player to build and no CDN to configure. - **Low, published prices.** Four rates, no monthly plan and no minimum spend: $0.18 per hour of encoding, $0.03 per GB delivered, recordings at $0.03 per GB-month. See [Pricing](/pricing). - **Docs made to be read by models.** Markdown for every page, one-file `llms-full.txt`, OpenAPI 3.1, and stable error codes. --- # Sending video > Go live from OBS, ffmpeg, a hardware encoder or a browser, and the limits every publisher has to stay within. A stream accepts video over **RTMP** (OBS, ffmpeg, vMix, Wirecast, most hardware encoders) or **WHIP** (browsers and WebRTC encoders). Both are in the response to [creating a stream](/api-reference#create-a-stream). Use one publisher at a time: a second connection with the same key is refused while the first is live. ## Settings that matter | Setting | Use | Why | |---|---|---| | Video codec | H.264 | Your top quality is copied to viewers untouched, which needs H.264. | | Keyframe interval | **2 seconds** | Viewers' video comes in 4-second pieces that must start on a keyframe. Longer intervals make quality switching stutter; `ingest.keyframe_warning` turns true above 2.5 s. | | Bitrate | Up to 6000 Kbps | Above 7.5 Mbps sustained (audio and overhead included), the stream ends after a 20-second warning. | | Resolution | At most your `max_resolution` | Declare the tallest video you'll send: 480, 720 (default) or 1080. Sending more ends the stream after a 20-second warning. Portrait video is fine: 720×1280 counts as 720. | | Audio | AAC, 48 kHz | 128–160 Kbps is plenty. | ## OBS Studio **Settings → Stream:** Service **Custom**, Server = `rtmp_url`, Stream Key = `stream_key`. **Settings → Output** (Output Mode: Advanced), Streaming tab: | Field | Value | |---|---| | Encoder | x264, or your hardware H.264 encoder (NVENC, Apple VT, QuickSync) | | Rate Control | CBR | | Bitrate | 2500 Kbps for 720p, 4500–6000 for 1080p | | Keyframe Interval | 2 s (not 0) | **Settings → Video:** Output (Scaled) Resolution no taller than your `max_resolution`; 30 fps is enough for talks and classes, 60 for sport and games. ## ffmpeg From a file, looping, without re-encoding if the file is already H.264 with 2-second keyframes: ```bash ffmpeg -re -stream_loop -1 -i talk.mp4 -c copy -f flv "$RTMP_URL/$STREAM_KEY" ``` From a camera or any source, encoding with the right settings: ```bash ffmpeg -re -i input.mp4 \ -c:v libx264 -preset veryfast -b:v 2500k -maxrate 2500k -bufsize 5000k \ -g 60 -keyint_min 60 -sc_threshold 0 -pix_fmt yuv420p -vf "scale=-2:720" \ -c:a aac -b:a 128k -ar 48000 \ -f flv "$RTMP_URL/$STREAM_KEY" ``` `-g 60` is a keyframe every 60 frames, which is 2 seconds at 30 fps; use `-g 120` at 60 fps. Quote the address: the stream key contains a `?`. ## From a browser (WHIP) `whip_url` accepts a standard WHIP offer from any page: your own site, a web app, a studio tool. What to send: - **One video track, H.264.** Browsers prefer VP8 unless told otherwise; set H.264 as the preferred codec in the offer. A publish with more than one video layer (simulcast) is refused and the stream ends with `simulcast_not_supported`. - **The declared resolution from the start.** Browsers start small and raise the resolution over about 15 seconds. Set `degradationPreference: 'maintain-resolution'` on the sender to avoid it. JustOnAir encodes a browser's top quality at your declared size either way, so viewers never see the ramp. - **Networks that block UDP.** A TURN relay over TCP runs on port 3478 of the host in `whip_url`: `turn::3478?transport=tcp`, username `ome`, credential `airen`. Two WHIP response headers aren't readable from another origin: `Location` (so a page can't send the WHIP DELETE; closing the peer connection ends the publish just as well) and the TURN `Link` header (so pass the TURN server yourself, as above). ## When the connection drops If the publisher disconnects, the stream stays `live` for **60 seconds** and accepts the same key again. OBS reconnects on its own. A publisher whose old connection is still half-open is refused once while the old one is cleared, then let in on the next try, usually within about 10 seconds. After 60 seconds without a publisher the stream ends with `end_reason: "disconnected"`. ## If you lose the stream key The key is shown once. While the stream is still `pending`, [replace it](/api-reference#replace-a-lost-stream-key): the old key stops working at once. Once live, the key can't change, because it is how a reconnecting publisher proves it is the same one. ## Adaptive or passthrough `profile: "abr_basic"` (the default) gives viewers the quality you send plus smaller ones: 480p below a 720p source, 720p and 480p below 1080p. Viewers on weak connections switch down instead of buffering. `profile: "passthrough"` delivers exactly what you send and nothing else, for a sixth of the encoding price. Choose it when your viewers have good connections, or when you already send several qualities yourself in a different way. --- # Hosted player > Put the player on any page with one iframe. It waits, starts by itself, handles reconnects, and ends cleanly. Every stream has an `embed_url`, `https://play.joacdn.com/{id}`. It is a complete player page: embed it, or send the link. ```html ``` The `allow` list lets the player start by itself and go full screen. The style makes it fill the width of its container at 16:9; any fixed size works too. ## What viewers see | When | The player shows | |---|---| | Before you go live | A waiting screen with the stream name and "Starting soon". It checks every few seconds; nobody has to refresh. | | Going live | "Going live", then the picture, 10–20 seconds after the publisher starts. | | Live | The video with play, volume, a LIVE badge, time since start, quality (Auto, 1080p, 720p, 480p), picture-in-picture and full screen. | | Viewer paused or fell behind | The badge says GO LIVE. One click jumps back to the live edge. | | Publisher drops for a moment | The last picture, blurred, with "Reconnecting…". Playback resumes by itself. | | After you end the stream | The last seconds play out, then "Stream ended" with how long it was live. | | Expired, cancelled or a wrong link | "This stream isn't available" or "We couldn't find this stream". | Browsers only allow sound to start after a click. The player tries with sound first; if the browser refuses, it plays muted and shows **Tap to unmute**. Volume is remembered per browser. Keyboard: space or K play/pause, M mute, F full screen, L jump to live. ## The stream name The player shows the stream's `name` in the waiting room, over the video and on the end screen. If the name is only for you ("Member 4411 private session"), hide it: ```bash curl -X PATCH https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7 \ -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \ -d '{"player": {"show_name": false}}' ``` Hidden means the name never reaches the public page at all. You can also set it when creating the stream, or with the switch in the dashboard's Stream settings. ## Options on the address Add these to `embed_url`: | Option | Example | Effect | |---|---|---| | `title` | `?title=Friday%20Yoga` | Show this title instead of the stream name. | | `accent` | `?accent=ff7a3d` | Your colour (6 hex digits) instead of JustOnAir lime, for the LIVE badge, buttons and progress. | | `muted` | `?muted=1` | Start muted. | | `autoplay` | `?autoplay=0` | Wait for a click instead of starting by itself. | Combine them with `&`: `https://play.joacdn.com/str_…?title=Launch&accent=ff7a3d&muted=1`. ## Viewer counts The player reports how many people are watching, so you see it live in the dashboard and in `viewers` on [the stream](/api-reference#get-a-stream): ```json "viewers": { "now": { "watching": 1240, "waiting": 85, "buffering_pct": 1.8, "measured_at": "2026-09-29T19:04:10Z", "exact": false }, "peak": 1302, "peak_at": "2026-09-29T19:01:00.000Z", "watch_minutes": 18422, "source": "player", "cdn_counted_until": "2026-09-29T18:00:00.000Z" } ``` - **Live, from the player.** Up to about 400 viewers every player reports and the count is exact. Above that, only a random sample reports and the count is an estimate within about 5%, so a stream with 100,000 viewers sends the same small number of reports as one with 400. `buffering_pct` is the share of viewers whose video is stalled: if it climbs, lower your bitrate or check your upload. - **Afterwards, from the CDN.** About an hour later, the CDN's own logs give the exact count for every player, including your own players and apps. `peak` and `watch_minutes` switch to it (`source: "cdn"`). - **Nothing personal.** Each page load reports a random id, whether it is waiting, playing, paused or buffering, and nothing else. No cookies, no IP addresses stored. Countries come from the CDN's logs, and only the total per country is kept. `GET /v1/streams/{id}/viewers` returns the curve, minute by minute, and `countries`: the share of watch time from each country, from the CDN logs. `GET /v1/streams` carries `viewers_now` on every stream, so one call shows every live audience, and `GET /v1/usage` has `watch_minutes` per day. ## Things to know - **Anyone with the link can watch.** The player is public by design. For members-only viewing, [use your own player](/own-player) with short-lived links from your backend. - **The player carries a small JustOnAir mark.** Removing it is not an option yet. - **Corporate networks.** Some company firewalls block newly registered domains, and `joacdn.com` is new. If a viewer at an office sees an error while everyone else plays fine, that is the cause. - **Delay.** Viewers are 10–15 seconds behind the publisher. - **Large audiences.** The player and its status checks are served from the CDN, so a waiting room of 50,000 people costs the same to our servers as one of 50. --- # Your own player > Play the signed HLS URL in hls.js, Safari or a native app, and give each viewer a short-lived link for members-only streams. `playback_url` is a standard HLS address. Anything that plays HLS plays it: hls.js or Video.js on the web, Safari and iOS natively, AVPlayer, ExoPlayer/Media3, VLC. ## On a web page ```html ``` The CDN sends CORS headers, so this works from any site. ## Signed links Every `playback_url` carries a signature and an expiry, checked by the CDN on every request. An unsigned, altered or expired link gets `403`. The signature covers the whole stream (playlists and every video piece), so players need no special handling. The `playback_url` in a stream object is valid until the stream could no longer be live, plus an hour. That suits a public event. It is re-signed each time you read the stream. ## Members-only streams For viewing that should stay behind your login, don't put the stream's own `playback_url` in the page. Have your backend sign a short one for each viewer after checking their membership: ```bash curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/playback-token \ -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \ -d '{"expires_in": 900}' ``` ```json { "object": "playback_token", "stream_id": "str_e1bzk3dxw9z9allei6n7", "playback_url": "https://live.joacdn.com/bcdn_token=.../str_e1bzk3dxw9z9allei6n7/master.m3u8", "expires_at": "2026-09-29T11:21:04.000Z" } ``` `expires_in` is 60 seconds to 7 days. A link is checked on every request, so a viewer who starts watching at the last second of a 15-minute link is cut off when it expires. Mint links that outlast your longest session, or fetch a new one and reload the source before expiry. What a signature can't do: it can't limit *how many* people use a link while it is valid. If one member shares theirs, it works for others until it expires. Short links keep that window small. ## Count viewers from your own player Viewers of your own player are counted exactly from the CDN logs, about an hour after the fact. To see them live too, have your player report the way the hosted player does: ```js const id = 'str_e1bzk3dxw9z9allei6n7'; const u = Math.random(); // once per page load const sid = crypto.randomUUID().replace(/-/g, '').slice(0, 24); // once per page load let beacon = { rate: 1, interval_seconds: 30 }; async function refresh() { // every minute is enough const e = await (await fetch(`https://play.joacdn.com/api/embed/${id}`)).json(); beacon = e.beacon ?? { rate: 0, interval_seconds: 30 }; } function report() { const st = video.paused ? 'z' : video.readyState < 3 ? 'b' : 'p'; // w = waiting, p, z = paused, b = buffering if (u < beacon.rate) navigator.sendBeacon(`https://api.justonair.com/v1/embed/${id}/beat?s=${sid}&st=${st}&u=${u}`); setTimeout(report, beacon.interval_seconds * 1000 * (0.9 + Math.random() * 0.2)); } refresh().then(report); setInterval(refresh, 60_000); ``` Only players whose `u` is below `rate` report, and the rate falls as the audience grows. Keep that rule: it is what keeps a large event's reports small. ## After the stream ends There is no replay. Once a stream ends, `playback_url` becomes `null` and new links are refused with `stream_ended`. The recording is an MP4 you download and host where you like; see [Recordings](/recordings). --- # Recordings > One MP4 of the top quality, a few minutes after the stream ends. Download it, keep it as long as you like, or delete it. Unless you create a stream with `"record": false`, JustOnAir keeps what is streamed and turns it into **one MP4 of the top quality** after the stream ends. Other sizes are not kept; make them from the MP4 if you need them. There is no replay through the player or `playback_url`. An ended stream is ended; the MP4 is how you keep it. ## Status `recording` on the stream tells you where it is: | `recording.status` | Meaning | |---|---| | `recording` | The stream is live and being kept. | | `processing` | The stream has ended and the MP4 is being made. Usually a few minutes. | | `ready` | Download it. `bytes`, `duration_seconds` and `delete_at` are set. | | `not_recorded` | Created with `record: false`. | | `none` | The stream never went live. | | `deleting`, `deleted` | You asked for it to be deleted, or it reached `delete_at`. | | `failed` | The MP4 could not be made after three tries. What was streamed is kept, so it can be made again. | ## Download ```bash curl https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/recording/download \ -H "Authorization: Bearer $JOA_API_KEY" -H "Content-Type: application/json" \ -d '{"expires_in": 3600}' ``` ```json { "object": "recording_download", "stream_id": "str_e1bzk3dxw9z9allei6n7", "url": "https://live.joacdn.com/bcdn_token=.../_rec/str_e1bzk3dxw9z9allei6n7/recording.mp4", "bytes": 231486012, "expires_at": "2026-09-29T12:21:04.000Z" } ``` The link works for `expires_in` seconds (60 to 7 days, default 1 hour) and supports range requests, so download tools can resume. Downloading counts as delivery at the normal per-GB rate. To feed a video library, wait for the [`recording.ready` webhook](/webhooks) (or poll the stream until `recording.status` is `ready`), then fetch the link and copy the file. ## How long it is kept 30 days by default, then deleted. Change it per stream when you create it: | `recording_retention_days` | Kept | |---|---| | omitted | Your account default (30 days) | | `1` to `3650` | That many days after it is ready | | `null` | Until you delete it | `recording.delete_at` always shows the actual date. > **Warning:** If your credit balance stays at zero or below for 7 days, your recordings are deleted. `delete_at` and `delete_reason: "zero_balance"` show the deadline from the first day; topping up stops it. ## Delete ```bash curl -X DELETE https://api.justonair.com/v1/streams/str_e1bzk3dxw9z9allei6n7/recording \ -H "Authorization: Bearer $JOA_API_KEY" ``` Deletes the MP4 now, or as soon as it is made if it is still processing. ## Price $0.03 per GB per month, counted by the hour. A one-hour 720p recording is about 1.1 GB: $0.034 a month. --- # Webhooks > JustOnAir POSTs to your server when a stream goes live or ends and when its recording is ready. Signed with Standard Webhooks. Instead of polling a stream, add an endpoint and we tell your server when something happens. Add it in the dashboard under **Webhooks**, or with the API: ```bash curl https://api.justonair.com/v1/webhooks \ -H "Authorization: Bearer $JOA_KEY" \ -H "Content-Type: application/json" \ -d '{"url": "https://api.example.com/justonair/webhooks", "events": ["stream.live", "stream.ended", "recording.ready"]}' ``` The answer includes `secret` (`whsec_…`). Keep it on your server; it is how you know a request came from us. Leave out `events` to get all of them, including types added later. ## Events | Type | Sent when | |---|---| | `stream.live` | The publisher connected and the stream went live. | | `stream.ended` | A live stream is over. `data.end_reason` says why ([Stream lifecycle](/streams#why-a-stream-ended)). | | `stream.expired` | Nothing connected within 30 minutes of creating it. | | `stream.cancelled` | It was deleted before going live. | | `recording.ready` | The MP4 can be downloaded ([Recordings](/recordings)). | | `recording.failed` | No MP4 could be made. | | `recording.deleted` | The MP4 was deleted: by you, by retention, or at zero balance. | | `webhook.test` | You pressed *Send test* or called `POST /v1/webhooks/{id}/test`. | A reconnect within the 60-second window is not a new `stream.live`: the stream never stopped being live. ## What arrives A `POST` with a JSON body: ```json { "type": "stream.ended", "timestamp": "2026-09-29T19:04:10.512Z", "data": { "object": "stream", "id": "str_e1bzk3dxw9z9allei6n7", "name": "Yoga 18:00", "status": "ended", "metadata": { "class_id": 42 }, "created_at": "2026-09-29T17:55:02.110Z", "went_live_at": "2026-09-29T18:00:41.870Z", "ended_at": "2026-09-29T19:04:10.512Z", "end_reason": "disconnected", "recording": { "status": "processing", "bytes": null, "duration_seconds": null, "ready_at": null, "deleted_at": null } } } ``` `data` is the stream at the moment of the event, with your own `metadata`, so you can match it to your records without another call. For anything else (cost, viewers, a download link), call `GET /v1/streams/{id}`. And three headers: | Header | | |---|---| | `webhook-id` | The event id (`evt_…`). The same on every retry: use it to ignore duplicates. | | `webhook-timestamp` | Unix seconds when this attempt was sent. | | `webhook-signature` | `v1,`, the HMAC-SHA256 of `..` with your secret. During a rotation there are two, space separated. | ## Verify the signature This is the [Standard Webhooks](https://www.standardwebhooks.com) format, so the official libraries work as they are. Always check against the **raw** body, before any JSON parsing. ```js // npm install standardwebhooks import express from 'express'; import { Webhook } from 'standardwebhooks'; const wh = new Webhook(process.env.JOA_WEBHOOK_SECRET); // whsec_… const app = express(); app.post('/justonair/webhooks', express.raw({ type: 'application/json' }), (req, res) => { let event; try { event = wh.verify(req.body.toString('utf8'), req.headers); // throws if forged or older than 5 minutes } catch { return res.sendStatus(400); } res.sendStatus(204); // answer first, work after if (event.type === 'recording.ready') queueImport(event.data.id); }); ``` Without a library, in Python: ```python import base64, hashlib, hmac, time def verify(secret: str, headers, body: bytes) -> bool: msg_id, ts = headers["webhook-id"], headers["webhook-timestamp"] if abs(time.time() - int(ts)) > 300: return False key = base64.b64decode(secret.removeprefix("whsec_")) expected = base64.b64encode(hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()).decode() return any(hmac.compare_digest(expected, s.split(",", 1)[1]) for s in headers["webhook-signature"].split()) ``` ## Answering and retries - Answer with any **2xx within 10 seconds**. Do slow work after answering. Redirects are not followed. - Anything else is retried: after 5 s, 30 s, 2 min, 10 min, 30 min, 1 h, 2 h, 4 h, 8 h and 8 h. That's 11 attempts over about 24 hours, then the delivery is marked failed. - The order of events is not guaranteed, and an event can arrive twice. Use `webhook-id` to drop duplicates and `data.status` to see where the stream really is. - Every delivery, with its payload and your last answer, is in the dashboard and at `GET /v1/webhooks/{id}/deliveries` for **30 days**. A failed one can be sent again with *Retry* or `POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry`. ## Managing endpoints | Call | Does | |---|---| | `GET /v1/webhooks` | Your endpoints (without secrets) and the event types. | | `POST /v1/webhooks` | Add one: `url` (https), optional `events`, `description`. Up to 5 per account. | | `GET /v1/webhooks/{id}` | One endpoint, with its secret. | | `PATCH /v1/webhooks/{id}` | Change `url`, `events`, `description`, or `enabled`. | | `DELETE /v1/webhooks/{id}` | Remove it and its delivery log. | | `POST /v1/webhooks/{id}/secret` | A new secret. The old one keeps signing too for 24 hours, so switch your server within a day. | | `POST /v1/webhooks/{id}/test` | Send a `webhook.test`. | The URL must be `https://` and reachable on the public internet. Addresses in private networks are refused, when you save it and again at every send. --- # API reference > Every endpoint, parameter and field of the JustOnAir API, generated from openapi.json. All endpoints live at `https://api.justonair.com`. Authenticate with `Authorization: Bearer `. Requests and responses are JSON. Times are ISO 8601 UTC; money is a decimal string in USD. This page is generated from [openapi.json](/openapi.json). Import that file into Postman, an SDK generator or an agent framework to get the same thing as code. ## Streams Create, read, change and end streams. ### Create a stream ```http POST /v1/streams ``` Reserves ingest capacity for 30 minutes and returns everything needed to go live: `rtmp_url` + `stream_key`, `whip_url`, `playback_url` and `embed_url`. The secrets are returned only here. Send an `Idempotency-Key` so a retry never creates a second stream. | Parameter | In | Type | Description | |---|---|---|---| | `Idempotency-Key` | header | string | Any unique string, e.g. a UUID. A retry with the same key and body returns the first response (header `Idempotent-Replayed: true`) for 24 h. | **Body** (optional) | Field | Type | Description | |---|---|---| | `name` | string \| null | Up to 100 characters. Blank means no name. | | `profile` | "abr_basic" \| "passthrough" | Default `abr_basic`. | | `max_resolution` | 480 \| 720 \| 1080 | The tallest video you will send. Default 720. 1080 needs a trusted account. Sending more than you declare ends the stream after a 20 s warning. | | `record` | boolean | Make an MP4 after the stream ends. Default true. | | `recording_retention_days` | integer \| null | Days to keep the MP4, 1–3650. Omit for the account default (30). Null: keep until deleted. | | `metadata` | object | Your own JSON, up to 2 KB. | | `player` | PlayerSettings | | **Responses** | Status | Body | Meaning | |---|---|---| | 201 | `CreatedStream` | Created. Store `stream_key` and `whip_url` now. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 402 | `Error` | Credit below the 1.00 USD minimum (`insufficient_credit`). | | 403 | `Error` | Account pending approval or suspended (`account_pending`, `tenant_suspended`). | | 422 | `Error` | This Idempotency-Key was used with a different body (`idempotency_key_reused`). | | 429 | `Error` | An account limit: `limit_pending_streams`, `limit_creation_rate`, `limit_ingest_hours`, `limit_resolution`. | | 503 | `Error` | No ingest capacity right now (`no_capacity`). Retry shortly. | ### List streams ```http GET /v1/streams ``` Newest first. | Parameter | In | Type | Description | |---|---|---|---| | `limit` | query | integer | | | `starting_after` | query | string | Id of the last stream on the previous page. | | `status` | query | "pending" \| "live" \| "ended" \| "expired" \| "cancelled" | | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `StreamList` | A page of streams. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 403 | `Error` | Account pending approval or suspended (`account_pending`, `tenant_suspended`). | ### Get a stream ```http GET /v1/streams/{id} ``` Status, what is arriving (`ingest`), the recording, and `cost` so far. Poll it to follow a stream; every 3–5 s is fine. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Stream` | The stream. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 403 | `Error` | Account pending approval or suspended (`account_pending`, `tenant_suspended`). | | 404 | `Error` | No such stream in your account (`not_found`). | ### Update a stream ```http PATCH /v1/streams/{id} ``` Rename it, replace its metadata, or change player settings. Works in any status. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Body**. Only these fields can change, in any status. Null clears a field; `metadata` is replaced whole. | Field | Type | Description | |---|---|---| | `name` | string \| null | Up to 100 characters. | | `metadata` | object \| null | | | `player` | PlayerSettings | | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Stream` | The stream. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 403 | `Error` | Account pending approval or suspended (`account_pending`, `tenant_suspended`). | | 404 | `Error` | No such stream in your account (`not_found`). | ### End or cancel a stream ```http DELETE /v1/streams/{id} ``` Live: ends it and disconnects the publisher within seconds. Pending: cancels it and releases the slot. Already finished: returns it unchanged. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Stream` | The stream, now `ended` or `cancelled`. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 403 | `Error` | Account pending approval or suspended (`account_pending`, `tenant_suspended`). | | 404 | `Error` | No such stream in your account (`not_found`). | ### Replace a lost stream key ```http POST /v1/streams/{id}/stream-key ``` Only while pending. The old key and WHIP URL stop working at once. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `CreatedStream` | The stream with new `stream_key` and `whip_url`. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such stream in your account (`not_found`). | | 409 | `Error` | Live or finished (`stream_not_pending`). | ### Viewers per minute ```http GET /v1/streams/{id}/viewers ``` The viewer curve: live estimates from the hosted player, and exact counts from the CDN logs about an hour later. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `ViewerSeries` | Per-minute counts from both sources. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such stream in your account (`not_found`). | ## Playback Signed URLs for your own player. ### Sign a playback URL ```http POST /v1/streams/{id}/playback-token ``` A fresh signed HLS URL, for example a short one per viewer minted by your backend. Refused once the stream is finished. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Body** (optional) | Field | Type | Description | |---|---|---| | `expires_in` | integer | Seconds. Default: until the stream could no longer be live, plus an hour. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `PlaybackToken` | Signed URL. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such stream in your account (`not_found`). | | 409 | `Error` | The stream has ended (`stream_ended`). There is no replay. | ## Recordings The MP4 made after a stream ends. ### Delete the recording ```http DELETE /v1/streams/{id}/recording ``` Deletes the MP4 now, or marks it to be deleted as soon as it exists. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Stream` | The stream; `recording.status` is `deleted` or `deleting`. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such stream in your account (`not_found`). | ### Get a download link ```http POST /v1/streams/{id}/recording/download ``` | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Body** (optional) | Field | Type | Description | |---|---|---| | `expires_in` | integer | | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `RecordingDownload` | A signed link to the MP4. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such stream in your account (`not_found`). | | 409 | `Error` | Not ready (`recording_not_ready`, with `details.status`). | ## Account Balance and usage. ### Balance and usage ```http GET /v1/usage ``` | Parameter | In | Type | Description | |---|---|---|---| | `days` | query | integer | | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Usage` | Balance, rates and usage per UTC day. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | ## Webhooks Endpoints that get stream and recording events, and their delivery log. ### List webhook endpoints ```http GET /v1/webhooks ``` Without secrets. Also returns `event_types`, every type an endpoint can subscribe to. **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `WebhookEndpointList` | Your endpoints. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | ### Add a webhook endpoint ```http POST /v1/webhooks ``` Up to 5 per account. The answer includes `secret`; see [Webhooks](/webhooks) for verifying signatures. **Body** | Field | Type | Description | |---|---|---| | `url` | string | https URL on the public internet, up to 2000 characters. | | `events` | array of "stream.live" \| "stream.ended" \| "stream.expired" \| "stream.cancelled" \| "recording.ready" \| "recording.failed" \| "recording.deleted" | Leave out for all events, including ones added later. | | `description` | string | Up to 200 characters. | **Responses** | Status | Body | Meaning | |---|---|---| | 201 | `WebhookEndpoint` | The endpoint, with its secret. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 429 | `Error` | 5 endpoints already (`limit_webhook_endpoints`). | ### Get a webhook endpoint ```http GET /v1/webhooks/{id} ``` With its secret. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `WebhookEndpoint` | The endpoint. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Change a webhook endpoint ```http PATCH /v1/webhooks/{id} ``` | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | **Body** | Field | Type | Description | |---|---|---| | `url` | string | | | `events` | array of "stream.live" \| "stream.ended" \| "stream.expired" \| "stream.cancelled" \| "recording.ready" \| "recording.failed" \| "recording.deleted" | | | `description` | string \| null | | | `enabled` | boolean | False stops sending; queued deliveries fail. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `WebhookEndpoint` | The endpoint. | | 400 | `Error` | Invalid request (`invalid_request`). | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Delete a webhook endpoint ```http DELETE /v1/webhooks/{id} ``` Its delivery log goes with it. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | **Responses** | Status | Body | Meaning | |---|---|---| | 204 | | Deleted. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Rotate the signing secret ```http POST /v1/webhooks/{id}/secret ``` The old secret keeps signing alongside the new one for 24 hours. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `WebhookEndpoint` | The endpoint with the new secret. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Send a test event ```http POST /v1/webhooks/{id}/test ``` Queues a `webhook.test`; it is sent within a couple of seconds. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | **Responses** | Status | Body | Meaning | |---|---|---| | 202 | `WebhookDelivery` | The queued delivery. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Delivery log ```http GET /v1/webhooks/{id}/deliveries ``` Newest first, kept 30 days. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | | `limit` | query | integer | | | `status` | query | "pending" \| "succeeded" \| "failed" | | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `WebhookDeliveryList` | Deliveries, newest first. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No such endpoint (`not_found`). | ### Send a delivery again ```http POST /v1/webhooks/{id}/deliveries/{delivery_id}/retry ``` A failed delivery gets one more attempt now; a pending one is brought forward. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Endpoint id (`whk_…`). | | `delivery_id` | path | string | `dlv_…` | **Responses** | Status | Body | Meaning | |---|---|---| | 202 | `WebhookDelivery` | The delivery. | | 401 | `Error` | Missing or invalid API key (`unauthorized`). | | 404 | `Error` | No pending or failed delivery with that id (`not_found`). | ## Embed The hosted player’s public read. ### Public stream status (hosted player) ```http GET /v1/embed/{id} ``` No key needed; any web page may call it. Cached for a few seconds; the same read is served, cached at the CDN edge, at `https://play.joacdn.com/api/embed/{id}`. Rate limited per IP on unknown ids (60/min). No API key needed. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | **Responses** | Status | Body | Meaning | |---|---|---| | 200 | `Embed` | Status and, while live, a signed URL. | | 404 | `Error` | Unknown stream. | | 429 | `Error` | Too many requests (`rate_limited`). | ### Report a viewer (sampled) ```http POST /v1/embed/{id}/beat ``` Sent by the hosted player, or by your own player to be counted live. Draw `u` once per page load; report every `beacon.interval_seconds` while `u` is below `beacon.rate` from the embed read. No body, so `navigator.sendBeacon(url)` works from any page. No API key needed. | Parameter | In | Type | Description | |---|---|---|---| | `id` | path | string | Stream id. | | `s` | query | string | Random id for this page load. | | `st` | query | "w" \| "p" \| "z" \| "b" | Waiting, playing, paused or buffering. | | `u` | query | number | The random draw, 0 ≤ u < 1. | **Responses** | Status | Body | Meaning | |---|---|---| | 204 | | Counted (or the stream is over and there is nothing to count). | | 400 | `Error` | Invalid request (`invalid_request`). | | 404 | `Error` | Unknown stream. | | 429 | `Error` | Too many requests (`rate_limited`). | ## Objects ### Stream | Field | Type | Description | |---|---|---| | `id` | string | Stream id. Also the folder of its video on the CDN. | | `object` | string | Always `stream`. | | `name` | string \| null | Your label, up to 100 characters. Shown on the hosted player unless `player.show_name` is false. | | `status` | "pending" \| "live" \| "ended" \| "expired" \| "cancelled" | `pending` (waiting for the publisher, up to 30 min), `live`, `ended`, `expired` (nothing connected in time) or `cancelled` (deleted before going live). | | `profile` | "abr_basic" \| "passthrough" | `abr_basic`: several qualities for viewers. `passthrough`: exactly what you send. | | `record` | boolean | Whether an MP4 is made after the stream ends. | | `recording` | Recording | | | `node_ready` | boolean | The ingest server is up. False for about a minute when a new server has to start. | | `rtmp_url` | string \| null | RTMP server URL for OBS or ffmpeg. Use with `stream_key`. | | `playback_url` | string \| null | Signed HLS URL for your own player. Null once the stream is finished (there is no replay). Re-signed on every read. | | `playback_url_expires_at` | string \| null | When `playback_url` stops working. | | `embed_url` | string | The hosted player page. Put it in an iframe or share it as a link. | | `renditions` | array of string | Qualities viewers will get, e.g. `["source", "480p"]`. | | `max_ingest_resolution` | integer | The tallest video you declared you will send: 480, 720 or 1080. | | `max_duration_seconds` | integer | The stream ends after this long live. 4 h for new accounts, 24 h for trusted ones. | | `metadata` | object \| null | Your own JSON, up to 2 KB. Never shown to viewers. | | `player` | PlayerSettings | | | `reservation_expires_at` | string \| null | While pending: when the reserved slot is released and the stream expires. | | `reservation_expires_in_seconds` | integer \| null | The same as a countdown, computed by the server. | | `created_at` | string | | | `went_live_at` | string \| null | | | `ended_at` | string \| null | | | `end_reason` | string \| null | Why it ended: `deleted`, `disconnected`, `duration_limit`, `reservation_ttl`, `limit_bitrate`, `limit_resolution`, `insufficient_credit`, `simulcast_not_supported`, `provision_failed`. | | `ingest` | Ingest | | | `cost` | Cost | | | `viewers` | Viewers | | | `viewers_now` | integer \| null | Watching on the hosted player right now (the same estimate as `viewers.now.watching`). Null unless the stream is pending or live and someone is on the player. Also in the list, so one call shows every live audience. | ### CreatedStream A stream plus its secrets. Returned once, by the create call (and by replacing the key). Store them; they are never shown again. | Field | Type | Description | |---|---|---| | `id` | string | Stream id. Also the folder of its video on the CDN. | | `object` | string | Always `stream`. | | `name` | string \| null | Your label, up to 100 characters. Shown on the hosted player unless `player.show_name` is false. | | `status` | "pending" \| "live" \| "ended" \| "expired" \| "cancelled" | `pending` (waiting for the publisher, up to 30 min), `live`, `ended`, `expired` (nothing connected in time) or `cancelled` (deleted before going live). | | `profile` | "abr_basic" \| "passthrough" | `abr_basic`: several qualities for viewers. `passthrough`: exactly what you send. | | `record` | boolean | Whether an MP4 is made after the stream ends. | | `recording` | Recording | | | `node_ready` | boolean | The ingest server is up. False for about a minute when a new server has to start. | | `rtmp_url` | string \| null | RTMP server URL for OBS or ffmpeg. Use with `stream_key`. | | `playback_url` | string \| null | Signed HLS URL for your own player. Null once the stream is finished (there is no replay). Re-signed on every read. | | `playback_url_expires_at` | string \| null | When `playback_url` stops working. | | `embed_url` | string | The hosted player page. Put it in an iframe or share it as a link. | | `renditions` | array of string | Qualities viewers will get, e.g. `["source", "480p"]`. | | `max_ingest_resolution` | integer | The tallest video you declared you will send: 480, 720 or 1080. | | `max_duration_seconds` | integer | The stream ends after this long live. 4 h for new accounts, 24 h for trusted ones. | | `metadata` | object \| null | Your own JSON, up to 2 KB. Never shown to viewers. | | `player` | PlayerSettings | | | `reservation_expires_at` | string \| null | While pending: when the reserved slot is released and the stream expires. | | `reservation_expires_in_seconds` | integer \| null | The same as a countdown, computed by the server. | | `created_at` | string | | | `went_live_at` | string \| null | | | `ended_at` | string \| null | | | `end_reason` | string \| null | Why it ended: `deleted`, `disconnected`, `duration_limit`, `reservation_ttl`, `limit_bitrate`, `limit_resolution`, `insufficient_credit`, `simulcast_not_supported`, `provision_failed`. | | `ingest` | Ingest | | | `cost` | Cost | | | `viewers` | Viewers | | | `viewers_now` | integer \| null | Watching on the hosted player right now (the same estimate as `viewers.now.watching`). Null unless the stream is pending or live and someone is on the player. Also in the list, so one call shows every live audience. | | `stream_key` | string | RTMP stream key, `{id}?key={secret}`. OBS: paste as Stream Key with `rtmp_url` as Server. One-shot: works for this stream only. | | `whip_url` | string | WHIP endpoint for browsers and WebRTC encoders. Contains the secret. | ### Ingest What the ingest node last measured arriving. Null until the first measurement. | Field | Type | Description | |---|---|---| | `width` | integer \| null | Frame width in pixels. | | `height` | integer \| null | Frame height in pixels. | | `kbps` | integer \| null | Bitrate of everything received (video, audio, overhead), averaged over about 30 s. | | `observed_at` | string | When this was measured. | | `limit_warning` | string \| null | Set while a limit is broken: `limit_bitrate` or `limit_resolution`. Still broken 20 s later, the stream ends with that `end_reason`. | | `keyframe_interval_seconds` | number \| null | Average seconds between the publisher’s keyframes. Null for browser (WHIP) publishers. | | `keyframe_warning` | boolean | True above 2.5 s. Set your encoder to a 2 s keyframe interval. A warning only; never ends a stream. | ### Recording The recording: one MP4 of the top quality, made after the stream ends. Null while pending. | Field | Type | Description | |---|---|---| | `status` | "recording" \| "processing" \| "ready" \| "deleting" \| "deleted" \| "failed" \| "not_recorded" \| "none" \| "legacy" | `recording` while live, then `processing`, `ready`, `deleting`, `deleted`, `failed`, `not_recorded` (record: false) or `none` (never went live). | | `bytes` | integer \| null | MP4 size. | | `duration_seconds` | number \| null | MP4 length. | | `retention_days` | integer \| null | Days kept after it is ready. Null: until you delete it. | | `ready_at` | string \| null | When the MP4 became downloadable. | | `delete_at` | string \| null | When it will be deleted: the retention date, or 7 days after the balance reached zero, whichever is earlier. | | `delete_reason` | string \| null | `retention` or `zero_balance`. | | `deleted_at` | string \| null | When it was deleted. | ### Cost What this stream has cost so far at current rates. Only on `GET /v1/streams/{id}`. Amounts are decimal strings. | Field | Type | Description | |---|---|---| | `total_usd` | string | Everything below. | | `delivery_bytes` | integer | Bytes delivered to viewers, counted up to `delivery_counted_until`. | | `delivery_usd` | string | Delivery cost. | | `delivery_counted_until` | string \| null | CDN logs are read an hour at a time, so delivery trails reality by up to about 80 minutes. | | `ingest_seconds` | integer | Seconds of publishing. | | `ingest_usd` | string | Encoding (abr_basic) or passthrough cost. | | `storage_gb_hours` | number | Recording storage used. | | `storage_usd` | string | Recording storage cost. | ### Viewers Viewer counts. While the stream runs, `now` is estimated from the hosted player: exact up to about 400 viewers, within about 5% above. After the fact, the CDN logs give the exact count for every player (hosted, your own, apps), about an hour later; `peak` and `watch_minutes` use them once they exist. Only on `GET /v1/streams/{id}`. | Field | Type | Description | |---|---|---| | `now` | object \| null | Live estimate from the hosted player. Null when nobody is on the player or the stream is over. | | `peak` | integer \| null | Most viewers in any one minute. | | `peak_at` | string \| null | The start of that minute. | | `watch_minutes` | integer \| null | Total minutes watched, all viewers together. | | `source` | "cdn" \| "player" \| null | Where `peak` and `watch_minutes` come from: `cdn` (every player, exact) or `player` (hosted player estimate, until the CDN count arrives). | | `cdn_counted_until` | string \| null | CDN logs are read up to this time. | ### ViewerSeries | Field | Type | Description | |---|---|---| | `object` | string | Always `viewer_series`. | | `stream_id` | string | Stream id. Also the folder of its video on the CDN. | | `player` | array of object | Per minute, from the hosted player (live estimate). | | `cdn` | array of object | Per minute, from the CDN logs: average viewers watching at once, every player included. | | `countries` | array of object | Where the watch time came from, from the CDN logs (every player), largest first, up to 30. Grows hour by hour while the stream runs. | ### PlayerSettings | Field | Type | Description | |---|---|---| | `show_name` | boolean | Show the stream name to viewers on the hosted player. Default true. False keeps the name out of the public embed read entirely. | ### StreamList | Field | Type | Description | |---|---|---| | `object` | string | Always `list`. | | `data` | array of Stream | | | `has_more` | boolean | More streams after the last one. Pass its id as `starting_after`. | ### PlaybackToken | Field | Type | Description | |---|---|---| | `object` | string | Always `playback_token`. | | `stream_id` | string | Stream id. Also the folder of its video on the CDN. | | `playback_url` | string | Signed HLS URL. | | `expires_at` | string \| null | | ### RecordingDownload | Field | Type | Description | |---|---|---| | `object` | string | Always `recording_download`. | | `stream_id` | string | Stream id. Also the folder of its video on the CDN. | | `url` | string | Signed link to the MP4 on the CDN. Downloading it counts as delivery. | | `bytes` | integer | MP4 size. | | `expires_at` | string | | ### Embed What the hosted player needs. Public: no key, any origin may read it. | Field | Type | Description | |---|---|---| | `object` | string | Always `embed`. | | `id` | string | Stream id. Also the folder of its video on the CDN. | | `status` | "pending" \| "live" \| "ended" \| "unavailable" | `pending`, `live`, `ended`, or `unavailable` (expired or cancelled). | | `title` | string \| null | The stream name when it is to be shown, otherwise null. | | `playback_url` | string \| null | While live: a signed HLS URL, the same for every viewer. | | `playback_url_expires_at` | string \| null | | | `went_live_at` | string \| null | | | `ended_at` | string \| null | | | `poll_after_seconds` | integer \| null | Ask again after this many seconds (5 pending, 60 live). Null: stop asking. | | `beacon` | object \| null | Viewer counting: a player whose random number is below `rate` reports every `interval_seconds` (see Count viewers from your own player). Null once the stream is over. | ### Usage | Field | Type | Description | |---|---|---| | `object` | string | Always `usage`. | | `balance_usd` | string | Credit minus everything charged so far. | | `available_usd` | string | Balance minus used-but-not-yet-charged. Live streams end when this reaches zero. | | `balance_depleted_at` | string \| null | Set while the balance is at or below zero. | | `recordings_delete_at` | string \| null | 7 days after the balance reached zero: when recordings will be deleted unless you top up. | | `rates` | object | | | `days` | array of object | | ### WebhookEndpoint | Field | Type | Description | |---|---|---| | `object` | string | Always `webhook_endpoint`. | | `id` | string | `whk_…` | | `url` | string | Where events are POSTed. https only. | | `description` | string \| null | Your own note. | | `events` | array of "stream.live" \| "stream.ended" \| "stream.expired" \| "stream.cancelled" \| "recording.ready" \| "recording.failed" \| "recording.deleted" | Event types sent to this endpoint. | | `all_events` | boolean | True when subscribed to everything, including event types added later. | | `enabled` | boolean | False: nothing is sent. | | `created_at` | string | | | `secret` | string | `whsec_…`, for verifying signatures. Only on create, `GET /v1/webhooks/{id}` and rotation. | ### WebhookDelivery | Field | Type | Description | |---|---|---| | `object` | string | Always `webhook_delivery`. | | `id` | string | `dlv_…` | | `event_id` | string | `evt_…`, the `webhook-id` header. | | `event_type` | string | | | `stream_id` | string \| null | | | `status` | "pending" \| "succeeded" \| "failed" | | | `attempts` | integer | | | `max_attempts` | integer | | | `next_attempt_at` | string \| null | | | `last_attempt_at` | string \| null | | | `last_status_code` | integer \| null | What your server answered. | | `last_error` | string \| null | Why the last attempt failed (`answered 500`, a timeout, a DNS error, …). | | `last_duration_ms` | integer \| null | | | `delivered_at` | string \| null | | | `created_at` | string | | | `payload` | WebhookEvent | | ### WebhookEvent The body of every webhook. Headers: `webhook-id`, `webhook-timestamp`, `webhook-signature` (Standard Webhooks). | Field | Type | Description | |---|---|---| | `type` | "stream.live" \| "stream.ended" \| "stream.expired" \| "stream.cancelled" \| "recording.ready" \| "recording.failed" \| "recording.deleted" \| "webhook.test" | | | `timestamp` | string | When it happened. | | `data` | object | For stream and recording events, the stream at that moment. For `webhook.test`, the endpoint. | ### Error | Field | Type | Description | |---|---|---| | `error` | object | | --- # Stream lifecycle > The five statuses a stream moves through, the timers that move it, and why a stream ends. A stream is one broadcast. Its key works once: when the stream is over, create a new one for the next broadcast. ## Statuses ```text create │ ┌───▼────┐ 30 min, nothing connected ┌─────────┐ │pending │──────────────────────────────▶│ expired │ └───┬────┘ └─────────┘ │ publisher accepted DELETE ┌───────────┐ │ └──────────────────────▶│ cancelled │ ┌───▼────┐ └───────────┘ │ live │ └───┬────┘ │ DELETE, publisher gone 60 s, max duration, │ a limit broken, or credit at zero ┌───▼────┐ │ ended │ └────────┘ ``` | Status | Meaning | Can go live? | |---|---|---| | `pending` | Created; capacity reserved; waiting for the publisher. | Yes, for 30 minutes | | `live` | A publisher is connected (or dropped less than 60 s ago). | It is | | `ended` | It was live and is over. The recording is being made. | No | | `expired` | Nothing connected within 30 minutes. | No | | `cancelled` | Deleted before it went live. | No | ## The timers | Timer | Length | Where to see it | |---|---|---| | Waiting for the publisher | 30 minutes from creation | `reservation_expires_at`, `reservation_expires_in_seconds` | | Reconnecting after a drop | 60 seconds, same key | `status` stays `live` meanwhile | | Maximum duration | 4 hours (new accounts), 24 hours (trusted) | `max_duration_seconds` | | Ingest limit warning | 20 seconds before the stream is ended | `ingest.limit_warning` | There is no way to reserve a stream for a future time. Create it when the publisher is about to start. ## Why a stream ended `end_reason` on an ended, expired or cancelled stream: | `end_reason` | What happened | What to do | |---|---|---| | `deleted` | You called `DELETE /v1/streams/{id}`. | — | | `disconnected` | The publisher left and didn't come back within 60 s. | Normal end for OBS "Stop Streaming". | | `duration_limit` | It reached `max_duration_seconds`. | Create a new stream to continue. | | `reservation_ttl` | Nothing connected within 30 minutes. | Create the stream closer to the start. | | `limit_bitrate` | Over 7.5 Mbps arrived for 20 seconds after the warning. | Set the encoder to at most 6000 Kbps. | | `limit_resolution` | Video larger than `max_resolution` for 20 seconds after the warning. | Scale the output down, or create the stream with a higher `max_resolution`. | | `insufficient_credit` | Your credit reached zero. | Top up, then create a new stream. | | `simulcast_not_supported` | A browser sent several video layers. | Publish one video track. | | `provision_failed` | No ingest server could be started. Rare. | Create a new stream. | ## What is arriving While live, `ingest` shows what the ingest server measures, updated every few seconds: ```json "ingest": { "width": 1280, "height": 720, "kbps": 2710, "observed_at": "2026-09-29T10:32:14.120Z", "limit_warning": null, "keyframe_interval_seconds": 2, "keyframe_warning": false } ``` `kbps` counts everything received, audio and overhead included, so a 2500 Kbps video setting reads a little higher. `keyframe_warning` is only advice: the stream keeps running. ## Following a stream Add a [webhook](/webhooks) and we tell your server when the stream goes live, ends and has its recording. Or poll `GET /v1/streams/{id}` every 3–5 seconds while you care, and stop when the status is `ended`, `expired` or `cancelled`. The player page does its own polling through the CDN, so viewers add no load to your API calls. The same read has `viewers`: how many are watching now (from the hosted player), and the peak and total watch time (exact, from the CDN logs, about an hour later). See [Hosted player](/player#viewer-counts). --- # Errors > Every error has a stable code. Match on the code, show the message to people. Errors come back with an HTTP status and this body: ```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 } } } ``` `code` never changes for a given condition, so program against it. `message` is written for people and may change. `details` appears when there are numbers worth having. ## Codes | Status | `code` | Meaning | What to do | |---|---|---|---| | 400 | `invalid_request` | A field is missing, unknown or out of range. The message names it. | Fix the request. | | 401 | `unauthorized` | Missing or wrong API key. | Send `Authorization: Bearer joa_live_…`. | | 402 | `insufficient_credit` | Less than $1.00 of credit left. `details` has the balance. | Add credit. | | 403 | `account_pending` | The account hasn't been approved yet. | Wait for the approval email. | | 403 | `tenant_suspended` | The account is suspended. | Contact JustOnAir. | | 404 | `not_found` | No such stream in this account. | Check the id and the key's account. | | 409 | `stream_not_pending` | The key can only be replaced before the stream goes live. `details.status` says the current status. | For an expired stream, create a new one. | | 409 | `stream_ended` | The stream has ended, so no new playback links. | There is no replay; use the recording. | | 409 | `recording_not_ready` | The MP4 isn't ready. `details.status` says why. | Poll the stream until `recording.status` is `ready`. | | 422 | `idempotency_key_reused` | This `Idempotency-Key` was already used with a different body. | Use a new key for a different request. | | 429 | `limit_pending_streams` | Too many streams waiting to go live. | Start or delete one first. | | 429 | `limit_creation_rate` | Too many streams created in the last hour. | Wait; reuse pending streams. | | 429 | `limit_ingest_hours` | Monthly ingest hours used up (new accounts: 20). | Wait for next month or ask to be trusted. | | 429 | `limit_resolution` | `max_resolution` above what the account may send (new accounts: 720). | Ask to be trusted for 1080p. | | 429 | `limit_api_keys` | 20 active API keys already. | Revoke one in the dashboard. | | 429 | `limit_webhook_endpoints` | 5 webhook endpoints already. | Delete one, or subscribe one endpoint to more events. | | 429 | `rate_limited` | Too many unknown ids on the public embed endpoint. | Slow down. | | 503 | `no_capacity` | No ingest capacity free right now. | Retry in a minute. | A 5xx without one of these codes is a problem on our side; retrying with the same `Idempotency-Key` is always safe. ## Refusals that aren't HTTP errors Some problems happen on the video connection, not on an API call. A publisher can be refused (wrong key, stream already finished, another publisher connected, account out of credit), or a live stream can be ended. The API call to read the stream then shows why: `status` and `end_reason` on [Stream lifecycle](/streams). --- # Limits > What each account can do, what a publisher may send, and the sizes of everything the API accepts. ## Account limits New accounts start with conservative limits. An account is made **trusted** by hand, on request, once we know what you broadcast. | Limit | New | Trusted | Error when reached | |---|---|---|---| | Streams live at once | 1 | 10 | publisher refused | | Streams waiting to go live at once | 2 | 20 | `limit_pending_streams` | | Streams created per hour | 10 | 100 | `limit_creation_rate` | | Ingest hours per month | 20 | No cap (credit is the limit) | `limit_ingest_hours` | | Highest `max_resolution` | 720 | 1080 | `limit_resolution` | | Longest stream | 4 hours | 24 hours | ends with `duration_limit` | | Active API keys | 20 | 20 | `limit_api_keys` | | Webhook endpoints | 5 | 5 | `limit_webhook_endpoints` | ## What a publisher may send | | Limit | |---|---| | Bitrate | 6 Mbps of video; the stream ends above 7.5 Mbps sustained, everything included, after a 20 s warning | | Resolution | Within the declared `max_resolution` in either orientation, with 12% slack | | Video tracks | One (simulcast is refused) | | Codec | H.264 | | Publishers per stream | One at a time | ## Sizes | Field | Limit | |---|---| | `name` | 100 characters | | `metadata` | 2 KB of JSON | | `Idempotency-Key` | 1–255 characters, remembered for 24 hours | | Playback link lifetime (`expires_in`) | 60 seconds to 7 days | | Recording download link | 60 seconds to 7 days, default 1 hour | | `recording_retention_days` | 1 to 3650, or `null` for no limit | | `GET /v1/streams` page | Up to 100 | | `GET /v1/usage` | Up to 90 days | | Webhook delivery log | Kept 30 days | ## Viewers There is no cap on viewers. Delivery is paid per GB from your credit, and the credit is the real limit: when it reaches zero, live streams end. The CDN has its own protection against abuse, and requests to a single URL are rate limited per viewer. --- # Pricing > Four rates, paid from prepaid credit. No monthly plan, no minimum spend. Worked examples and how billing works. | What | Rate | Per hour | |---|---|---| | Encoding, `abr_basic` (several qualities for viewers) | $0.003 per minute | $0.18 per stream-hour | | Passthrough (exactly what you send) | $0.0005 per minute | $0.03 per stream-hour | | Delivery to viewers | $0.03 per GB | about $0.034 per viewer-hour at 720p, $0.014 at 480p | | Recording storage | $0.03 per GB-month | about $0.034 per month for one hour of 720p | Encoding and passthrough are counted per second of publishing. Delivery is what viewers actually download: a viewer on a weak connection who drops to 480p costs less. Downloading a recording counts as delivery. Nothing is charged for a stream that never goes live, for API calls, or for the hosted player. `GET /v1/usage` returns these rates, your balance and your usage per day, so an agent or your backend can work out costs itself. ## Worked examples Viewer-hours at 720p assume about 2.5 Mbps, or 1.125 GB per hour. | Event | Calculation | Cost | |---|---|---| | A 1-hour yoga class at 720p, 30 viewers, recorded and kept 30 days | $0.18 encoding + 30 × $0.034 delivery + $0.034 storage | **$1.23** | | A 1-hour 720p event with 100 viewers | $0.18 + 100 × $0.034 | **$3.56** | | A 2-hour town hall, 500 viewers at 720p | 2 × $0.18 + 1,000 × $0.034 | **$34.11** | | An agent's 5-minute test, passthrough, 1 viewer | 5 × $0.0005 + 0.094 GB × $0.03 | **$0.005** | ## How billing works - **Prepaid credit.** Creating a stream needs at least $1.00 of credit. During early access, credit is added by the JustOnAir team when your account is approved and on request. - **Counted continuously, charged daily.** Delivery is read from the CDN's logs an hour at a time; each UTC day is charged once, about two hours after it ends. `cost` on a stream and `/v1/usage` show everything used so far, charged or not. - **At zero, streams stop.** When credit left (balance minus usage not yet charged) reaches zero, live streams end with `end_reason: "insufficient_credit"`. After 7 days at zero, recordings are deleted. ## Why it stays simple Many live video APIs add a monthly plan, a minimum spend, or charge several times more per encoded hour. JustOnAir has the four rates above and nothing else: no plan, no minimum, no charge for a stream that never goes live, and nothing extra for the hosted player or signed URLs.