# Reverie 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.8** (2026-09-18) — see "Versioning" for what changed since 1.7 - 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://api.reverie-ai.com/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://api.reverie-ai.com/api/public/v1` ### POST /connections — create a session `POST https://api.reverie-ai.com/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://api.reverie-ai.com/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` | Yes | 1–2000 characters | | `client_sent_at_ms` | No | For your own latency attribution | **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 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` | | 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 buying API Energy: https://reverie-ai.com/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 only | | 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.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.REVERIE_BASE_URL || "https://api.reverie-ai.com/api/public/v1"; const AUTH = { Authorization: `Bearer ${process.env.REVERIE_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.