CUBSTER Join the waitlist
DOCS / REALTIME

Realtime

Cubster is the front door for a realtime voice session: it mints a short-lived connect token, a separate broker relays the audio and holds the vendor conversation, and when the call ends the broker posts the finished transcript back here. Cubster never touches the audio itself — no upload, no storage, no vendor key.

How the pieces fit

Three parties, three jobs: Cubster mints the session token and stores the transcript at the end; the broker verifies that token, opens the WebSocket, relays audio frames to the voice vendor, and captures the transcript; the client just asks Cubster for a session and then talks to the broker directly. Cubster is on the request path only at the start (mint) and the end (transcript) — the audio itself never crosses it.

POST /api/v1/realtime/sessions

API key only — the key must include the realtime service (see Authentication); a session credential can't mint one. Optional JSON body: name — a display name for the resulting transcript, a string of at most 255 characters, defaulting to "Talk <ISO minute>" (e.g. Talk 2026-08-28T15:03). Returns 201.

$ curl -X POST https://app.cubster.dev/api/v1/realtime/sessions \
  -H "Authorization: Bearer $CUBSTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "Standup check-in"}'
201 response
{
  "sessionId": "8c1a…-uuid",
  "endpoint": "wss://realtime.example.dev/v1/session",
  "token": "<base64url payload>.<base64url Ed25519 signature>",
  "expiresAt": "2026-08-28T15:04:05.000Z"
}

Hand endpoint and token straight to the client that opens the WebSocket — it sends the token as Authorization: Bearer <token> on the upgrade request to endpoint. What happens on the WebSocket after that — audio framing, control events, close codes — is the broker's own wire contract, not Cubster's.

The connect token

base64url(JSON payload) + "." + base64url(Ed25519 signature over the payload segment). Cubster signs with a private key only it holds (REALTIME_SIGNING_KEY); the broker verifies with the matching public key and cannot mint tokens itself. It authorizes exactly one WebSocket connect and expires 60 seconds after it's minted; nothing in it is secret, the signature is what matters.

token payload
{ "v": 1, "sessionId": "uuid", "workspaceId": "uuid",
  "profileId": "uuid", "name": "Talk 2026-08-28T15:03",
  "iat": 1756393385, "exp": 1756393445 }
401 Missing or invalid API key.
403 The key doesn't include the 'realtime' service (standard forbiddenService body), or the request used a session credential instead of a key.
400 name is longer than 255 characters.
503 Realtime is not configured (REALTIME_ENDPOINT / REALTIME_SIGNING_KEY / REALTIME_BROKER_SECRET unset).
POST /api/v1/realtime/sessions/:id/transcript

Broker-only. Auth is Authorization: Bearer <REALTIME_BROKER_SECRET> — a plain shared secret that gates this route only (it proves nothing about identity), compared timing-safe, never an API key and never logged. An API key presented here is simply rejected, the same as any wrong bearer value. :id must be a UUID. Identity — workspace, profile, display name — is not read from the body; it comes from token, the same signed session token minted by POST /api/v1/realtime/sessions, which the broker echoes back here. The token's 60-second exp is not enforced on this route (a session can run for minutes), but its iat must be no more than 24 hours old, and its sessionId must match :id.

live.json
{
  "token": "<the session token from POST /api/v1/realtime/sessions>",
  "provider": { "name": "openai", "model": "gpt-realtime-2.1-mini" },
  "language": "en", "durationSeconds": 143.2,
  "status": "completed", "error": null,
  "text": "Hi there. Hello! How can I help?",
  "segments": [
    { "speaker": 0, "start": 0.4, "end": 1.1, "text": "Hi there.", "confidence": null },
    { "speaker": 1, "start": 1.6, "end": 3.0, "text": "Hello! How can I help?", "confidence": null }
  ]
}

text is optional — when omitted it's derived by joining segments[].text with a space. status is completed or failed (a session that died before any turn is still stored — an empty failed transcript is a useful trace). A second POST for the same session id is rejected as a duplicate.

401 Missing/wrong bearer secret, or token missing, malformed, tampered, or older than 24 hours.
403 The token's session id doesn't match :id.
400 Body failed validation — the message names the field.
404 :id isn't a UUID, or the token's workspace doesn't exist.
409 A transcript for this session id already exists.
503 Realtime is not configured.
The live transcript

A successful post to the transcript endpoint returns 201 with the same v1 Transcript JSON shape (documented on the Transcription page) that a batch upload produces, with these differences: no audio asset, so assetId is null and every source.* field but kind/durationSeconds is null too; words is always []; and realtimeSessionId is present (it's absent on an uploaded transcript).

201 response
{
  "id": "uuid", "workspaceId": "uuid", "assetId": null,
  "displayName": "Talk 2026-08-28T15:03", "version": 1, "status": "completed", "error": null,
  "language": "en", "diarize": true,
  "source": { "kind": "live", "filename": null, "contentType": null,
    "size": null, "durationSeconds": 143.2, "url": null },
  "provider": { "name": "openai", "model": "gpt-realtime-2.1-mini", "requestId": null },
  "realtimeSessionId": "uuid",
  "text": "…", "speakerCount": 2,
  "segments": [ … ], "words": [],
  "createdAt": "ISO", "updatedAt": "ISO"
}

It shows up in GET /api/v1/transcripts and GET /api/v1/transcripts/:id exactly like an uploaded one — there's no separate realtime listing endpoint. On the dashboard, a live transcript shows a LIVE tag instead of the audio player.

Errors summary

Sessions route: 401 / 403 (standard forbiddenService body) / 503. Transcript ingest route: 401 bad secret / 400 body / 404 id or workspace / 409 duplicate / 503.

SDK

client.realtime.createSession() mints the token — key-only, same as uploads.createGrant(), throwing a client-side CubsterError (no request sent) if the client wasn't configured with an API key.

const session = await cubster.realtime.createSession({ name: 'Standup check-in' })
// { sessionId, endpoint, token, expiresAt }
// hand session.endpoint + session.token to the broker/client that opens the WebSocket

When the transcript comes back, it reads through the same transcripts.list/transcripts.get calls as an uploaded one. Gate any UI that shows an <audio> player on source.url being non-null rather than assuming source.kind === "upload".