Live streams
Watch tickets
Let your users watch relay streams without Kymoscope accounts and without exposing your API key.
The flow
- The browser asks your backend for a ticket for a stream it may watch (your own authorisation).
- Your backend calls
stream-watch-ticketwith the auth part of your Kymoscope API key. - 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.
- 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.
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"}{
"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
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
- Generate a long random secret.
- Set it as
WATCH_TICKET_SECRETon the relay Worker (wrangler secret put WATCH_TICKET_SECRET). - Set the same value as a Supabase edge secret and deploy
stream-watch-ticketwithverify_jwt = false. - To rotate, move the old value to
WATCH_TICKET_SECRET_PREVon the relay, set the new one on both sides, and remove the old one after two minutes.