{
"type": "session.ready",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"status": "active",
"expires_at": "2026-10-17T18:05:00Z"
}{
"type": "session.warning",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"code": "sample_overdue",
"act_by": "2026-10-17T18:02:15Z"
}{
"type": "session.terminated",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"reason": "license_revoked",
"sentence": "Maya Okafor revoked this license.",
"revocation": "rvk_s9t0wxyhk5zba2q8v1m3n7p4r6",
"sample": null,
"at": "2026-10-17T18:03:41Z"
}{
"type": "sample.decided",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"ref": "turn-42",
"sample": "smp_4r6s9t0wxyhk5zba2q8v1m3n7p",
"decision": "allow",
"reasons": []
}{
"type": "error",
"ref": "turn-43",
"code": "parameter_invalid",
"detail": "`window.end_ms` must be greater than `window.start_ms`.",
"param": "window.end_ms"
}{
"type": "pong",
"id": "1"
}{
"type": "sample.submit",
"ref": "turn-42",
"kind": "transcript",
"window": {
"start_ms": 81200,
"end_ms": 84600
},
"text": "Welcome back to the Ridge Runners podcast."
}{
"type": "ping",
"id": "1"
}Session stream (planned)
Proposed WebSocket contract for session changes and termination.
Connect
Open a WebSocket to thestream_url returned by
POST /v1/sessions:
wss://app.reflections.ai/v1/sessions/ses_q8v1m3n7p4r6s9t0wxyhk5zba2/stream
| From | How |
|---|---|
| A client, such as a browser or an app | Offer two subprotocols in Sec-WebSocket-Protocol: tlp.session.v1, and tlp.session.<token>, where <token> is the session’s current token. |
| A server | Offer tlp.session.v1, and send your secret key in Authorization: Bearer rk_…. |
tlp.session.v1 and never echoes the token, so it stays out of response
headers and proxy logs. The token is checked once, when the connection opens. Renewing it with
POST /v1/sessions/{session_id}/renew keeps the session
alive and does not reconnect the stream. Message fields follow the API version the session was
created with.
The first message is session.ready. Messages arrive in order on one connection. Send a ping at
least every 30 seconds when nothing else is sent; Reflections answers with a pong carrying the
same id. The stream closes when the session ends.
| Message | What to do |
|---|---|
session.ready | Start output. expires_at is when the current token expires. |
session.warning | Act before act_by: renew (renewal_due), submit a sample (sample_overdue), or wrap up (license_ending, max_duration). |
session.terminated | Stop output within one second. Renewals now fail. |
sample.decided | The decision on a sample you sent on the stream, with the ref you sent. A deny is followed by session.terminated. |
error | A message you sent could not be used. The connection stays open. |
Send samples
You can send samples on the stream assample.submit instead of calling
POST /v1/sessions/{session_id}/samples, which saves a
round trip per sample. Send them at least as often as the session’s sampling requires. A sample
sent with POST gets its decision in the response; a sample sent on the stream gets it as
sample.decided.
| Field | Value |
|---|---|
type | sample.submit |
ref | Your reference for the sample, up to 64 characters. sample.decided echoes it. |
kind | audio, transcript or frame |
window | start_ms and end_ms: where the sample falls, in milliseconds from the session’s start |
data | For audio or frame: the bytes, base64, up to 2 MB |
media_type | audio/wav or audio/ogg for audio, image/jpeg for a frame |
text | For transcript: the words of one turn, up to 8,000 characters |
{
"type": "sample.submit",
"ref": "turn-42",
"kind": "transcript",
"window": { "start_ms": 81200, "end_ms": 84600 },
"text": "Welcome back to the Ridge Runners podcast."
}
{
"type": "sample.decided",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"ref": "turn-42",
"sample": "smp_4r6s9t0wxyhk5zba2q8v1m3n7p",
"decision": "allow",
"reasons": []
}
error was not received. It does not count toward sampling, so send a
corrected one before the next act_by:
{
"type": "error",
"ref": "turn-43",
"code": "parameter_invalid",
"detail": "`window.end_ms` must be greater than `window.start_ms`.",
"param": "window.end_ms"
}
code is one of invalid_request (text that is not JSON, or an unknown type),
parameter_missing, parameter_invalid, unsupported_media_type, media_unreadable and
payload_too_large. detail is for you, not your user.
Stop within one second
Whensession.terminated arrives, stop generating and stop playing what you already generated,
within one second. Do not wait for the current sentence or frame to finish. Then end the session on
your side and report its usage up to at. The
session.terminated webhook reports the same
termination, usually later, in data.object.termination; act on whichever arrives first.
Close codes
A request that is not a WebSocket upgrade gets426 Upgrade Required, and one that does not offer
tlp.session.v1 gets 400 invalid_request, each with a problem details
body. Every other failure happens after the upgrade, so a browser can read it too: Reflections
closes the connection with a code and a reason, without sending session.ready.
| Code | Reason | When | What to do |
|---|---|---|---|
1000 | session_ended | The session ended. | Do not reconnect. |
1000 | session_terminated | Reflections terminated the session, after session.terminated. | Stop output. Do not reconnect. |
1001 | going_away | Reflections is moving the connection to another server. | Reconnect at once with the current token. |
1003 | binary_not_supported | You sent a binary frame. | Send samples as JSON, with media in base64 data. |
1011 | internal_error | An error on Reflections’ side. | Read the session, then reconnect after 1 second, doubling the wait each time. |
1013 | service_unavailable | Reflections cannot take the connection now. | The same as 1011. |
4401 | authentication_required | No token and no secret key. | Offer the token, or send the key. |
4401 | token_invalid | The token is malformed, has expired or names another session. | Reconnect with the token from your latest renewal. |
4401 | invalid_api_key | The secret key is not valid. | Use a valid key. |
4403 | insufficient_scope | A restricted key without sessions:write. | Use a key with that scope. |
4404 | resource_not_found | No session has this ID in the key’s account and mode. | Check the ID and the key’s mode. |
4408 | ping_timeout | Nothing arrived from you for 60 seconds. | Reconnect, and send a ping every 30 seconds when idle. |
4409 | session_not_active | The session was not active when you connected. | Read the session and stop output. |
4429 | rate_limited | The session already has 4 open stream connections. | Close a connection you no longer use. |
44xx codes are the HTTP status plus 4000, and their reasons are the
error codes the same failure gets over REST.
Reconnect
If the connection drops with any code other than1000, reconnect with the session’s current
token, the one from your last renewal. Before you reconnect, read the session with
GET /v1/sessions/{session_id}: if status is
terminated or ended, a session.terminated you missed is recorded in termination, and you
stop output instead of reconnecting. If you cannot read the session, stop output no later than
when the current token expires; the example stops at once. Renewal is the second safeguard: when
a renewal fails with session_not_active, stop output
at once.
Example
import WebSocket from "ws";
function followSession(session: { id: string; stream_url: string }, currentToken: () => string, stopOutput: () => void) {
const ws = new WebSocket(session.stream_url, ["tlp.session.v1", `tlp.session.${currentToken()}`]);
let pings: NodeJS.Timeout | undefined;
ws.on("open", () => {
pings = setInterval(() => ws.send(JSON.stringify({ type: "ping", id: String(Date.now()) })), 25_000);
});
ws.on("message", (raw) => {
const message = JSON.parse(raw.toString());
switch (message.type) {
case "session.terminated":
stopOutput(); // Within one second.
ws.close();
break;
case "session.warning":
console.warn(`${message.code}: act by ${message.act_by}`);
break;
case "sample.decided":
if (message.decision === "deny") stopOutput(); // session.terminated follows
break;
}
});
ws.on("error", (err) => console.error("session stream:", err.message)); // "close" follows
ws.on("close", async (code) => {
clearInterval(pings);
if (code === 1000) return stopOutput(); // The session ended or was terminated.
try {
const res = await fetch(`https://app.reflections.ai/v1/sessions/${session.id}`, {
headers: {
Authorization: `Bearer ${process.env.REFLECTIONS_KEY}`,
"Reflections-Version": "2026-10-15",
},
});
if (!res.ok) throw new Error(`Retrieve session failed: ${res.status}`);
const current = await res.json();
if (current.status === "active") {
setTimeout(() => followSession(session, currentToken, stopOutput), 1_000);
return;
}
} catch (err) {
console.error(err);
}
stopOutput(); // Ended, terminated or unknown.
});
}
import asyncio, json, os, time
import requests
import websockets
async def follow_session(session, current_token, stop_output):
while True:
try:
async with websockets.connect(
session["stream_url"], subprotocols=["tlp.session.v1", f"tlp.session.{current_token()}"]
) as ws:
pinger = asyncio.create_task(send_pings(ws))
try:
async for raw in ws:
message = json.loads(raw)
if message["type"] == "session.terminated":
stop_output() # Within one second.
return
if message["type"] == "session.warning":
print(f"{message['code']}: act by {message['act_by']}")
if message["type"] == "sample.decided" and message["decision"] == "deny":
stop_output() # session.terminated follows
finally:
pinger.cancel()
except websockets.ConnectionClosed as closed:
if closed.rcvd is not None and closed.rcvd.code == 1000:
stop_output() # The session ended or was terminated.
return
except (websockets.InvalidHandshake, OSError):
pass # Read the session before reconnecting.
try:
res = await asyncio.to_thread(
requests.get,
f"https://app.reflections.ai/v1/sessions/{session['id']}",
headers={
"Authorization": f"Bearer {os.environ['REFLECTIONS_KEY']}",
"Reflections-Version": "2026-10-15",
},
timeout=5,
)
res.raise_for_status()
active = res.json()["status"] == "active"
except (requests.RequestException, ValueError, KeyError):
active = False
if not active:
stop_output() # Ended, terminated or unknown.
return
await asyncio.sleep(1)
async def send_pings(ws):
while True:
await asyncio.sleep(25)
await ws.send(json.dumps({"type": "ping", "id": str(int(time.time()))}))
Related
Real-time sessions
Session token
{
"type": "session.ready",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"status": "active",
"expires_at": "2026-10-17T18:05:00Z"
}{
"type": "session.warning",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"code": "sample_overdue",
"act_by": "2026-10-17T18:02:15Z"
}{
"type": "session.terminated",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"reason": "license_revoked",
"sentence": "Maya Okafor revoked this license.",
"revocation": "rvk_s9t0wxyhk5zba2q8v1m3n7p4r6",
"sample": null,
"at": "2026-10-17T18:03:41Z"
}{
"type": "sample.decided",
"session": "ses_q8v1m3n7p4r6s9t0wxyhk5zba2",
"ref": "turn-42",
"sample": "smp_4r6s9t0wxyhk5zba2q8v1m3n7p",
"decision": "allow",
"reasons": []
}{
"type": "error",
"ref": "turn-43",
"code": "parameter_invalid",
"detail": "`window.end_ms` must be greater than `window.start_ms`.",
"param": "window.end_ms"
}{
"type": "pong",
"id": "1"
}{
"type": "sample.submit",
"ref": "turn-42",
"kind": "transcript",
"window": {
"start_ms": 81200,
"end_ms": 84600
},
"text": "Welcome back to the Ridge Runners podcast."
}{
"type": "ping",
"id": "1"
}The session ID, ses_….
tlp.session.<token> with the session's current token, offered with tlp.session.v1. Use this from a client.
Your secret key. Use this only from a server.
GET
From a client: tlp.session.v1, tlp.session.<token>. From a server: tlp.session.v1.
From a server only: Bearer and your secret key.
Sent once, when the connection is authenticated.
Something will end the session unless you act.
Stop output within one second. Renewals now fail.
The decision on a sample you sent on the stream. A deny is followed by session.terminated.
A message you sent could not be used. The connection stays open.
The answer to a ping.
A sample of what the session produced, with the same fields as the REST call.
Keeps the connection open. Send one at least every 30 seconds when nothing else is sent.