# PopVid Realtime API (v1) > Build live, talking video characters into your product: your server creates a session over REST, your client drives it over a WebSocket control channel and receives audio + video over a receive-only WebRTC connection. This file is the complete public contract for **Realtime API v1**. It is written for coding assistants: every request field, response field, event, and error code an integration needs is below. Everything not stated here is not part of the contract. - Document revision: **1.9** (2026-10-12) — see "Versioning" for what changed since 1.8 - API version: `v1` — carried by the REST base path and the WebSocket subprotocol `r2.v1`; the two always move together - Model: `r2-realtime-v1` (the only accepted value in v1) - REST base URL: `https://popvid.ai/api/public/v1` - Realtime (WebSocket) endpoint: **always** the `credentials.control_url` value returned by `POST /connections`, used verbatim — never a hardcoded host ## Architecture Three layers. Which machine each runs on is part of the security model, not a suggestion. | Layer | Protocol | Runs on | Purpose | |---|---|---|---| | Configuration | HTTPS REST | **Your server** | Create a session and mint its short-lived client credential in one call; close sessions | | Control | WebSocket | Your client | Start the session, submit turns, receive events, track usage | | Media | WebRTC | Your client | Receive audio + video. **Receive-only** — the client publishes no track | Lifecycle: 1. Your server: `POST /connections` with your long-lived API key → `{ session, credentials }`. 2. Your server sends **only** `credentials` to the client. 3. Client: open the WebSocket at `credentials.control_url`. 4. Client: `session.start` → server: `session.ready`. 5. Client: `media.offer` (SDP) → server: `media.answer` (SDP) → audio + video start flowing. 6. Loop: client `turn.submit` → server `turn.text`, `turn.prompt_ready`, `turn.started`, `media.clip`(×n), `turn.visible`. 7. Client `session.close` (or server-side `DELETE`) → server `session.ended`. ## Authentication ### API keys (server only) Long-lived keys are prefixed `pk_live_` and authenticate every REST call: ``` Authorization: Bearer pk_live_... ``` **Never embed an API key in a browser, mobile app, or any client you ship.** A key can create sessions and incur charges. Keep it on your server and hand clients only the `credentials` object returned by `POST /connections`. Keys are shown once at creation and stored only as a hash; if a key is lost, rotate it. ### Control tokens (the only client credential) `POST /connections` returns a JWT bound to a single `session_id`, carrying that session's budget and an expiry. If it leaks, the blast radius is one session and one budget. - Default lifetime: 10 minutes. Request up to 60 minutes with `credentials_ttl_ms`. - **Set the TTL to at least `limits.max_duration_ms`.** The token is checked when a socket connects, so a token that outlives the session covers every reconnect inside it, including resume-after-drop. ## REST API Base URL: `https://popvid.ai/api/public/v1` ### POST /connections — create a session `POST https://popvid.ai/api/public/v1/connections` One call defines the session, reserves capacity, and mints the client credential. It **does not start the stream** — that happens when your client sends `session.start`. Request body: ```json { "model": "r2-realtime-v1", "character": { "name": "Aria", "prompt": "A calm archivist.", "voice_ref_url": "https://cdn.example.com/aria-voice.mp3" }, "scene": { "prompt": "A dim library at night." }, "seed_image_url": "https://cdn.example.com/aria.jpg", "language": "en", "history": [{ "role": "user", "content": "..." }, { "role": "character", "content": "..." }], "limits": { "max_turns": 200, "turn_rate_per_min": 20 }, "credentials_ttl_ms": 600000, "metadata": { "your_key": "your_value" } } ``` | Field | Required | Notes | |---|---|---| | `model` | Yes | `r2-realtime-v1` is the only value in v1 | | `character.name`, `character.prompt` | Yes | `prompt` carries the character's persona and background. No hard length limit | | `character.voice_ref_url` | No | Absolute `http(s)` URL of a voice sample the character's speech is modelled on. **Only the first 10 seconds are used.** Up to 2048 characters. Omit for the default voice. **If the URL cannot be fetched, the session fails to start** — host it somewhere publicly reachable and stable. The sample is what the voice is cloned from, so its quality caps the result: supply **two-channel (stereo)** audio, keep noise to a minimum, and make sure those 10 seconds are dominated by the one voice you want — background music, a second speaker, or room noise all pull the match away from it | | `scene.prompt` | No | Setting and situation. No hard length limit | | `seed_image_url` | No | Publicly reachable HTTPS image used as the first frame. Resized — never cropped or padded — to the session's `media.video` dimensions (576×768, 3:4 portrait), so supply that ratio. Any other ratio is stretched to fit, and because the first frame also anchors the character's appearance, that distortion carries through the whole session | | `language` | No | BCP-47, defaults to `en` | | `history` | No | Up to 40 prior messages; `role` is `user` or `character` | | `limits` | No | See "Limits and capacity". **`max_duration_ms` is not applied under the current billing model** — see "Billing" | | `credentials_ttl_ms` | No | Lifetime of the returned `control_token`. Default 10 min, cap 60 min. Set to at least the longest a session can run — currently 5 minutes | | `metadata` | No | Up to 16 string values, echoed back on the session and in settlement | Response `201`: ```json { "session": { "session_id": "sess_8fJ2kL9mQ4nR7tVw3xYz1a", "status": "created", "model": "r2-realtime-v1", "created_at_ms": 1755750000000, "reservation_expires_at_ms": 1755750060000, "limits": { "max_duration_ms": 5000, "max_turns": 200, "turn_rate_per_min": 20 }, "media": { "video": { "width": 576, "height": 768, "fps": 24, "codec": "h264" }, "audio": { "codec": "opus", "sample_rate": 48000, "channels": 1 } }, "metadata": { "your_key": "your_value" } }, "credentials": { "session_id": "sess_8fJ2kL9mQ4nR7tVw3xYz1a", "control_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "control_url": "wss:///public/v1/realtime", "expires_at_ms": 1755750600000, "ice_servers": [{ "urls": ["stun:stun.l.google.com:19302"] }], "budget": { "max_duration_ms": 5000, "max_turns": 200, "remaining_duration_ms": 5000, "remaining_turns": 200 } } } ``` Rules that break integrations when ignored: - **`credentials.control_url` must be used verbatim.** It is a complete URL. In the current production environment the issued value looks like `wss:///public/v1/realtime`, but it varies by environment and may change without notice. Hardcoding a WSS host is the single most common reason a client that works in test fails in production. - **`reservation_expires_at_ms` is a hard deadline.** Connect and send `session.start` before it, or the session is released with `reason: reservation_expired`. Capacity is exclusive, so an unused session would otherwise hold it indefinitely. - **Pass `credentials.ice_servers` straight to `new RTCPeerConnection({ iceServers })`, and never reuse it across sessions.** It is a list; its contents may change, so do not assume a fixed shape or length. It **may** carry TURN relays next to STUN (availability depends on deployment — do not branch on one being there), and **relay credentials expire and rotate without notice** — they are short-lived (hours, not permanent), so a list cached across sessions will eventually fail to allocate. Always use the `ice_servers` from the session you just created. Relay is a fallback that only kicks in when the direct path fails, so clients that connect today are unaffected. - Send **only** the `credentials` object to your client. Keep `session` on your server — `session.session_id` is what you pass to the close endpoint. - **`budget.max_duration_ms` comes back as `5000`, and that is correct.** The session opens with a 5-second budget and extends itself in 5-second blocks while it runs, up to 300 000 ms. Do not treat the small number as an error, and do not retry the call to get a bigger one. See "Billing". Common failures: `409 no_capacity`, `422 content_rejected` (no session created, no capacity held), `409 budget_exhausted` (API Energy balance cannot cover the first block). ### DELETE /sessions/{session_id} — close a session `DELETE https://popvid.ai/api/public/v1/sessions/{session_id}` Ends the session, releases capacity, and returns final usage. **Idempotent** — repeat calls return the same settlement. ```json { "session_id": "sess_8fJ2kL9mQ4nR7tVw3xYz1a", "status": "ended", "reason": "server_closed", "started_at_ms": 1755750010000, "ended_at_ms": 1755750310000, "duration_ms": 300000, "billed_ms": 300000, "turns_used": 42, "metadata": { "your_key": "your_value" } } ``` A connected client receives `session.ended` before the socket closes. ## Realtime control (WebSocket) ### Connecting ``` {credentials.control_url}?session_id=sess_...&resume_from=evt_... ``` `resume_from` is optional and only used when reconnecting. Credentials travel in the WebSocket **subprotocol** — browsers cannot set custom headers on a WebSocket: ```js new WebSocket(url, ["r2.v1", `r2.token.${control_token}`]); ``` Non-browser clients may instead send `Authorization: Bearer `. **Do not put the control token in the query string.** It would be recorded in proxy logs, browser history, and `Referer` headers. Handshake close codes: | Code | Meaning | |---|---| | `4401` | Token missing, malformed, or expired | | `4403` | Token does not match `session_id` | | `4404` | Session not found | | `4409` | Session already has an active connection | | `4410` | Session has ended | Normal termination after `session.close` uses code `1000`. ### Heartbeats The server sends a protocol-level `ping` every 5 seconds; browsers answer automatically, so there is nothing to implement. Three missed responses (15 seconds) end the session with `idle_timeout`. **There is no application-level heartbeat message** — do not send extra frames to keep the connection alive. ### Message envelope Every message shares one shape. Client → server: ```json { "type": "turn.submit", "id": "c-17", "data": { "turn_id": "turn_...", "text": "Hello" } } ``` Server → client: ```json { "type": "turn.visible", "id": "evt_0K3xQ9mN2pL7vR4tYb1cZa", "ts_ms": 1755750000123, "session_id": "sess_8fJ2kL9mQ4nR7tVw3xYz1a", "data": { "turn_id": "turn_...", "latency_ms": 4210 }, "debug": null } ``` - `id` on a server message increases monotonically within a session and is your **resume cursor**. - `id` on a client message is your own correlation string; you choose the format. - `debug` is diagnostic output, is `null` in normal operation, and is **not part of the API contract**. Treat it as absent. ### Client events (client → server) Five message types. There are no others in v1. #### `session.start` Starts the stream. Succeeds once per session. Responds with `session.ready`. ```json { "type": "session.start", "id": "c-1", "data": { "seed_image_url": "..." } } ``` `data.seed_image_url` is an optional override of what you set at creation, and carries the same 3:4 requirement. Everything else — character, scene, history, budget — is fixed at creation time. #### `media.offer` Requires `session.ready` first. Responds with `media.answer`. ```json { "type": "media.offer", "id": "c-2", "data": { "sdp": "v=0\r\no=- ..." } } ``` #### `media.connected` Send once, as soon as the `RTCPeerConnection` reaches `connected`. Requires `session.ready` first. `data` is empty — the server timestamps it on arrival. There is no reply event. ```json { "type": "media.connected", "id": "c-3", "data": {} } ``` **Billing starts at this moment, not at `session.ready`.** If you never send it, the renderer's own connection signal is used as a fallback, and that signal fires on average **about 2.4 seconds earlier** — you pay for the gap. Sending it is both cheaper and the only way the billing clock matches what the user actually sees. #### `turn.submit` ```json { "type": "turn.submit", "id": "c-17", "data": { "turn_id": "turn_9dK2mP...", "text": "You look different today.", "client_sent_at_ms": 1755750123456 } } ``` | Field | Required | Notes | |---|---|---| | `turn_id` | Yes | **You generate it** (UUIDv4 or ULID, `turn_` prefix recommended). Every `turn.*` event refers back to it. A repeated `turn_id` within a session is discarded idempotently, which makes retry-after-reconnect safe | | `text` | Unless `gesture_data` | 1–2000 characters | | `gesture_data` | No | Makes this a gesture turn instead of a text turn. Mutually exclusive with `text`. See "Gesture turns" below | | `client_sent_at_ms` | No | For your own latency attribution | #### Gesture turns A turn can also be a gesture the user makes on the video — a tap, a drag, a pinch or a spread. Send `gesture_data` in place of `text`: we look at the frame the user touched, work out what they touched and what they did to it, and the character reacts on camera. Capture the frame that is on screen when the gesture starts and send it inline. ```json { "type": "turn.submit", "id": "c-18", "data": { "turn_id": "turn_4e7Qx...", "gesture_data": { "id": "g_1760268973000", "createdAt": 1760268973000, "video": { "width": 480, "height": 640, "currentTime": 16.86, "frameImage": "data:image/jpeg;base64,/9j/4AAQSkZJRg..." }, "gesture": { "type": "DRAG", "startPoint": { "x": 331, "y": 427 }, "endPoint": { "x": 287, "y": 202 }, "displacement": { "dx": -44, "dy": -225 }, "distance": 229.3, "duration": 956, "radius": 72, "trail": [ { "x": 331, "y": 427, "t": 0 }, { "x": 287, "y": 202, "t": 933 } ] } } } } ``` | Field | Required | Notes | |---|---|---| | `id` | Yes | Your identifier for the gesture, for your own tracing | | `createdAt` | Yes | When the gesture happened, Unix epoch milliseconds | | `video.width`, `video.height` | Yes | Pixel size of `frameImage`. Every coordinate in `gesture` is in this space | | `video.frameImage` | Yes | The frame on screen when the gesture started, as a base64 data URL (`data:image/jpeg;base64,…`). JPEG at about 0.7 quality, at the video's own resolution, is enough | | `video.currentTime` | No | Playback position of the captured frame, in seconds. For tracing only | | `gesture` | Yes | One of the four shapes below, chosen by `type` | | `type` | User action | Required fields | |---|---|---| | `TAP` | One finger taps a point | `position`, `radius`, `duration` | | `DRAG` | One finger moves across the picture; the direction comes from `displacement` | `startPoint`, `endPoint`, `displacement` `{dx, dy}`, `distance`, `duration`, `radius`, `trail` `[{x, y, t}]` | | `PINCH` | Two fingers move together (`scale` < 1) | `center`, `startRadius`, `endRadius`, `scale`, `duration`, `pinchTrail` | | `SPREAD` | Two fingers move apart (`scale` > 1) | Same as `PINCH` | A `pinchTrail` point is `{ finger1: {x, y}, finger2: {x, y}, center: {x, y}, radius, t }`; send at least two (the first and last frames of the gesture set its direction). `duration` and every `t` are milliseconds, `t` counted from the start of the gesture. `radius` is the touch area around the point, in the same pixels as the coordinates — 72 on a 480-pixel-wide frame works well. **Coordinates are pixels of `frameImage`, with the origin at the bottom-left and y pointing up.** Convert from screen coordinates yourself: subtract any letterboxing that `object-fit` adds around the video, scale to `video.width` × `video.height`, then flip y. A positive `displacement.dy` means the finger moved up. Touches that start outside the picture should not be sent. - A gesture turn returns the same events as a text turn, and counts toward `max_turns` and `turn_rate_per_min` in the same way. `turn.text` carries only the character's line. - Expect `turn.text` about one second later than for a text turn: the frame has to be looked at first. - A malformed gesture is answered with `error` / `invalid_request`, and `message` names the field, e.g. `data.gesture_data.gesture.pinchTrail needs at least 2 points for PINCH`. Other gesture types (long press, double tap, region select) are rejected the same way. - The whole message must stay under **512 KiB**; a larger one closes the WebSocket with code `1009`. A 480×640 JPEG frame at 0.7 quality is typically 40–130 KB. **Turns are newest-wins.** A new `turn.submit` interrupts any turn still generating — there is no separate cancel call. The interrupted turn's remaining events will never arrive. v1 does not queue turns and has no cancel-without-replace primitive. #### `session.close` ```json { "type": "session.close", "id": "c-99", "data": { "reason": "client_closed" } } ``` Responds with `session.ended`, then closes with code `1000`. ### Server events (server → client) | Event | When | Frequency | |---|---|---| | `session.ready` | Stream is up; you may negotiate media and send turns | Once per session, always first | | `media.answer` | SDP answer is ready | Once per `media.offer` | | `turn.text` | The character's **written reply** is ready | 0–1 per turn | | `turn.prompt_ready` | The turn has been accepted for generation | 0–1 per turn | | `turn.started` | The character has stopped idling and begun this response | 0–1 per turn | | `media.clip` | A video segment's first frame is on screen | Many per session | | `turn.visible` | This turn's first frame is on screen | 0–1 per turn | | `usage.tick` | Billing heartbeat, cumulative. The clock starts when media connects, not at `session.ready` — a session that never connects media is never billed, and `expires_at_ms` is absent until then | Every 5 seconds | | `session.renewed` | The duration budget has just been extended. `budget_ms` is the new total, not the increment | Once per extension | | `session.ended` | Session is over | Once, always last | | `error` | Something failed | Any time | Payloads (the `data` object of each event): ``` session.ready { "expires_at_ms": …, "media": {…}, "limits": {…} } media.answer { "sdp": "v=0\r\n…" } turn.text { "turn_id": "turn_…", "text": "I changed my coat." } turn.prompt_ready { "turn_id": "turn_…" } turn.started { "turn_id": "turn_…", "est_ms": 3200 } media.clip { "seq": 42, "kind": "turn", "turn_id": "turn_…" } turn.visible { "turn_id": "turn_…", "latency_ms": 4210 } usage.tick { "billed_ms": 12000, "turns_used": 3, "budget_ms": 15000, "budget_remaining_ms": 3000, "turns_remaining": 197, "expires_at_ms": 1755750600000 } session.renewed { "charged": true, "extended_ms": 5000, "segments": 4, "budget_ms": 20000, "budget_remaining_ms": 8000, "expires_at_ms": 1755750605000 } session.ended { "reason": "client_closed", "duration_ms": 300000, "billed_ms": 300000, "turns_used": 42 } error { …the error object, see "Errors"… } ``` Notes: - `media.clip.kind` is `idle` or `turn`. `turn_id` is `null` unless `kind` is `turn`. A common use is showing a waiting affordance while `kind` is `idle`, so users can tell the character is waiting rather than replying. - `turn.started.est_ms` is an estimate for progress indication, not a commitment. - `turn.visible.latency_ms` measures from receipt of `turn.submit` to first frame sent; it excludes your client's jitter buffer and decode time, so it is a lower bound. - **`usage.tick` values are cumulative, not deltas.** Losing one changes nothing — the next carries the correct total. When `budget_remaining_ms` reaches zero the session is ended for you. Budgets are hard ceilings, not warnings. `session.ended.reason` is one of: ``` client_closed · server_closed · budget_exhausted · idle_timeout reservation_expired · superseded · media_failed · internal_error viewer_gone ``` - `media_failed` also covers "media was never connected": if the WebRTC connection is not established within 30 seconds of `session.ready`, the session ends and **nothing is billed**. - `viewer_gone` means the renderer saw your WebRTC connection drop and released the session immediately instead of waiting for the heartbeat to time out. Same outcome as `idle_timeout`, reached sooner. Renegotiating media does not trigger it. ### Delivery guarantees Guaranteed: - `id` increases monotonically within a session; events arrive in that order. - Within a single turn: `turn.text` → `turn.prompt_ready` → `turn.started` → `media.clip`(×n) → `turn.visible`. - `session.ready` is always first; `session.ended` is always last. Not guaranteed: - Turns do not interleave predictably. When a new turn interrupts an older one, the older turn's remaining events never arrive. - Apart from `session.ready` and `session.ended`, **every event is best-effort**. **Do not build a UI state machine that requires all five turn events to advance.** A turn may produce only `turn.text` and nothing else. Key your UI on `turn_id` and apply your own timeout. ### Reconnecting and resume Events are retained for **5 minutes**, but the session itself is held for only **10 seconds** after the socket drops. Reconnect within that window or the session ends with `idle_timeout` and the renderer is released to someone else — retry promptly rather than backing off. Reconnect with the last `id` you received: ``` {credentials.control_url}?session_id=sess_…&resume_from=evt_… ``` Everything after that cursor is replayed, then live delivery resumes. Replayed events are byte-identical to the originals, including `id` and `ts_ms` — **deduplicate by `id`**. This is the only situation that produces duplicates. If the cursor has aged out you receive an `error` with code `resume_window_expired` (`fatal: false`) and only live events afterwards; rebuild your UI state. Because `turn_id` is idempotent within a session, re-submitting a turn after a reconnect is safe. ## Media (WebRTC) Media is delivered to your client over SRTP. Signaling travels on the WebSocket. ### Negotiating ```js const pc = new RTCPeerConnection({ iceServers: credentials.ice_servers }); pc.addTransceiver("video", { direction: "recvonly" }); pc.addTransceiver("audio", { direction: "recvonly" }); await pc.setLocalDescription(await pc.createOffer()); await iceGatheringComplete(pc); // required send("media.offer", { sdp: pc.localDescription.sdp }); ``` **Trickle ICE is not supported in v1.** Wait for ICE gathering to complete before sending the offer; the answer likewise contains a complete candidate set. Apply a timeout (5 seconds is reasonable) so a network that never reports completion does not block you forever. **The connection is receive-only. Uplink audio is not supported.** Declare both m-lines as `recvonly`; an offer carrying a `sendonly` or `sendrecv` track is not accepted. Speech input (STT / VAD) is not part of v1 — turns are submitted as text or gestures through `turn.submit`. ### Tracks | Track | Codec | Parameters | |---|---|---| | video | H.264 baseline | 576×768 @ 24 fps, roughly 1.5–2 Mbps | | audio | Opus | 48 kHz mono | Audio is generated together with the video, so picture and sound are inherently in sync — no client-side alignment is needed. ### Disconnecting and recovering | Goal | How | |---|---| | Drop media, keep the session | `pc.close()`. The session stays alive **and billing continues**; send a new `media.offer` to resume | | Recover after a network change or ICE failure | Create a **new** `RTCPeerConnection` and send a fresh `media.offer`. This is the only recovery primitive in v1; expect a 1–2 second gap | | End the session | `session.close` or the `DELETE` endpoint. **Do not just call `pc.close()` and walk away** — the session remains billable until it times out | ## Errors One error object, shared by REST and WebSocket. REST returns it under `error`; the WebSocket delivers it as the `data` of an `error` event. ```json { "error": { "code": "no_capacity", "message": "All realtime capacity is in use.", "status": 409, "retryable": true, "retry_after_ms": 5000, "fatal": false, "turn_id": null, "request_id": "req_4nQ8xR2mK7pL9vT3wYz1cB" } } ``` **Branch on `code`, never on `message`** — messages are for humans and may change. `fatal: true` (WebSocket only) means the session is over and `session.ended` follows. Include `request_id` in support requests. | Code | Status | Retryable | Meaning | |---|---|---|---| | `unauthorized` | 401 | No | Key or token invalid or expired | | `forbidden` | 403 | No | Credential does not match the target session | | `invalid_request` | 400 | No | Missing, malformed, or oversized field — `message` names it | | `invalid_state` | 409 | No | Command does not apply in the session's current state | | `session_not_found` | 404 | No | No such session | | `session_expired` | 410 | No | Session has ended; create a new one | | `no_capacity` | 409 | Yes | No capacity available. Back off using `retry_after_ms` | | `content_rejected` | 422 | No | Content screening failed. At session creation nothing is created; on a turn only that turn is dropped and `turn_id` identifies it | | `rate_limited` | 429 | Yes | Turn rate exceeded. Honor `retry_after_ms` | | `quota_exceeded` | 429 | No | Daily or concurrency quota exhausted. **Reserved — not currently returned**; see "Limits and capacity" | | `budget_exhausted` | 409 | No | Session budget spent. `fatal: true` | | `resume_window_expired` | 410 | No | Resume cursor older than 5 minutes. `fatal: false` | | `unsupported` | 400 | No | Capability not available in v1 | | `upstream_unavailable` | 502 | Yes | Transient backend failure | | `internal_error` | 500 | Yes | Unexpected failure. Report with `request_id` | ## Limits and capacity Per session: | Limit | Default | Maximum | On exceed | |---|---|---|---| | `max_duration_ms` | 5 000, extended automatically | 300 000 (5 min) | `session.ended` / `budget_exhausted` | | `max_turns` | 200 | 1 000 | Same | | `turn_rate_per_min` | 20 | 60 | `error` / `rate_limited`; the turn is dropped | | Reservation window | 60 s | — | `session.ended` / `reservation_expired` | | Event retention | 300 s | — | `error` / `resume_window_expired` | | Client message size | — | 512 KiB | WebSocket closed with code `1009` | | Conversation context | 100 most recent messages | — | Older messages fall out of the character's memory | Per account: `concurrent_sessions`, `sessions_per_day`, and `turns_per_day` are recorded on your key but are **not currently enforced** — no request is rejected today for exceeding them, and `429 quota_exceeded` is not returned. What actually binds you is renderer concurrency (below) and your API Energy balance. If you need guaranteed per-account quotas, contact us. **Concurrent session capacity is limited.** When none is free, `POST /connections` returns `409 no_capacity` with `retry_after_ms`, and neither a session nor a credential is issued. **Your integration must implement backoff and retry on `no_capacity`.** Size your usage by concurrent sessions, not by request rate. Talk to us before launching a workload that needs sustained concurrency. ## Billing One billing model is offered through this API: **API Energy**, a prepaid balance on your account. It is a separate ledger — consumer credits and subscriptions do not cross over to it. - A session opens with a **5 000 ms** duration budget and buys another **5-second block** as it runs, up to **300 000 ms (5 minutes)**. You never request an extension; it is automatic. - Each extension arrives as a `session.renewed` event. `budget_ms` is the new total, not the increment. - The clock starts when **media connects** — send `media.connected`, or the renderer's own signal is used and starts the clock about 2.4 seconds earlier. A session that never connects media is never billed and settles at `duration_ms: 0`. - When your balance cannot cover the next block, the session ends with `budget_exhausted`: a `fatal: true` error first, then `session.ended`. - At creation, an insufficient balance returns `409 budget_exhausted` and no session is created. - `limits.max_duration_ms` in your request is **not applied** under this model. - Rates and top-up: https://popvid.ai/openapi/pricing If your integration needs longer sessions or a different billing arrangement, contact us — other arrangements exist but are not offered through this API. ## Best practices | Do this | Why | |---|---| | Keep the API key server-side; mint one credential bundle per client session | A leaked control token costs one session; a leaked API key costs your account | | Generate `turn_id` client-side and reuse it when retrying | Retries become idempotent instead of duplicating a turn | | Track the last event `id` and pass it as `resume_from` | A brief network drop costs nothing; without it you lose events silently | | Deduplicate events by `id` | Resume replays are byte-identical to the originals | | Apply your own per-turn timeout | Events are best-effort; a turn may end without `turn.visible` | | Handle `connectionState === "failed"` | Some networks block the UDP that WebRTC needs | | Always end sessions explicitly | An abandoned session bills until it times out | | Show `usage.tick` budget to users on long sessions | Sessions end abruptly at budget exhaustion otherwise | | Back off on `no_capacity` and `rate_limited` | Both are transient and carry `retry_after_ms` | | Ignore unknown fields and unknown enum values | New ones are added without a version bump | ## Not available in v1 | Capability | Status | |---|---| | Speech input (STT / VAD) | Not supported. The WebRTC connection is receive-only — the client publishes no audio track, and turns are text or gestures | | Changing character or scene mid-session | Create a new session | | Cancelling a turn without replacing it | Interruption is newest-wins only | | Trickle ICE and ICE restart | Recovery is a full re-offer | | Session status polling | Use `usage.tick` and the `DELETE` settlement response | | Webhooks | Session completion is not pushed to your server | ## Versioning The REST path (`/public/v1`) and the WebSocket subprotocol (`r2.v1`) carry the version together — they always move as a pair. Backward-compatible, **no version bump; your client must tolerate these**: new fields on existing responses and events, new enum values, new event types, relaxed limits. Breaking, **new version with the previous one supported for at least six months**: removing or renaming a field, changing a field's meaning or type, removing an event type, tightening validation. Pin nothing beyond the version string, and treat `debug` as absent — it is diagnostic output and changes without notice. ### Changed in revision 1.9 The API version is unchanged (`/public/v1`, `r2.v1`). This revision is additive and backward-compatible: a client written against 1.8 keeps working with no edits. - **Added `turn.submit.gesture_data`.** A turn can be a tap, drag, pinch or spread on the video, sent with the frame it touched. See "Gesture turns". - **`turn.submit.text` is required unless `gesture_data` is sent**; the two are mutually exclusive. Text-only clients are unaffected. - **Client message size is now documented: 512 KiB.** A larger message closes the WebSocket with `1009`. ### Changed in revision 1.8 The API version is unchanged (`/public/v1`, `r2.v1`) and this revision is backward-compatible in both shape and behaviour. A client written against 1.7 keeps working with no edits. - **`credentials.ice_servers` may now include TURN relays.** It was STUN-only before. The field was always a list whose contents could change, so nothing about the response shape is different — but two runtime properties are new, and both matter if you cached that list: - **Relay credentials expire and rotate.** They are short-lived (hours, not permanent). A list reused across sessions will eventually fail to allocate. Always use the `ice_servers` from the session you just created. - **Relay is a fallback, not a route change.** It is only used when the direct path fails, so a client that connects today behaves exactly as before. - **Relays are not guaranteed to be present.** Availability depends on deployment; the list may still come back STUN-only. Do not branch on a relay being there. - No new events, fields, error codes, or limits. ### Changed in revision 1.7 The API version is unchanged (`/public/v1`, `r2.v1`) and everything below is backward-compatible. A client written against 1.6 keeps working, but the first two items change what it observes at runtime. - **Budget model.** Sessions start at a `5000` ms budget and extend themselves in 5-second blocks up to `300000` ms. The `limits.max_duration_ms` you send is not applied. Previously documented as 600 000 default / 3 600 000 max. - **Per-account quotas are not enforced.** `concurrent_sessions`, `sessions_per_day`, `turns_per_day` are recorded but never rejected, and `429 quota_exceeded` is not returned. Previously documented as enforced. - **Added:** `media.connected` (client event), `session.renewed` (server event), `viewer_gone` (`session.ended.reason`), `character.voice_ref_url` (request field), `expires_at_ms` (on `usage.tick`). - **Heartbeat** is every 5 s, `idle_timeout` after 3 missed (15 s). Was 10 s / 30 s. - **After a socket drop the session is held for only 10 s**, then ends with `idle_timeout`. - **Billing starts when media connects**, not at `session.ready`; a session that never connects media is never billed. - **`no_capacity` now suggests `retry_after_ms: 5000`.** Was 30 000. ## Minimal working example ### 1. Your server (one POST) ```js // Base URL from configuration; production value shown. const BASE = process.env.POPVID_BASE_URL || "https://popvid.ai/api/public/v1"; const AUTH = { Authorization: `Bearer ${process.env.POPVID_API_KEY}`, "Content-Type": "application/json" }; // One call: defines the session, reserves capacity, and mints the credential // for one client. It does not start the stream — `session.start` does. const { session, credentials } = await fetch(`${BASE}/connections`, { method: "POST", headers: AUTH, body: JSON.stringify({ model: "r2-realtime-v1", character: { name: "Aria", prompt: "A calm archivist who speaks in short sentences." }, scene: { prompt: "A dim library at night, rain on the windows." }, credentials_ttl_ms: 600000 }) }).then((r) => r.json()); // Send `credentials` to your client. Never send the API key. // Keep `session.session_id` — it is what you DELETE later. ``` Closing from the server: ```js await fetch(`${BASE}/sessions/${session.session_id}`, { method: "DELETE", headers: AUTH }); ``` ### 2. Your client (one WebSocket, one peer connection) ```js // Use control_url exactly as returned — never a hardcoded WSS host. const ws = new WebSocket( `${credentials.control_url}?session_id=${credentials.session_id}`, ["r2.v1", `r2.token.${credentials.control_token}`] ); const pc = new RTCPeerConnection({ iceServers: credentials.ice_servers }); pc.ontrack = (e) => { videoElement.srcObject = e.streams[0]; }; // Billing starts here, not at session.ready — report it as soon as media is up. pc.onconnectionstatechange = () => { if (pc.connectionState === "connected") send("media.connected", {}); }; let lastEventId = null; // resume cursor const seen = new Set(); // dedupe on replay ws.onopen = () => send("session.start", {}); ws.onmessage = async (raw) => { const msg = JSON.parse(raw.data); if (seen.has(msg.id)) return; // replayed event seen.add(msg.id); lastEventId = msg.id; // keep for reconnects switch (msg.type) { case "session.ready": pc.addTransceiver("video", { direction: "recvonly" }); pc.addTransceiver("audio", { direction: "recvonly" }); await pc.setLocalDescription(await pc.createOffer()); await iceGatheringComplete(pc); // no trickle ICE in v1 send("media.offer", { sdp: pc.localDescription.sdp }); break; case "media.answer": await pc.setRemoteDescription({ type: "answer", sdp: msg.data.sdp }); break; case "turn.text": showCharacterLine(msg.data.turn_id, msg.data.text); break; case "turn.visible": hideThinkingIndicator(msg.data.turn_id); break; case "usage.tick": updateBudget(msg.data.budget_remaining_ms); break; case "session.renewed": updateBudget(msg.data.budget_remaining_ms); break; case "session.ended": teardown(msg.data.reason); break; case "error": handleError(msg.data); break; } }; function send(type, data) { ws.send(JSON.stringify({ type, id: `c-${Date.now()}`, data })); } function say(text) { send("turn.submit", { turn_id: `turn_${crypto.randomUUID()}`, text }); } // Wait for a complete candidate set, with a timeout so a quiet network // cannot block the handshake forever. function iceGatheringComplete(pc, timeoutMs = 5000) { if (pc.iceGatheringState === "complete") return Promise.resolve(); return new Promise((resolve) => { const done = () => { clearTimeout(t); pc.removeEventListener("icegatheringstatechange", check); resolve(); }; const check = () => { if (pc.iceGatheringState === "complete") done(); }; const t = setTimeout(done, timeoutMs); pc.addEventListener("icegatheringstatechange", check); }); } ``` ### 3. Reconnecting ```js const url = `${credentials.control_url}?session_id=${credentials.session_id}` + (lastEventId ? `&resume_from=${lastEventId}` : ""); const ws2 = new WebSocket(url, ["r2.v1", `r2.token.${credentials.control_token}`]); // Replayed events repeat their original `id` — the `seen` set above discards them. ``` That is a complete integration: **one HTTP call on your server, one WebSocket and one peer connection on your client.** ## Support Include the `request_id` from the error body, the `session_id`, and an approximate timestamp. For `internal_error` and `upstream_unavailable` these three are usually enough to locate the session end to end.