Live streams

Watch tickets

Let your users watch relay streams without Kymoscope accounts and without exposing your API key.

The flow

  1. The browser asks your backend for a ticket for a stream it may watch (your own authorisation).
  2. Your backend calls stream-watch-ticket with the auth part of your Kymoscope API key.
  3. Kymoscope returns a ticket valid for two minutes, for watching that stream only, plus the stream descriptor. Tickets can be minted for your own streams, public streams and streams of your organisation, never for another account’s private streams.
  4. The browser connects with the ticket and decrypts with the publisher’s API key or the stream password. In connect, pass either one. Only pass the API key to browsers you trust, since it can also publish.

Backend endpoint

A key has two parts, kymoscope_<auth>.<secret>. Send only the auth part (everything before the .) in x-api-key: the secret part never leaves your machine, and Kymoscope stores only a hash of the auth part.

HTTP
POST https://clahbbkqdskkhymymoge.supabase.co/functions/v1/stream-watch-ticket
x-api-key: kymoscope_<auth>           # the part of the API key before the "." only
content-type: application/json

{"streamId": "7b0c1f7e-2d39-4c6e-9d0a-3f1f5a9f2c11"}
200 OK
{
  "ticket": "wt.eyJzaWQiOi...",
  "expiresIn": 120,
  "stream": { "id": "7b0c…", "name": "Rig A", "salt": "q1R…", "key_check": { "iv": "…", "ciphertext": "…" } }
}

Error codes are listed in Relay and backend API.

Browser side

TypeScript
async function ticketFor(streamId: string) {
  const r = await fetch('/api/kymoscope-ticket', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ streamId }),
  })
  if (!r.ok) return null
  return r.json() as Promise<{ ticket: string; stream: LiveStreamDescriptor }>
}

const engine = createEngine({
  live: {
    relay: {
      url: 'wss://live.example.com',          // the relay URL you were given
      getToken: async (streamId) => (await ticketFor(streamId))?.ticket ?? null,
    },
  },
})

const first = await ticketFor(streamId)
try {
  await engine.live.connect(first!.stream, passwordFromUser)
} catch (e) {
  if (e instanceof KymoscopeError && e.code === 'wrong_password') askAgain()
  else throw e
}

// Later
engine.live.disconnect(streamId)

connect takes the stream password or the API key that publishes it, derives the key and checks it against the stream’s key check before opening the socket: a wrong password rejects with wrong_password, and a missing relay configuration with relay_unavailable. Set or change the relay later with engine.setOptions({ live: { relay } }). The connection then shows in state.live with its status and is plotted.

getToken runs on every connect and reconnect, and again shortly before the current ticket expires: the SDK hands the relay the fresh ticket on the open socket, so a long watch never drops. Returning null marks the stream as errored and retries after two seconds.

Operator setup

  1. Generate a long random secret.
  2. Set it as WATCH_TICKET_SECRET on the relay Worker (wrangler secret put WATCH_TICKET_SECRET).
  3. Set the same value as a Supabase edge secret and deploy stream-watch-ticket with verify_jwt = false.
  4. To rotate, move the old value to WATCH_TICKET_SECRET_PREV on the relay, set the new one on both sides, and remove the old one after two minutes.