Subscribe to authorized live state with protocol v2. Delivery is at least once, so resilient clients deduplicate events and resume from their last applied cursor.
ts-api/src/http/websocket.ts and ts-api/src/http/routes/realtime.ts on 2026-08-13.POST /v1/realtime/session.wss://api.dyva.ai/v1/ws without credentials in the URL.hello frame containing the ticket.ready, then subscribe to the topics your principal is allowed to read.1. Mint a ticket
curl -X POST https://api.dyva.ai/v1/realtime/session \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"2. First socket frame
{
"type": "hello",
"protocol": 2,
"token": "<short-lived realtime ticket>"
}A successful hello returns the server cursor, heartbeat cadence, principal, and default subscriptions. Only subscribe after this frame. Resource topics still pass database authorization even when the ticket is valid.
Server ready
{
"type": "ready",
"protocol": 2,
"connection_id": "018f3f5a-6c91-7ee1-83d1-469a7f456b10",
"principal": {
"kind": "user",
"id": "user_abc123"
},
"topics": [
"public:platform",
"user:user_abc123",
"session:session_abc123"
],
"cursor": "1722190000000-0",
"heartbeat_ms": 25000,
"server_time": "2026-07-30T12:00:00.000Z"
}Client subscribe with replay
{
"type": "subscribe",
"topics": [
"conversation:conv_abc123"
],
"cursor": "1722190000000-0",
"limit": 200
}Controls use top-level fields. Send domain writes, including new messages, through the corresponding REST endpoint; the socket carries subscriptions, presence, delivery, and recovery.
subscribeRequest one or more authorized topics. Include a cursor to replay missed durable events.
{ "topics": ["conversation:..."], "cursor"?: "...", "limit"?: 200 }unsubscribeRemove requested topics that are not connection defaults.
{ "topics": ["conversation:..."] }resumeReplay all current subscriptions after the last fully applied cursor.
{ "cursor": "...", "limit"?: 200 }ackTell this socket which cursor was applied. Persist the cursor in your own client too.
{ "cursor": "..." }typingPublish ephemeral typing presence to an authorized, already-subscribed resource topic.
{ "topic": "conversation:...", "active": true }pingRenew presence and receive the current stream head cursor.
{}Control frames use type. Domain events use the versioned envelope below and identify their schema with name.
readyAuthentication succeeded. Includes the principal, default topics, cursor, and heartbeat cadence.
subscribedReports accepted and rejected topics. A credential never bypasses topic authorization.
unsubscribedReports the topics removed from this connection.
replay_startedA bounded replay is beginning for accepted topics.
replay_completeReplay finished. Resume again from its cursor when truncated is true.
resync_requiredThe cursor cannot be replayed safely. Fetch an authoritative REST snapshot before advancing.
pongHeartbeat response with the current stream head cursor.
errorA named protocol or rate-limit error scoped to this connection.
Durable delivery is at least once. Validate the event version, deduplicate by event_id, respect aggregate revisions when present, and persist the cursor only after applying or intentionally ignoring the event.
{
"protocol": 2,
"event_id": "5ca8eb59-4d89-4acc-9242-3d7ce7f77b91",
"name": "conversation.updated",
"version": 1,
"cursor": "1722190000000-1",
"topics": [
"conversation:conv_abc123"
],
"occurred_at": "2026-07-30T12:00:00.000Z",
"produced_at": "2026-07-30T12:00:00.050Z",
"actor": {
"user_id": "user_abc123"
},
"aggregate": {
"kind": "conversation",
"id": "conv_abc123",
"revision": 42
},
"data": {
"conversation_id": "conv_abc123"
},
"trace_id": "trace_abc123"
}Store the last fully applied cursor in your client. On reconnect, include it in subscribe or send resume after subscriptions are restored.
Replay returns replay_started, zero or more envelopes marked as replayed, then replay_complete. When truncated is true, resume again from the returned cursor.
On resync_required, fetch an authoritative REST snapshot for the affected topics. Do not advance the cursor while retaining stale local state.
Resume
{
"type": "resume",
"cursor": "1722190000000-0",
"limit": 200
}Acknowledge
{
"type": "ack",
"cursor": "1722190000000-0"
}A protocol-v2 hello may omit the token for anonymous, read-only access to allowed public:* collection topics. Anonymous sockets cannot subscribe to resource topics, publish typing, or use replay. Mint a guest ticket when a logged-out browser needs an authorized resource or replay.
{
"type": "hello",
"protocol": 2
}Typing is ephemeral and requires an authenticated user, an existing subscription, and separate write authorization for the topic. Send a stop frame when possible and expire remote typing locally if a stop event is lost.
{
"type": "typing",
"topic": "conversation:conv_abc123",
"active": true
}{
"type": "typing",
"topic": "conversation:conv_abc123",
"active": false
}4401The credential, session, or API key expired or was revoked.
Mint a new ticket, reconnect, then resume from the last applied cursor.
4429A rate limit or slow-consumer threshold was reached.
Back off with jitter and reduce inbound or outbound pressure.
1012The gateway lost its realtime subscriber and closed fail-safe.
Reconnect and resume after a short backoff.
1013The gateway is still synchronizing and cannot authenticate safely.
Retry with exponential backoff and jitter.
This example mints a fresh ticket for each connection, waits for ready, restores a subscription, deduplicates at-least-once events, acknowledges applied cursors, and reconnects with backoff.
const API = "https://api.dyva.ai/v1/realtime/session";
const SOCKET = "wss://api.dyva.ai/v1/ws";
const topic = "conversation:conv_abc123";
const seenEventIds = new Set();
let lastCursor = sessionStorage.getItem("dyva:realtime:cursor");
let socket;
let reconnectAttempt = 0;
let stopped = false;
async function mintTicket(accessToken) {
const response = await fetch(API, {
method: "POST",
headers: accessToken
? { Authorization: `Bearer ${accessToken}` }
: undefined,
});
if (!response.ok) {
throw new Error(`Realtime session failed (${response.status})`);
}
return response.json();
}
async function applyEvent(event) {
// Validate event.name, event.version, and event.data for your domain.
console.log(event.name, event.data);
}
async function fetchAuthoritativeSnapshot(topics) {
// Fetch the REST snapshot for each affected topic before advancing.
console.log("Snapshot required for", topics);
}
async function connect(accessToken) {
const session = await mintTicket(accessToken);
socket = new WebSocket(SOCKET);
socket.addEventListener("open", () => {
socket.send(JSON.stringify({
type: "hello",
protocol: 2,
token: session.token,
}));
});
socket.addEventListener("message", async ({ data }) => {
const message = JSON.parse(data);
if (message.type === "ready") {
reconnectAttempt = 0;
socket.send(JSON.stringify({
type: "subscribe",
topics: [topic],
...(lastCursor ? { cursor: lastCursor, limit: 200 } : {}),
}));
return;
}
if (message.type === "resync_required") {
await fetchAuthoritativeSnapshot(message.topics ?? [topic]);
lastCursor = message.cursor ?? null;
if (lastCursor) {
sessionStorage.setItem("dyva:realtime:cursor", lastCursor);
}
return;
}
if (message.type === "replay_complete" && message.truncated) {
socket.send(JSON.stringify({
type: "resume",
cursor: message.cursor,
limit: 200,
}));
return;
}
if (message.protocol === 2 && message.event_id) {
if (seenEventIds.has(message.event_id)) return;
await applyEvent(message);
seenEventIds.add(message.event_id);
if (message.cursor) {
lastCursor = message.cursor;
sessionStorage.setItem("dyva:realtime:cursor", lastCursor);
socket.send(JSON.stringify({ type: "ack", cursor: lastCursor }));
}
}
});
socket.addEventListener("close", () => {
if (stopped) return;
const delay = Math.min(1_000 * 2 ** reconnectAttempt, 30_000);
reconnectAttempt += 1;
setTimeout(() => void connect(accessToken), delay + Math.random() * 250);
});
}
function disconnect() {
stopped = true;
socket?.close(1000, "Client closed");
}
void connect("YOUR_ACCESS_TOKEN");