PopVid
PopVid
API Platform
Version 1.0.0
All Systems Operational

API Documentation

Learn how to integrate our API

Integration Guide对接指南

PopVid Realtime APIPopVid 实时 API

Build live, talking video characters into your product. Send a line of text — get back a character who answers it on camera, in real time, over WebRTC. This is the complete integration reference for Realtime API v1. 把会说话的实时视频角色接入你的产品。发送一行文本,即可通过 WebRTC 实时收到角色在镜头前的回应。本文是 Realtime API v1 的完整对接参考。

API versionAPI 版本
v1
Model模型
r2-realtime-v1
Document revision文档版本
1.7 · 2026-09-16
REST base URLREST 基础地址
https://popvid.ai/api/public/v1
Realtime endpoint实时接入点
From 取自 credentials.control_url
Subprotocol子协议
r2.v1

Overview概览#

You supply a character description and a scene. PopVid returns a live audio-video stream plus a structured event feed describing what the character is doing. 你提供角色描述与场景,PopVid 返回一路实时音视频流,以及一份描述角色当前行为的结构化事件流。

Layer分层 Protocol协议 Runs on运行位置 Purpose用途
Configuration配置层 HTTPS REST Your server你的服务端 Create a session and its short-lived client credential in one call; close sessions一次调用创建会话并签发短期客户端凭证;关闭会话
Control控制层 WebSocket Your client你的客户端 Start the session, send turns, receive events, track usage启动会话、发送对话轮次、接收事件、跟踪用量
Media媒体层 WebRTC Your client你的客户端 Receive audio + video. Receive-only — the client publishes no track接收音频与视频。仅接收 —— 客户端不推送任何轨道
Your server你的服务端 Your client你的客户端 PopVid 1 POST /connections long-lived API key使用长期 API Key 2 session · control_token · control_url · ice_servers 3 credentials only仅下发凭证 4 WebSocket connect建立 WebSocket 5 session.start 6 session.ready 7 media.offer 8 media.answer 9 audio + video over WebRTCWebRTC 音视频流 loop · each turn循环 · 每一轮 10 turn.submit turn.text · turn.started · media.clip · turn.visible 11 session.close 12 session.ended (billed usage)(含计费用量)
End-to-end lifecycle: one REST call on your server, then WebSocket control and WebRTC media on your client. 端到端生命周期:服务端一次 REST 调用,随后由客户端完成 WebSocket 控制与 WebRTC 媒体。
🔑

Your API key never reaches the browser. Your server exchanges it for a control_token that is scoped to one session, carries that session's budget, and expires. That token is the only credential your client ever holds. API Key 永远不会进入浏览器。你的服务端用它换取 control_token:该令牌只绑定单个会话、只携带该会话的额度、并且会过期。客户端持有的唯一凭证就是它。

Quickstart快速开始#

Endpoints and environments接入地址与环境

Production and test run on different hosts. Neither address is a constant you should compile into your client — take both from the places below and keep them in configuration. 正式环境与测试环境是两套不同的域名。这两个地址都不是可以写死进客户端的常量 —— 请按下表取值,并放在配置里。

Address地址 Where it comes from取值来源
REST base URLREST 基础地址 Production is https://popvid.ai/api/public/v1. The test environment runs on a different host, handed to you together with your test key — one address per environment. Read it from configuration; the examples below use POPVID_BASE_URL正式环境为 https://popvid.ai/api/public/v1。测试环境是另一个域名,随测试密钥一并提供 —— 每个环境一个地址。请从配置读取,下面的示例统一用 POPVID_BASE_URL
Realtime endpoint (WSS)实时接入点(WSS) Always credentials.control_url, used verbatim. It is a complete URL of the form wss://<realtime-host>/public/v1/realtime. The host is not the same as the REST host, it differs between environments, and it may change without notice — so it is returned to you on every session rather than published as a constant. A hardcoded WSS host is the most common reason a client that works in test fails in production一律使用 credentials.control_url 的原值。它是形如 wss://<realtime-host>/public/v1/realtime 的完整 URL。它的域名与 REST 域名不同,且区分环境、可能随时变更 —— 正因如此它是每次会话下发给你的,而不是作为常量公布。写死 WSS 域名是「测试能连、正式连不上」最常见的原因

1. On your server1. 在你的服务端

JavaScript
// Both come from configuration. Production is the default below; your test
// environment uses a different host, issued with your test key.
const BASE = process.env.POPVID_BASE_URL ?? "https://popvid.ai/api/public/v1";
const AUTH = { Authorization: `Bearer ${POPVID_API_KEY}`, "Content-Type": "application/json" };

// One call: defines the session, reserves capacity, and mints the credential
// for one browser. 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." },
    limits: { max_duration_ms: 600000 },
    credentials_ttl_ms: 600000
  })
}).then((r) => r.json());

// Send `credentials` to your client. Never send POPVID_API_KEY.
JavaScript
// 两者都来自配置。下面的默认值是正式环境;测试环境是另一个域名,
// 随测试密钥一并提供。
const BASE = process.env.POPVID_BASE_URL ?? "https://popvid.ai/api/public/v1";
const AUTH = { Authorization: `Bearer ${POPVID_API_KEY}`, "Content-Type": "application/json" };

// 一次调用:定义会话、预留容量,并为单个浏览器签发凭证。
// 此时仍未启动推流 —— 推流由 `session.start` 触发。
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." },
    limits: { max_duration_ms: 600000 },
    credentials_ttl_ms: 600000
  })
}).then((r) => r.json());

// 把 credentials 下发给客户端;绝不要下发 POPVID_API_KEY。

2. On your client2. 在你的客户端

JavaScript
// 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;

ws.onopen = () => send("session.start", {});

ws.onmessage = async (raw) => {
  const msg = JSON.parse(raw.data);
  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);         // required — see Media
      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 });
}
JavaScript
// 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]; };

// 计费从这一刻开始,不是从 session.ready 开始 —— 媒体一通就上报。
pc.onconnectionstatechange = () => {
  if (pc.connectionState === "connected") send("media.connected", {});
};

let lastEventId = null;

ws.onopen = () => send("session.start", {});

ws.onmessage = async (raw) => {
  const msg = JSON.parse(raw.data);
  lastEventId = msg.id;                       // 保留,用于断线重连

  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);         // 必需 —— 见「媒体」一节
      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 });
}

That is a complete integration: one HTTP call on your server, one WebSocket and one peer connection on your client. 这就是完整的对接:服务端一个 HTTP 调用,客户端一个 WebSocket 加一个 PeerConnection。

Authentication鉴权#

API keysAPI Key

Long-lived keys are prefixed pk_live_ and authenticate every REST call: 长期密钥以 pk_live_ 开头,用于所有 REST 调用的鉴权:

HTTP
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 from POST /connections. 绝不要把 API Key 内嵌到浏览器、移动端或任何会分发出去的客户端。密钥可以创建会话并产生费用。请把它留在服务端,只把 POST /connections 返回的 credentials 下发给客户端。

Keys are shown once at creation and stored only as a hash. If a key is lost, rotate it. 密钥只在创建时展示一次,服务端仅保存哈希。密钥丢失请直接轮换。

Control tokens控制令牌

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. POST /connections 返回一个 JWT,绑定单个 session_id,携带该会话的额度与过期时间。即使泄露,影响面也仅限一个会话、一份额度。

The default lifetime is 10 minutes; request up to 60 minutes with credentials_ttl_ms. Set it to at least the longest a session can run — currently 5 minutes. The token is checked when a socket connects, so one that outlives the session covers every reconnect inside it — including resuming after a drop. 默认有效期 10 分钟,可通过 credentials_ttl_ms 申请最长 60 分钟。请把它设为不小于会话可能达到的最长时长 —— 当前为 5 分钟。令牌只在建立连接时校验,因此只要它的有效期覆盖整场会话,会话内的每一次重连(含掉线后续传)都不需要再取新凭证。

REST API#

Create a session创建会话#

POST/connections

Defines the session, reserves capacity, and mints the credential your client needs — in one call. Does not start the stream — that happens when your client sends session.start. 一次调用完成三件事:定义会话、预留容量、签发客户端所需的凭证。不会启动推流 —— 推流在客户端发送 session.start 时才开始。

Request请求体

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说明
modelYes r2-realtime-v1 is the only value in v1v1 中唯一取值为 r2-realtime-v1
character.name
character.prompt
Yes prompt up to 4000 charactersprompt 最长 4000 字符
character.voice_ref_urlNo 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 it 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 character's voice is cloned from, so its quality sets the ceiling on 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
音色样本的绝对 http(s) 地址,角色语音将以它为参考。只取前 10 秒。最长 2048 字符。不传则使用默认音色。地址取不到,这一场会起不来 —— 请放在公网可访问且稳定的位置。

样本是角色音色的克隆来源,它的质量直接决定效果上限。请提供双声道(立体声)音频,尽量少杂音,并确保这 10 秒里突出的是你要复刻的那一个人声 —— 背景音乐、第二个说话人、环境噪声都会把音色带偏
scene.promptNo Setting and situation, up to 4000 characters场景与情境描述,最长 4000 字符
seed_image_urlNo Publicly reachable HTTPS image used as the first frame. It is 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可公网访问的 HTTPS 图片,用作首帧。它会被缩放到会话的 media.video 尺寸(576×768,3:4 竖版)——不裁剪、不补边,因此请按该比例提供。其它比例会被拉伸填满,而首帧同时锚定角色长相,这份变形会延续整场会话
languageNo BCP-47, defaults to enBCP-47,默认 en
historyNo Up to 40 prior messages. role is user or character最多 40 条历史消息,roleusercharacter
limitsNo See Limits and capacity「限额与容量」
credentials_ttl_msNo Lifetime of the returned control_token. Defaults to 10 minutes, capped at 60. Set it to at least the longest a session can run — currently 5 minutes返回的 control_token 的有效期。默认 10 分钟,上限 60 分钟。请设为不小于会话可能达到的最长时长 —— 当前为 5 分钟
metadataNo Up to 16 string values, echoed back on the session and in settlement最多 16 个字符串值,会在会话与结算结果中原样返回

Response 201响应 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://<realtime-host>/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
    }
  }
}

reservation_expires_at_ms is a hard deadline. Connect and send session.start before it, or the session is released (reason: reservation_expired). Capacity is exclusive — a session nobody uses would hold it indefinitely otherwise. reservation_expires_at_ms 是硬性截止时间。必须在此之前完成连接并发送 session.start,否则会话会被释放(reason: reservation_expired)。容量是独占的 —— 否则一个无人使用的会话会一直占着它。

💰

The budget starts at 30 seconds and extends itself. A session opens with a 30-second duration budget and buys another 30-second block as it runs, up to 300 000 ms (5 minutes). You never request an extension — each one arrives as a session.renewed event, and the session ends with budget_exhausted once your balance cannot cover the next block. limits.max_duration_ms in your request is not applied under the current billing model; if you need longer sessions or a different billing arrangement, contact us. Rates are on the pricing page. 预算从 30 秒起步,并自行延长。会话以 30 秒的时长预算开始,运行中自动续购下一个 30 秒块,上限 300 000 ms(5 分钟)。你无需主动申请延长 —— 每次延长都会以 session.renewed 事件通知;当余额不足以支付下一块时,会话以 budget_exhausted 结束。请求里的 limits.max_duration_ms 在当前计费模式下不生效;如果你需要更长的会话或不同的计费方式,请联系我们。费率见定价页

control_url is a complete URL and its host differs between environments — use it exactly as returned. Do not rebuild it from a constant. control_url 是一个完整 URL,其域名按环境不同 —— 请原样使用,不要用常量自行拼接。

Pass credentials.ice_servers straight to new RTCPeerConnection({ iceServers }). It is a list — its contents may change without notice, and your client should not assume a fixed shape. credentials.ice_servers 直接传给 new RTCPeerConnection({ iceServers })。它是一个列表,内容可能随时变化,客户端不要假设固定结构。

Send only the credentials object to your client. Keep session on your server — session.session_id is what you pass to close a session. 只把 credentials 下发给客户端,session 留在服务端 —— 关闭会话时要用的正是 session.session_id

Common failures: 409 no_capacity, 422 content_rejected (no session is created and no capacity is held), 409 budget_exhausted (your API Energy balance cannot cover the first block). 常见失败:409 no_capacity422 content_rejected(不会创建会话,也不占用容量)、409 budget_exhausted(API Energy 余额不足以支付首个计费块)。

Close a session关闭会话#

DELETE/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. 仍在连接中的客户端会在 socket 关闭前收到 session.ended

Realtime API实时 API#

Connecting建立连接#

WSS
{credentials.control_url}?session_id=sess_...&resume_from=evt_...

Credentials travel in the WebSocket subprotocol — browsers cannot set custom headers on a WebSocket: 凭证通过 WebSocket 子协议传递 —— 浏览器无法为 WebSocket 设置自定义请求头:

JavaScript
new WebSocket(url, ["r2.v1", `r2.token.${control_token}`]);

Non-browser clients may send Authorization: Bearer <control_token> instead. 非浏览器客户端可以改用 Authorization: Bearer <control_token>

🚫

Do not put the control token in the query string. It would be recorded in proxy logs, browser history, and Referer headers. 不要把控制令牌放进 query string。它会被记录到代理日志、浏览器历史和 Referer 头中。

Handshake close codes握手关闭码

Code关闭码Meaning含义
4401Token missing, malformed, or expired令牌缺失、格式错误或已过期
4403Token does not match session_id令牌与 session_id 不匹配
4404Session not found会话不存在
4409Session already has an active connection会话已有活跃连接
4410Session has ended会话已结束

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. 服务端每 5 秒发送一次协议级 ping,浏览器会自动响应,你无需实现任何逻辑。连续 3 次无响应(15 秒)会以 idle_timeout 结束会话。没有应用层心跳消息 —— 不要为了保活额外发帧。

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. debug is null unless the session was created with "debug": true; its contents are diagnostic and not part of the API contract. 服务端消息的 id 在同一会话内单调递增,同时也是你的续传游标。除非创建会话时带了 "debug": true,否则 debug 恒为 null;其内容仅供诊断,不属于 API 契约

Client events客户端事件#

session.start

Starts the stream. Succeeds once per session.启动推流。每个会话只能成功一次。

JSON
{ "type": "session.start", "id": "c-1",
  "data": { "seed_image_url": "..." } }

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. Responds with session.ready. seed_image_url 是对创建时配置的可选覆盖,同样要求 3:4 比例。其余内容 —— 角色、场景、历史、额度 —— 在创建时即固定。响应为 session.ready

media.offer

JSON
{ "type": "media.offer", "id": "c-2", "data": { "sdp": "v=0\r\no=- ..." } }

Requires session.ready first. Responds with media.answer. 必须先收到 session.ready。响应为 media.answer

media.connected

JSON
{ "type": "media.connected", "id": "c-3", "data": {} }

Send this once, as soon as your RTCPeerConnection reaches connected. Requires session.ready first. The data object is empty — the server timestamps it on arrival. There is no reply event. 在你的 RTCPeerConnection 进入 connected 后立即发送一次。必须先收到 session.readydata 为空对象 —— 服务端以收到的时刻为准。没有对应的响应事件。

💰

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, so you pay for the gap. Sending it is both the cheaper path and the only way the billing clock matches what your user actually sees. 计费从这一刻开始,而不是从 session.ready 开始。如果你始终不发送,系统会回落到渲染端自身的连通信号 —— 该信号平均早约 2.4 秒触发,这段差额由你承担。发送它更划算,也是让计费时钟与用户实际看到的画面对齐的唯一方式。

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_idYes 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由你生成(推荐 UUIDv4 或 ULID,加 turn_ 前缀)。所有 turn.* 事件都会回指它。同一会话内重复的 turn_id 会被幂等丢弃 —— 因此断线重连后的重试是安全的
textYes 1–2000 characters1–2000 字符
client_sent_at_msNo For your own latency attribution供你自己做延迟归因

Turns are newest-wins. A new turn.submit interrupts any turn still generating — 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. 轮次遵循「后来者胜」。新的 turn.submit 会打断仍在生成中的轮次 —— 没有单独的取消接口。被打断轮次的剩余事件将永不到达。v1 不做轮次排队,也没有「只取消不替换」的原语。

session.close

JSON
{ "type": "session.close", "id": "c-99", "data": { "reason": "client_closed" } }

Responds with session.ended, then closes with code 1000. 响应 session.ended,随后以关闭码 1000 断开连接。

Server events服务端事件#

Event事件 When触发时机 Frequency频率
session.ready Stream is up; you may negotiate media and send turns流已就绪,可协商媒体并发送轮次 Once per session每会话一次
media.answer SDP answer is readySDP answer 已就绪 Once per media.offer每个 media.offer 一次
turn.text The character's written reply is ready角色的文字回复已就绪 0–1 per turn每轮 0–1 次
turn.prompt_ready Rendering instructions have reached the renderer渲染指令已送达渲染器 0–1 per turn每轮 0–1 次
turn.started The character has stopped idling and begun this response角色已退出待机,开始本次回应 0–1 per turn每轮 0–1 次
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每轮 0–1 次
usage.tick Billing heartbeat, cumulative. The clock starts when the media connection is established, not at session.ready — a session that never connects media is never billed. expires_at_ms is absent until then.计费心跳,累计值。计时从媒体连通那一刻开始,不是从 session.ready 开始 —— 媒体一直没连通的会话不计费。在此之前 expires_at_ms 不存在。 Every 5 seconds每 5 秒
session.renewed The session's duration budget has just been extended. budget_ms is the new total, not the increment.本场会话的时长预算刚被延长。budget_ms 是延长后的新总量,不是增量。 Once per extension每次延长一次
session.ended Session is over会话已结束 Once, always last一次,且总在最后
error Something failed发生错误 Any time任意时刻

Payloads事件负载

JSON
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 }

media.clip.kind is idle or turn. turn_id is null unless kind is turn. A common use is showing an idle affordance in your UI while kind is idle, so users can tell the character is waiting rather than replying. media.clip.kind 取值为 idleturn。除非 kindturn,否则 turn_idnull。一个常见用法是在 kindidle 时于界面上给出「待机中」提示,让用户能区分角色是在等待还是在回应。

turn.started.est_ms is an estimate for progress indication, not a commitment. turn.visible.latency_ms measures from our receipt of turn.submit to first frame sent; it excludes your client's jitter buffer and decode time, so it is a lower bound. turn.started.est_ms 只是用于进度提示的估算,不构成承诺。turn.visible.latency_ms 统计的是从我们收到 turn.submit 到发出首帧的时间,不含客户端抖动缓冲与解码耗时,因此是一个下界。

session.ended.reason

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. media_failed 也包含「媒体从未连通」这一种:session.ready 之后 30 秒内没有建立 WebRTC 连接,会话即结束,且不计费

viewer_gone means the renderer saw your WebRTC connection drop and released the session immediately, rather than waiting for the heartbeat to time out. It is the same outcome as idle_timeout, reached sooner. Renegotiating media does not trigger it. viewer_gone 表示渲染端观察到你的 WebRTC 连接已断开,并立即释放了会话,而不是等心跳超时。结果与 idle_timeout 相同,只是更早。重新协商媒体不会触发它。

💰

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. usage.tick 是累计值,不是增量。丢一条也没关系 —— 下一条会带上正确的总量。当 budget_remaining_ms 归零时,会话会被自动结束。额度是硬上限,不是提醒。

Delivery guarantees投递保证#

Guaranteed有保证

  • id increases monotonically within a session; events arrive in that order.id 在会话内单调递增,事件按此顺序到达。
  • Within a single turn: turn.textturn.prompt_readyturn.startedmedia.clip(×n) → turn.visible.单个轮次内的顺序:turn.textturn.prompt_readyturn.startedmedia.clip(×n)→ turn.visible
  • session.ready is always first; session.ended is always last.session.ready 永远是第一条,session.ended 永远是最后一条。

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.session.readysession.ended 外,所有事件均为尽力投递
⚠️

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. 不要设计成必须集齐五个轮次事件才能推进的 UI 状态机。某一轮可能只产出 turn.text,再无其他。请以 turn_id 为键组织 UI,并自行设置超时。

Reconnecting断线重连#

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: 事件保留 5 分钟,但连接断开后会话只为你保留 10 秒。请在这个窗口内重连,否则会话会以 idle_timeout 结束、渲染器被释放给他人 —— 应当尽快重试,不要长退避。用你收到的最后一个 id 重连:

WSS
{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_msdeduplicate by id. This is the only situation that produces duplicates. 游标之后的所有事件会被重放,随后恢复实时投递。重放事件与原事件逐字节一致,包括 idts_ms —— 请按 id 去重。这是唯一会产生重复的场景。

If the cursor has aged out you receive error with code resume_window_expired (fatal: false) and only live events afterwards; rebuild your UI state. 若游标已过期,你会收到 code 为 resume_window_expirederrorfatal: false),此后只有实时事件;此时请重建 UI 状态。

Media媒体#

Media flows directly between the renderer and your client over SRTP. Signaling travels on the WebSocket. 媒体经 SRTP 在渲染器与你的客户端之间直连传输,信令走 WebSocket。

Negotiating协商

JavaScript
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 });
JavaScript
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);           // 必需
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. v1 不支持 Trickle ICE。发送 offer 前请等待 ICE 收集完成;answer 同样携带完整候选集。请加上超时(5 秒比较合理),避免某些网络永远不上报完成而把流程卡死。

Receiving接收

Track轨道 Codec编码 Parameters参数
video视频H.264 baseline 576×768 @ 24 fps, roughly 1.5–2 Mbps576×768 @ 24 fps,约 1.5–2 Mbps
audio音频Opus 48 kHz mono48 kHz 单声道

Audio is generated together with the video, so picture and sound are inherently in sync — no client-side alignment is needed. 音频与视频一同生成,音画天然同步 —— 客户端无需做对齐。

🎙️

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. 连接是纯接收的,不支持上行音频。两条 m-line 都请声明为 recvonly;带 sendonlysendrecv 轨道的 offer 不被接受。v1 不包含语音输入(STT / VAD)—— 对话轮次一律通过 turn.submit 以文本提交。

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 resumepc.close()。会话仍然存活且继续计费;重新发送 media.offer 即可恢复
Recover after a network change or ICE failure网络切换或 ICE 失败后恢复 Create a new RTCPeerConnection and send a fresh media.offer. This is the only recovery primitive in v1; expect a 1–2 second gap新建一个 RTCPeerConnection 并发送新的 media.offer。这是 v1 中唯一的恢复手段,预计有 1–2 秒断流
End the session结束会话 session.close or DELETE. Do not just call pc.close() and walk away — the session remains billable until it times outsession.closeDELETE不要只调 pc.close() 就走人 —— 会话在超时之前仍然计费

A real session, message by message真实调用样例#

Every message of one real session, in the order it happened — captured from an integrator's iOS app on a physical device (react-native-webrtc + WebSocket). The JSON is verbatim from the capture apart from the redactions noted below. 一场真实会话从建连到结算的全部报文,按发生顺序排列 —— 采集自接入方 iOS App 真机(react-native-webrtc + WebSocket)。除下面声明的脱敏项外,所有 JSON 均为原始抓取。

Captured采集时间 Session会话 Turns轮次 Environment环境
2026-09-01sess_S3V0Pb6K… 3 turns3 轮对话 dev
📖

How to read this. Times are offsets from session creation. One message is marked constructed — it was not captured and is written to contract; everything else is byte-for-byte from the session. Redacted: control_token is masked, the realtime host appears as <realtime-host> (it varies by environment — always use the value you are given), and the asset URLs are replaced with an example host. The capture also carried a greeting clip and greeting parameters; both were withdrawn from the contract after it was taken, so they are removed here too. One value is now historical: this session was created under the earlier budget model and shows max_duration_ms: 240000. Sessions today start at 5 000 and extend themselves — see What changed. The message sequence itself is unaffected. 怎么读这份样例。时间列是相对会话创建时刻的偏移。标注为构造的一条未实采、按契约拼写,其余逐字节来自真实会话。脱敏项:control_token 已打码;实时接入点写作 <realtime-host>(它按环境不同,请始终使用下发给你的值);素材 URL 换成了示例域名。此外,采集时会话还带了开场白参数与一条开场片段,二者已从契约中撤下,样例中一并移除。有一个数值现在属于历史:这场会话创建于旧的预算模型下,样例中为 max_duration_ms: 240000;如今的会话从 5 000 起步并自动续购 —— 见变更明细。报文序列本身不受影响。

1 · Connect — one REST call for every credential① 建连 —— 一次 REST 调用拿到全部凭证#

A single POST /connections does three things: define the session, reserve capacity (a 60-second window), and mint the client credential. Nothing is streaming yet. The request carries Authorization: Bearer pk_live_…, so this step belongs on your own server. 一次 POST /connections 完成三件事:定义会话、预留容量(60 秒窗口)、签发客户端凭证。此时尚未启动推流。请求头带 Authorization: Bearer pk_live_…,这一步应发生在接入方自己的服务端。

client → server客户端 → 服务端 HTTPS POST /connections t+0.00s
JSON
{
  "model": "r2-realtime-v1",
  "character": {
    "name": "🌹 Diana Moreau 🌹",
    "prompt": "You are the character in this story. Stay in character, reply in short spoken lines, and never mention being an AI."
  },
  "language": "en",
  "limits": {
    "max_duration_ms": 240000,
    "max_turns": 200,
    "turn_rate_per_min": 20
  },
  "credentials_ttl_ms": 600000,
  "scene": {
    "prompt": "A casual real-time video chat with the user.\n\nWhen the user message is a third-person line starting with \"The user ...\", it describes the user's own expression or action, not something done to you. React to what you observe about them."
  },
  "seed_image_url": "https://cdn.example.com/aria/last_frame.png",
  "metadata": {
    "story_id": "story_20260620192023_611069",
    "episode_id": "episode_20260620192023Gn7v"
  }
}

character.prompt / scene.prompt are the integrator's own persona and scene copy. seed_image_url is the first frame (publicly reachable HTTPS). credentials_ttl_ms is set to at least limits.max_duration_ms — the token is checked once, when the WebSocket is opened, so a lifetime covering the whole session is what makes reconnects work. character.prompt / scene.prompt 是接入方自己的人设与场景文案;seed_image_url 为首帧图片(公网可访问的 HTTPS);credentials_ttl_ms 设为不小于 limits.max_duration_ms —— 令牌只在建立 WebSocket 时校验一次,有效期覆盖整场会话即可支撑断线重连。

server → client服务端 → 客户端 HTTPS 201 POST /connections 响应 t+0.00s
JSON
{
  "session": {
    "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
    "status": "created",
    "model": "r2-realtime-v1",
    "created_at_ms": 1788240929526,
    "reservation_expires_at_ms": 1788240989526,
    "limits": {
      "max_duration_ms": 240000,
      "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": {
      "story_id": "story_20260620192023_611069",
      "episode_id": "episode_20260620192023Gn7v"
    }
  },
  "credentials": {
    "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
    "control_token": "eyJhbGciOiJIUzI1NiIsInR5……<token 已打码,实际 311 字符>",
    "control_url": "wss://<realtime-host>/public/v1/realtime",
    "expires_at_ms": 1788241529594,
    "ice_servers": [
      {
        "urls": [
          "stun:stun.l.google.com:19302"
        ]
      }
    ],
    "budget": {
      "max_duration_ms": 240000,
      "max_turns": 200,
      "remaining_duration_ms": 240000,
      "remaining_turns": 200
    }
  }
}

Keep session on your server (session_id is what closes it) and hand the whole credentials object to the client. reservation_expires_at_ms is a hard deadline: connect and send session.start within 60 seconds or the session is released. session 留在服务端(关会话要用 session_id),credentials 整包下发给客户端。reservation_expires_at_ms 是硬性截止:60 秒内必须连上并发出 session.start,否则会话被释放。

2 · Control plane — WebSocket and starting the stream② 控制面 —— WebSocket 连接与起流#

The client opens the WebSocket at credentials.control_url with ?session_id=…. The credential travels in the subprotocol — ["r2.v1", "r2.token.<control_token>"] from a browser, or subprotocol r2.v1 plus an Authorization: Bearer <control_token> header from a non-browser client, which is what this capture does. Never put the token in the query string. 客户端拿 credentials.control_url 建 WebSocket,URL 带 ?session_id=…。凭证走子协议 ["r2.v1", "r2.token.<control_token>"](浏览器),或子协议 r2.v1 + Authorization: Bearer <control_token> 请求头(非浏览器客户端 —— 本样例即此方式)。不要把令牌放进 query string。

client → server客户端 → 服务端 WS session.start 连接建立后立即
JSON
{
  "type": "session.start",
  "id": "c-1",
  "data": {
    "seed_image_url": "https://cdn.example.com/aria/last_frame.png"
  }
}

Succeeds once per session. Fields in data are optional overrides of what you set at creation (this capture re-sends the seed). GPU streaming starts here. 每个会话只能成功一次。data 里的字段是对建会话配置的可选覆盖(本例重复下发了 seed)。真正的 GPU 推流从这里开始。

server → client服务端 → 客户端 WS session.ready t+1.28s
JSON
{
  "type": "session.ready",
  "id": "evt_0000B9xILXXdj2SBlmxso4",
  "ts_ms": 1788240930806,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "expires_at_ms": 1788241170804,
    "limits": {
      "max_duration_ms": 240000,
      "max_turns": 200,
      "turn_rate_per_min": 20
    },
    "media": {
      "audio": {
        "channels": 1,
        "codec": "opus",
        "sample_rate": 48000
      },
      "video": {
        "codec": "h264",
        "fps": 24,
        "height": 768,
        "width": 576
      }
    }
  },
  "debug": null
}

Always the first server event; only after it may you negotiate media and submit turns. Note the envelope: id increases monotonically within the session and is the cursor for resume_from; debug is always null. 永远是第一条服务端事件,收到它才可以协商媒体、发送轮次。注意信封结构:id 在会话内单调递增,是断线续传(resume_from)的游标;debug 恒为 null。

3 · Media plane — WebRTC negotiation③ 媒体面 —— WebRTC 协商#

Two m-lines, one offer, one answer. v1 does not support trickle ICE: wait for local candidate gathering to finish before sending the offer, and the answer likewise carries a complete set. Feed credentials.ice_servers straight into the peer connection. 两条 m-line、offer/answer 各一次。v1 不支持 Trickle ICE:必须等本地 ICE 候选收集完成再发 offer,answer 同样携带完整候选集。ICE 服务器直接用 credentials.ice_servers

client → server客户端 → 服务端 WS media.offer 约 t+2.13s
JSON
{
  "type": "media.offer",
  "id": "c-2",
  "data": {
    "sdp": "<完整 SDP 见下方折叠块,共 6375 字符>"
  }
}

This offer comes from react-native-webrtc on iOS and carries the complete ICE candidate set. 本样例的 offer 来自 iOS 端 react-native-webrtc,含完整 ICE 候选集。

server → client服务端 → 客户端 WS media.answer t+4.74s
JSON
{
  "type": "media.answer",
  "id": "evt_0000B9xILrImG0Rt3UAIPw",
  "ts_ms": 1788240934261,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "sdp": "<完整 SDP 见下方折叠块,共 3144 字符>"
  },
  "debug": null
}

After setRemoteDescription the SRTP path is up and audio + video start arriving at ontrack. setRemoteDescription 之后 SRTP 直连建立,音视频开始到达 ontrack

server → client服务端 → 客户端 WS media.clip(idle) t+12.15s
JSON
{
  "type": "media.clip",
  "id": "evt_0000B9xIMXh0eImuWnpF1E",
  "ts_ms": 1788240941675,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "kind": "idle",
    "seq": 0,
    "turn_id": null
  },
  "debug": null
}

Signals that a clip's first frame is on screen. kind: idle means the character is waiting — a common use is telling "waiting" apart from "replying" in your UI. 视频片段首帧上屏的通知。kind: idle 表示角色处于待机 —— 常见用法是据此在 UI 上区分「等待中」与「回应中」。

4 · A turn — turn.submit and its five events④ 对话轮次 —— turn.submit 与五个回执事件#

One turn = one turn.submit from the client, then turn.textturn.prompt_readyturn.startedmedia.clipturn.visible from the server. Apart from ready and ended, every event is best-effort: do not build a state machine that needs all five to advance. Turn 1 is expanded in full below; turns 2 and 3 are summarised. 一轮 = 客户端一条 turn.submit,服务端按序回 turn.textturn.prompt_readyturn.startedmedia.clipturn.visible。除 ready/ended 外所有事件都是尽力投递,不要做必须集齐五个事件才推进的状态机。下面完整展开第 1 轮,第 2、3 轮摘要列出。

client → server客户端 → 服务端 WS turn.submit t+12.98s
JSON
{
  "type": "turn.submit",
  "id": "c-3",
  "data": {
    "turn_id": "turn_5420882c-c732-49fa-b0db-cc8f9c3a851a",
    "text": "The user pats you",
    "client_sent_at_ms": 1788240942507
  }
}

The client generates turn_id (turn_ + UUIDv4); every turn.* event refers back to it, and re-submitting the same id in one session is discarded as a duplicate. The text here is a third-person action line (the user patted the character) — expression and gesture input travels the same plain-text channel. turn_id 由客户端生成turn_ 前缀 + UUIDv4),所有 turn.* 事件回指它,同一会话内重复提交会被幂等丢弃。本例文本是第三人称动作描述(用户拍了拍角色)—— 表情/动作类输入走的同样是纯文本通道。

server → client服务端 → 客户端 WS turn.text t+14.05s
JSON
{
  "type": "turn.text",
  "id": "evt_0000B9xIMiZGDS7qFxHhy4",
  "ts_ms": 1788240943578,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "text": "Hey! Don't just pat me like that, it's ticklish!",
    "turn_id": "turn_5420882c-c732-49fa-b0db-cc8f9c3a851a"
  },
  "debug": null
}

The character's written reply. It arrives about 1.6 s ahead of the video, which makes it the right thing to put on screen first as a caption. 角色的文字回复,先于视频约 1.6 秒到达 —— 适合先上屏做字幕。

server → client服务端 → 客户端 WS turn.prompt_ready t+14.06s
JSON
{
  "type": "turn.prompt_ready",
  "id": "evt_0000B9xIMid9yxFES36p2u",
  "ts_ms": 1788240943588,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "turn_id": "turn_5420882c-c732-49fa-b0db-cc8f9c3a851a"
  },
  "debug": null
}

The turn has been accepted for generation. 渲染指令已送达渲染器。

server → client服务端 → 客户端 WS turn.started t+14.10s
JSON
{
  "type": "turn.started",
  "id": "evt_0000B9xIMioVImJp1EPsjA",
  "ts_ms": 1788240943621,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "est_ms": 1400,
    "turn_id": "turn_5420882c-c732-49fa-b0db-cc8f9c3a851a"
  },
  "debug": null
}

The character leaves idle and starts this response. est_ms is a progress hint, not a commitment. 角色退出待机、开始本次回应。est_ms 是进度提示用的预估,不构成承诺。

server → client服务端 → 客户端 WS media.clip(turn) t+15.65s
JSON
{
  "type": "media.clip",
  "id": "evt_0000B9xIMrjW8gg4gN9Whk",
  "ts_ms": 1788240945181,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "kind": "turn",
    "seq": 1,
    "turn_id": "turn_5420882c-c732-49fa-b0db-cc8f9c3a851a"
  },
  "debug": null
}

The first frame of this turn's response is on screen; turn_id points back at the submitted turn. 本轮回应视频的首帧已上屏,turn_id 回指提交的那一轮。

server → client服务端 → 客户端 WS turn.visible t+15.69s
JSON
{
  "type": "turn.visible",
  "id": "evt_0000B9xIMruVTpT5ESIIpk",
  "ts_ms": 1788240945212,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "latency_ms": 2487
  },
  "debug": null
}

⚠️ Field finding. The contract states the payload is {turn_id, latency_ms}, but across all three captured turns turn.visible arrived without turn_id (the raw message is kept unedited above). Do not rely on that field for per-turn reconciliation yet. latency_ms = 2487 is measured server-side from receiving the submit to sending the first frame; the client measured 2705 ms end to end, and the difference is network plus decode. ⚠️ 现场发现:契约写明 payload 为 {turn_id, latency_ms},但本次采集三轮的 turn.visible 均未携带 turn_id(原始报文保留未修饰)。客户端做轮次对账时暂勿依赖此字段。latency_ms = 2487 是服务端从收到 submit 到发出首帧的耗时;客户端实测 submit → visible 全程 2705ms,差值即网络与解码开销。

turn.submit textturn.text 回复latency_ms
2I don't know where I'm from.That sounds lonely... Do you want to stay here with me?2365 ms
3how are youI'm doing wonderful, especially now that we're chatting!2346 ms

Turns 2 and 3 produced exactly the same event sequence as turn 1; only the differing fields are listed. 第 2、3 轮事件序列与第 1 轮完全一致,仅列差异字段。

5 · Usage heartbeat — usage.tick⑤ 计费心跳 —— usage.tick#

One every 5 seconds. Every number is cumulative, not a delta, so losing one costs nothing — the next tick carries the correct total. When budget_remaining_ms reaches zero the server ends the session; the budget is a hard ceiling. 每 5 秒一条,全部是累计值而非增量,丢失一条无损 —— 下一条会带上正确总量。budget_remaining_ms 归零时会话被服务端直接结束,额度是硬上限。

server → client服务端 → 客户端 WS usage.tick t+17.95s
JSON
{
  "type": "usage.tick",
  "id": "evt_0000B9xIN4sVGaadS0SO12",
  "ts_ms": 1788240947480,
  "session_id": "sess_S3V0Pb6K6qaeh2G58YalNI",
  "data": {
    "billed_ms": 16673,
    "budget_ms": 240000,
    "budget_remaining_ms": 223327,
    "turns_remaining": 199,
    "turns_used": 1
  },
  "debug": null
}

The books right after turn 1: 16.7 s billed, 1 turn used, 223 s and 199 turns left. 第 1 轮刚结束时的账面:已计费 16.7 秒、用掉 1 轮、余量 223 秒 / 199 轮。

6 · Teardown — closing and settlement⑥ 收尾 —— 关会话与结算#

Close it twice over: send session.close on the WebSocket and call DELETE /sessions/{session_id} over REST, either together or the second as a fallback. Both are idempotent. Do not just close the peer connection and walk away — the session keeps billing until it times out. These messages come from a second, shorter session captured the same day (sess_krJHaNhA…). 双保险收尾:WS 发 session.close,同时(或作为兜底)REST 调 DELETE /sessions/{session_id},两者均幂等。不要只关 PeerConnection 就走人 —— 会话在超时前持续计费。本节报文取自同日另一场短会话(sess_krJHaNhA…)。

client → server客户端 → 服务端 WS session.close
JSON
{
  "type": "session.close",
  "id": "c-2",
  "data": {
    "reason": "client_closed"
  }
}

Constructed, not captured — the capturing client closed its socket immediately after sending session.close and never waited for this. The contract guarantees it is the session's last event, after which the connection closes with code 1000. The values are taken from the DELETE settlement below, whose fields are identical.

server → client服务端 → 客户端 WS session.ended
JSON
{
  "type": "session.ended",
  "id": "evt_<示例>",
  "ts_ms": 1788240881865,
  "session_id": "sess_krJHaNhA8JRoJyOUYdYkcb",
  "data": {
    "reason": "client_closed",
    "duration_ms": 1677,
    "billed_ms": 1677,
    "turns_used": 0
  },
  "debug": null
}

The settlement: billed_ms / turns_used / reason. Repeat calls return the same result. metadata is whatever you passed at creation, echoed back verbatim — a good place for your own business ids. 注意:此条为按契约构造的示例,未实采 —— 采集端发出 close 后立即关闭了 socket,没等它回来。契约保证它是会话的最后一条事件,随后连接以关闭码 1000 断开。数值取自下方 DELETE 结算单,两者字段一致。

server → client服务端 → 客户端 HTTPS 200 DELETE /sessions/{id} 响应
JSON
{
  "session_id": "sess_krJHaNhA8JRoJyOUYdYkcb",
  "status": "ended",
  "reason": "client_closed",
  "started_at_ms": 1788240880188,
  "ended_at_ms": 1788240881865,
  "duration_ms": 1677,
  "billed_ms": 1677,
  "turns_used": 0,
  "metadata": {
    "story_id": "story_20260620192023_611069",
    "episode_id": "episode_20260620192023Gn7v"
  }
}

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. REST 与 WebSocket 共用同一个错误对象。REST 把它放在 error 字段下;WebSocket 则作为 error 事件的 data 下发。

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 分支处理,绝不要依赖 message —— 文案面向人类阅读,可能随时变更。fatal: true(仅 WebSocket)表示会话已经结束,随后会有 session.ended。提工单时请附上 request_id

Code错误码 Status Retryable可重试 Meaning含义
unauthorized401No Key or token invalid or expired密钥或令牌无效、已过期
forbidden403No Credential does not match the target session凭证与目标会话不匹配
invalid_request400No Missing, malformed, or oversized field — message names it字段缺失、格式错误或超长 —— message 会指出具体字段
invalid_state409No Command does not apply in the session's current state当前会话状态下该指令不适用
session_not_found404No No such session会话不存在
session_expired410No Session has ended; create a new one会话已结束,请新建
no_capacity409Yes No renderer available. Back off using retry_after_ms无可用渲染器,请按 retry_after_ms 退避重试
content_rejected422No Content screening failed. At session creation nothing is created; on a turn only that turn is dropped and turn_id identifies it内容审核未通过。发生在创建会话时不会创建任何资源;发生在某一轮时只丢弃该轮,turn_id 指明是哪一轮
rate_limited429Yes Turn rate exceeded. Honor retry_after_ms轮次频率超限,请遵守 retry_after_ms
quota_exceeded429No Daily or concurrency quota exhausted. Reserved — not currently returned; see Per account日配额或并发配额已用尽。保留错误码,当前不会返回;见「按账号」
budget_exhausted409No Session budget spent. fatal: true会话额度已耗尽。fatal: true
resume_window_expired410No Resume cursor older than 5 minutes. fatal: false续传游标已超过 5 分钟。fatal: false
unsupported400No Capability not available in v1v1 不支持该能力
upstream_unavailable502Yes Transient backend failure后端临时故障
internal_error500Yes Unexpected failure. Report with request_id未预期的错误,请附 request_id 反馈

Limits and capacity限额与容量#

Per session单会话

Limit限额项 Default默认 Maximum上限 On exceed超限行为
max_duration_ms 30 000, extended automatically30 000,自动续购 300 000 (5 min)300 000(5 分钟) session.ended / budget_exhausted
max_turns2001 000 Same同上
turn_rate_per_min2060 error / rate_limited; the turn is droppederror / rate_limited;该轮被丢弃
Reservation window预留窗口60 s session.ended / reservation_expired
Event retention事件保留300 s error / resume_window_expired
Conversation context会话上下文 100 most recent messages最近 100 条消息 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. The binding constraint is renderer concurrency below, together with your API Energy balance. If your integration needs guaranteed per-account quotas, contact us. concurrent_sessionssessions_per_dayturns_per_day 会记录在你的密钥上,但当前不做强制 —— 目前不会有请求因为超出它们而被拒绝,也不会返回 429 quota_exceeded。真正约束你的是下面的渲染器并发容量,以及你的 API Energy 余额。如果你的接入需要有保障的账号级配额,请联系我们

Concurrency并发

🔴

Concurrent session capacity is limited and is allocated per renderer. When none is free, POST /connections returns 409 no_capacity with retry_after_ms, and neither a session nor a credential is issued. 并发会话容量有限,且按渲染器分配。没有空闲容量时,POST /connections 返回 409 no_capacity 并带上 retry_after_ms,此时既不会创建会话,也不会签发凭证。

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. 你的集成必须为 no_capacity 实现退避重试。请按并发会话数而非请求速率来估算用量。若要上线需要持续高并发的业务,请提前与我们沟通。

Best practices最佳实践#

Do this建议做法 Why原因
Keep the API key server-side; mint one credential bundle per client session把 API Key 留在服务端;每个客户端会话签发一份凭证 A leaked control token costs one session; a leaked API key costs your account控制令牌泄露只损失一个会话,API Key 泄露则损失整个账号
Generate turn_id client-side and reuse it when retrying在客户端生成 turn_id,重试时复用同一个 Retries become idempotent instead of duplicating a turn让重试变成幂等,而不是重复产生一轮
Track the last event id and pass it as resume_from记录最后一个事件 id,作为 resume_from 传入 A brief network drop costs nothing; without it you lose events silently短暂断网零损失;不传则会静默丢事件
Deduplicate events by idid 对事件去重 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事件是尽力投递,某一轮可能没有 turn.visible
Handle connectionState === "failed"处理 connectionState === "failed" Some networks block the UDP that WebRTC needs部分网络会封锁 WebRTC 所需的 UDP
Always end sessions explicitly始终显式结束会话 An abandoned session bills until it times out被遗弃的会话会一直计费到超时
Show usage.tick budget to your users on long sessions长会话中把 usage.tick 的剩余额度展示给用户 Sessions end abruptly at budget exhaustion otherwise否则额度耗尽时会话会突然中断
Back off on no_capacity and rate_limitedno_capacityrate_limited 做退避 Both are transient and carry retry_after_ms两者都是临时状态,且带有 retry_after_ms
Ignore unknown fields and unknown enum values忽略未知字段与未知枚举值 New ones are added without a version bump新增内容不会伴随版本号变更

Not available in v1v1 暂不支持#

Capability能力 Status状态
Speech input (STT / VAD)语音输入(STT / VAD) Not supported. The WebRTC connection is receive-only — the client publishes no audio track, and turns are text only不支持。WebRTC 连接为纯接收,客户端不推送音频轨道,轮次仅支持文本
TURN relayTURN 中继 ice_servers currently carries STUN only. Clients on networks that block UDP will fail to connect. The field is a list — adding relays requires no client changeice_servers 目前只含 STUN。封锁 UDP 的网络将无法连接。该字段是列表,后续增加中继无需客户端改动
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 restartTrickle ICE 与 ICE restart Recovery is a full re-offer恢复方式是完整重新 offer
Session status polling会话状态轮询 Use usage.tick and the DELETE settlement response请使用 usage.tickDELETE 的结算响应
Webhooks Session completion is not pushed to your server会话结束不会推送到你的服务端

Versioning版本策略#

📝

Document revision 1.7 — brings the guide up to date with the API as deployed: the media.connected client event and the session.renewed server event, the viewer_gone end reason, the character.voice_ref_url request field, and the billing model that starts a session at a 5-second budget and extends it automatically. Field-level detail is in What changed below. From this revision on, every revision ships that table. 文档版本 1.7 —— 将文档与已部署的接口对齐:新增客户端事件 media.connected、服务端事件 session.renewed、结束原因 viewer_gone、请求字段 character.voice_ref_url,以及「会话以 5 秒预算起步并自动续购」的计费模型。字段级明细见下方变更明细自本版起,每一版都会附带该表。

What changed · 1.6 → 1.7变更明细 · 1.6 → 1.7#

Every item below is backward-compatible: nothing was removed or renamed, and the API version is unchanged (/public/v1, r2.v1). A client written against 1.6 keeps working — but the two rows marked change what it will observe at runtime. 下列变更全部向后兼容:没有删除或改名,API 版本未变(仍为 /public/v1r2.v1)。按 1.6 编写的客户端可继续运行 —— 但标记 的两行会改变它在运行时观察到的结果。

Change类型 Item 1.6 — was1.6 — 原 1.7 — now1.7 — 现
Changed变更 limits.max_duration_ms 600 000 default
3 600 000 max
默认 600 000
上限 3 600 000
5 000 at start, extended automatically, 300 000 max. The value you send is not applied under the current billing model起步 5 000,自动续购,上限 300 000。当前计费模式下你传入的值不生效
Changed变更 Per-account quotas账号级配额 Exceeding returns 429 quota_exceeded超限返回 429 quota_exceeded Recorded but not enforced; that code is not returned仅记录、不强制;该错误码不会返回
Added新增 media.connected (client event)(客户端事件) Report WebRTC connected; anchors the billing clock上报 WebRTC 已连通;作为计费起点
Added新增 session.renewed (server event)(服务端事件) The duration budget was just extended时长预算刚被延长
Added新增 viewer_gone (session.ended.reason)session.ended.reason Renderer saw the connection drop; same outcome as idle_timeout, sooner渲染端观察到连接断开;结果同 idle_timeout,但更早
Added新增 character.voice_ref_url (request)(请求字段) Voice sample, first 10 s used; a bad URL fails the session音色样本,取前 10 秒;地址取不到则整场起不来
Added新增 expires_at_ms (on usage.tick)usage.tick 上) Absolute end time of the current budget; absent before media connects当前预算的绝对结束时刻;媒体连通前不存在
Changed变更 Heartbeat interval心跳间隔 10 s5 s
Changed变更 idle_timeout threshold阈值 3 missed (30 s)3 次无响应(30 秒) 3 missed (15 s)3 次无响应(15 秒
Changed变更 Session held after socket drop断连后会话保留 Not documented未说明 10 s, then idle_timeout10 秒,之后 idle_timeout
Changed变更 Billing start计费起点 Not stated未明确 Media connection; a session that never connects media is never billed媒体连通时刻;媒体一直没连通的会话不计费
Changed变更 no_capacity · retry_after_ms 30 0005 000

The REST path (/public/v1) and the WebSocket subprotocol (r2.v1) carry the version together — they always move as a pair. REST 路径(/public/v1)与 WebSocket 子协议(r2.v1)共同承载版本号 —— 两者始终同步升级。

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. 发布新版本,旧版本至少继续支持 6 个月:删除或重命名字段、改变字段含义或类型、移除事件类型、收紧校验。

Pin nothing beyond the version string, and treat debug as absent — it is diagnostic output and changes without notice. 除版本号外不要固化任何东西;请把 debug 当作不存在 —— 它是诊断输出,随时可能变化。

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. 请提供错误响应中的 request_idsession_id 以及大致的时间戳。对于 internal_errorupstream_unavailable,这三项通常足以端到端定位会话。