Longer, end-to-end walkthroughs that combine the control plane and data plane
into realistic flows. Each assumes a control-plane Streams client constructed
with baseUrl/apiKey/orgId.
Recipe 1 — Go live from the browser, watch with a signed URL
A complete “create input → publish a webcam → watch” loop, entirely in the
browser, using the SDK’s MoQT publisher and viewer.
Create a live input
import { Streams } from "@clutchcall/sdk/streams";
const streams = new Streams({
baseUrl: "https://app.clutchcall.dev",
apiKey: process.env.CLUTCHCALL_CREDENTIALS!,
orgId: "org_abc",
});
const { input, streamKey } = await streams.liveInputs.create({
name: "Live Demo", ingest: "fmp4",
});
// Persist streamKey now — it is never returned again.
Publish a webcam over MoQT
const dataPlane = new Streams({ relayUrl: "https://relay.clutchcall.dev" });
const pub = dataPlane.publisher({ timesliceMs: 1000 });
const cam = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
await pub.publish(
input.external_input_id,
streamKey,
cam,
`stream/org_abc/${input.external_input_id}`,
);
Mint a signed playback URL for viewers
const { url } = await input.signedPlaybackUrl({ ttlSeconds: 3600 });
// Hand `url` (or just the input id + a token) to each viewer.
Play it into a <video>
const viewerStreams = new Streams({ apiBase: "", relayUrl: "https://relay.clutchcall.dev" });
const viewer = viewerStreams.viewer({
onStarted: () => console.log("live"),
onError: (e) => console.error("playback error", e),
});
viewer.attach(document.querySelector("video")!);
await viewer.play(input.external_input_id); // lv_… → live over MoQT
Tear down
pub.stop();
viewer.stop();
On a warm same-region path this loop is sub-second glass-to-glass. Across an
internet RTT, latency is roughly one RTT plus one MoQT group time — so keep the
publisher’s timesliceMs short (250–500 ms) for tighter latency at the cost of
slightly higher overhead.
Recipe 2 — RTMP event to thousands of viewers
A hardware/OBS encoder contributes over RTMP; the engine transcodes and fans the
stream out to every viewer over MoQT. The control plane mints per-viewer signed
URLs.
Provision the input and rotate a fresh key
const { input } = await streams.liveInputs.create({
name: "Keynote 2026", ingest: "rtmp",
});
const { streamKey } = await input.rotateStreamKey(); // fresh key for the event
Point the encoder at the RTMP endpoint
Server: rtmp://ingest.clutchcall.dev/live/<external_input_id>
Stream key: <streamKey>
The engine ingests, transcodes to multiple ABR renditions, and publishes the
catalog in-band on the .catalog track.Issue a signed URL per viewer (server side)
// In your app server, behind your own auth:
async function playbackForViewer(): Promise<string> {
const inp = await streams.liveInputs.get({ id: "li_keynote" });
const { url } = await inp.signedPlaybackUrl({ ttlSeconds: 900 });
return url; // 15-minute token; client re-fetches before expiry
}
Each browser plays the URL
const dataPlane = new Streams({ apiBase: "", token: tokenFromUrl });
const viewer = dataPlane.viewer();
viewer.attach(videoEl);
await viewer.play("lv_keynote");
Late joiners start at the most recent group boundary (keyframe); the relay
drops by priority under loss rather than blocking the stream.
Because the relay mesh fans out each group, the encoder uploads once no
matter how many viewers attach — fan-out cost lives at the edge POPs, not the
contribution link.
Recipe 3 — TUS2 upload to VOD playback
Upload a recorded file resumably, let the engine package it to CMAF, then play
the resulting asset from the same viewer the live path uses.
Start a resumable upload
// Preview surface: streams.assets.* — until the typed helper ships, drive
// the TUS2 endpoint with the same bearer key.
const { uploadUrl, assetId } = await streams.assets.createUpload({
name: "Session-Recording.mp4",
});
Upload in resumable chunks
async function tusUpload(uploadUrl: string, bytes: Uint8Array, chunk = 8 << 20) {
// On reconnect, HEAD uploadUrl to read Upload-Offset and resume there.
let offset = Number(
(await fetch(uploadUrl, { method: "HEAD", headers: { "tus-resumable": "2.0.0" } }))
.headers.get("upload-offset") ?? 0,
);
while (offset < bytes.byteLength) {
const end = Math.min(offset + chunk, bytes.byteLength);
const res = await fetch(uploadUrl, {
method: "PATCH",
headers: {
"tus-resumable": "2.0.0",
"upload-offset": String(offset),
"content-type": "application/offset+octet-stream",
},
body: bytes.slice(offset, end),
});
offset = Number(res.headers.get("upload-offset") ?? end);
}
}
Wait for packaging, then play the asset
On completion the engine packages the file to CMAF/fMP4 and assigns a
playback id (pb_…). The same viewer plays VOD over HTTPS from the catalog:const dataPlane = new Streams({ apiBase: "" });
const viewer = dataPlane.viewer();
viewer.attach(videoEl);
await viewer.play("pb_def456"); // pb_… → VOD: catalog.json + CMAF over HTTPS
VOD reuses the live stream/<org>/… namespace and the same catalog format, so a
single player component handles both live (lv_…) and on-demand (pb_…) ids —
the only difference is the delivery framing (MoQT vs HTTPS), which the viewer
picks by id prefix.
Recipe 4 — Headless capture of a live stream (Python)
Record a live broadcast to disk server-side, with signing-key rotation handled
ahead of token expiry.
import os, time
from clutchcall.streams import Streams, BroadcastViewer
streams = Streams(
base_url="https://app.clutchcall.dev",
api_key=os.environ["CLUTCHCALL_CREDENTIALS"],
org_id="org_abc",
)
inp = streams.live_inputs.get(id="li_xyz")
ticket = inp.signed_playback_url(ttl_seconds=3600)
out = open("broadcast.mp4", "wb")
def on_chunk(is_init, chunk):
# first chunk is the CMAF init segment; the rest are media segments
out.write(chunk.data)
def on_close(reason, detail):
out.close()
print("capture closed:", reason, detail)
viewer = BroadcastViewer.open(ticket.url, on_chunk=on_chunk, on_close=on_close)
# Run until the broadcast ends or you stop it.
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
viewer.close()
A bad or expired playback JWT closes the session with auth_failed (surfaced
through on_close). For captures longer than the token TTL, re-mint with
inp.signed_playback_url(...) and re-open the viewer before expires_at.