restream-sdk
v0.15.0
Published
Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers, collaborative split-screen) with zero client backend.
Maintainers
Readme
restream-sdk
Add multi-platform restreaming to your app. Your streamers connect their Twitch / Kick / YouTube accounts once, and you fan an already-live stream out to all of them — with live chat and viewer counts aggregated back into your UI.
Your existing live stream ──► Twitch
(one session) ──► Kick + merged chat & viewer counts back to your UI
──► YouTube …- ⚡️ Zero backend. Drop in a publishable key and go. (An optional one-route token mode adds hard per-user security — recommended for production.)
- 🎛 Headless. React hooks only — you build the UI with your own components.
- 🔌 You already have the stream. This is a control plane. You hand it the
publisherSessionIdfrom your existing stream and it manages the restream. It does not capture or publish media. - 🔑 No OAuth setup. Connecting social accounts is fully hosted — your streamers click "Connect Twitch"; you register nothing on any platform and configure no webhooks.
Mental model. You're already streaming a session (e.g. via Cloudflare Realtime). This SDK is the thin layer that lets a streamer link their social accounts and mirror that session to them, controlled entirely from your frontend. The heavy lifting (OAuth, tokens, transcoding, RTMP fan-out, chat collection) lives server-side.
Table of contents
- Install
- The integration contract (read this first)
- What you get from your operator
- Quickstart
- The full streamer lifecycle
- Where
publisherSessionIdcomes from - The
userIdcontract & persistence - Connecting accounts
- Going live
- Screen share & live layout
- Collaboration (split-screen)
- Live chat & viewers
- Banners / overlays
- Auth modes: publishable key vs. token
- Output resolution & quality
- API reference
- Data shapes
- Scopes
- Next.js / SSR notes
- Error handling
- Troubleshooting
- Integration checklist
Install
npm install restream-sdk
# peer dependency: react >= 17 (optional — a framework-agnostic core ships too)It's a normal build-time dependency bundled into your app. Your end users install nothing.
The integration contract (read this first)
To go from zero to live you provide exactly four things. Everything else is hosted.
| # | You provide | Where it comes from | Notes |
|---|---|---|---|
| 1 | apiBase | Your operator | e.g. https://restream-api.fomo.gg |
| 2 | A credential | Your operator | A publishableKey (pk_live_…) for zero-backend, or a tokenEndpoint for hardened mode |
| 3 | A stable userId | Your user system | The streamer's id in your database. Must be stable + unique — connections and jobs are keyed by it server-side. See the contract. |
| 4 | A publisherSessionId | Your existing stream | The live media session you already produce, passed into goLive(). See below. |
You do not register OAuth apps, host OAuth callbacks, configure platform webhooks, or handle any platform tokens. That's all server-side.
The golden rule: keep ONE publisher session for the whole stream. Publish the camera once and reuse that
publisherSessionId. Never re-publish (a new session) or callgoLive()again on reconnect, screen toggle, or collaborator change — those are live changes: usesetStreamState(),startScreenShare(), andacceptCollab().goLive()throws if a stream is already live; callstopLive()to cleanly end one before starting another.
What you get from your operator
Ask whoever runs the Restream Service for:
- API base URL — e.g.
https://restream-api.fomo.gg. - A publishable key —
pk_live_…, created in Admin → Clients → Keys, with:- Scopes:
connect,restream,read,chat:write(see Scopes). - Allowed origins: every origin your app runs on, e.g.
https://app.fomo.gg,http://localhost:3000. The key is origin-locked — it only works from those origins, which is what makes it safe to ship in your frontend.
- Scopes:
- (Production) The secret API key — server-side only, for token mode. Never put this in the browser.
Quickstart (zero backend)
"use client";
import { useRestream } from "restream-sdk";
export default function RestreamPanel({ streamerId, publisherSessionId }) {
const {
connections, connect, disconnect,
goLive, stopLive, job,
viewers, comments, sendChat,
} = useRestream({
apiBase: "https://restream-api.fomo.gg",
publishableKey: "pk_live_xxx",
userId: streamerId, // ← your stable id for this streamer
});
const isConnected = (p) => connections.some((c) => c.platform === p);
return (
<div>
{/* 1 — link social accounts (hosted OAuth popup) */}
{["twitch", "kick"].map((p) =>
isConnected(p)
? <button key={p} onClick={() => disconnect(p)}>Disconnect {p}</button>
: <button key={p} onClick={() => connect(p)}>Connect {p}</button>
)}
{/* 2 — mirror your existing session to the connected platforms */}
{!job
? <button onClick={() => goLive({ publisherSessionId, destinations: ["twitch", "kick"] })}>
Go live
</button>
: <button onClick={stopLive}>Stop ({job.status})</button>}
{/* 3 — merged viewers + chat (both directions) */}
<p>Viewers: {viewers?.total ?? 0}</p>
<ul>{comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
<button onClick={() => sendChat("hello everyone 👋")}>Say hi to all chats</button>
</div>
);
}The full streamer lifecycle
A complete, production-shaped component covering the whole journey — link accounts, go live with your session, watch links, viewers, two-way chat, stop:
"use client";
import { useRestream } from "restream-sdk";
import { useState } from "react";
const PLATFORMS = ["twitch", "kick"];
export default function Studio({ streamerId, getPublisherSessionId }) {
const r = useRestream({
apiBase: "https://restream-api.fomo.gg",
publishableKey: process.env.NEXT_PUBLIC_RESTREAM_PK,
userId: streamerId, // stable id from your user table
});
const [msg, setMsg] = useState("");
const isConnected = (p) => r.connections.some((c) => c.platform === p);
const live = r.job && !["stopped", "failed", "ended"].includes(r.job.status);
async function start() {
// Pull the session id from YOUR existing stream pipeline at go-live time.
const publisherSessionId = await getPublisherSessionId();
await r.goLive({ publisherSessionId, destinations: PLATFORMS.filter(isConnected) });
}
return (
<section>
{/* link accounts — persists across refreshes (keyed by userId) */}
<div>
{PLATFORMS.map((p) => (
<button key={p} onClick={() => isConnected(p) ? r.disconnect(p) : r.connect(p)}>
{isConnected(p) ? `✓ ${p}` : `Connect ${p}`}
</button>
))}
</div>
{/* go live / stop */}
{!live
? <button disabled={!PLATFORMS.some(isConnected)} onClick={start}>Go live</button>
: <button onClick={r.stopLive}>Stop stream</button>}
{r.job && <span> status: {r.job.status}</span>}
{/* watch links (available once broadcasts are created) */}
{(r.job?.broadcasts || []).map((b) => b.watchUrl &&
<a key={b.platform} href={b.watchUrl} target="_blank" rel="noreferrer">Watch on {b.platform} ↗</a>)}
{/* live stats + merged chat */}
{live && (
<>
<p>Viewers: {r.viewers?.total ?? 0}
{r.viewers?.platforms && Object.entries(r.viewers.platforms).map(([p, n]) => ` · ${p}: ${n}`)}</p>
<ul>{r.comments.map((c) => <li key={c.id}><b>{c.platform}/{c.author}:</b> {c.message}</li>)}</ul>
<form onSubmit={(e) => { e.preventDefault(); r.sendChat(msg); setMsg(""); }}>
<input value={msg} onChange={(e) => setMsg(e.target.value)} placeholder="message all chats" />
<button>Send</button>
</form>
</>
)}
{r.error && <p role="alert">{r.error.message}</p>}
</section>
);
}Note
getPublisherSessionId()— you supply the session id from your stream at the moment you go live. The SDK never creates it.
Where publisherSessionId comes from
This is the one piece that ties into your infrastructure, so it's worth being precise.
The publisherSessionId is the live media session your platform already
produces for the streamer. If your platform publishes streamer media to
Cloudflare Realtime (Calls) — the typical setup — the session id Cloudflare
returns when the streamer's broadcast starts is the publisherSessionId. You
already have it; you just pass it in:
// when your streamer goes live on YOUR platform, you already create a session:
const publisherSessionId = "<the Cloudflare Calls session id for this broadcast>";
await goLive({ publisherSessionId, destinations: ["twitch", "kick"] });Requirements:
The session must be live and actively sending media at the moment you call
goLive(). The worker pulls from it immediately.It must carry the tracks you intend to restream (audio + video). If you publish video only (e.g. no mic), pass
trackNames: ["video"]so the worker doesn't wait for an audio track that will never arrive:goLive({ publisherSessionId, destinations: ["twitch"], trackNames: ["video"] });
The SDK is a control plane: it does not open a camera, capture a screen, or
publish RTMP/WebRTC. Producing the session stays entirely on your side, exactly as
it works today — the only change is that your frontend now makes the goLive()
call (instead of nothing happening after the session starts).
The userId contract & persistence
userId is the streamer's id in your system (e.g. your DB primary key). It is
the join key for everything server-side, so:
- ✅ Stable — use the same value for a given streamer forever. Don't use a random/session value.
- ✅ Unique per streamer — one streamer = one
userId. - ✅ Opaque is fine —
"user_8412", a UUID, etc. Avoid PII.
Why it matters — persistence is automatic and server-side:
- When a streamer connects Twitch/Kick, the link is stored server-side, keyed
by
(yourClient, userId, platform). Nothing about it lives in the browser. - On mount (including after a page refresh, in a new tab, or on another device),
the SDK auto-calls
listConnections()and yourconnectionsstate repopulates — the streamer sees "✓ Twitch" again with no re-auth. - Platform tokens are auto-refreshed server-side, so connections stay valid long-term. A streamer only needs to reconnect if they revoke access on the platform itself.
If you pass a different
userIdon the next load, you'll get a different (empty) connection set. A stableuserIdis the whole persistence story.
In token mode the userId is baked into
the token your backend mints — so it's inherently stable per logged-in user and the
browser can't stream as anyone else.
Connecting accounts
await connect("twitch"); // opens a hosted OAuth popup, resolves when linked
await disconnect("kick");
connections; // [{ platform, platformUsername, ... }] — tokens never exposedconnect(platform)opens a popup to the service's hosted OAuth (using the operator's shared platform apps) and resolves on success. Allow popups for your origin.- Supported:
twitch,kick,youtube,facebook,instagram— availability depends on what the operator has enabled. - Connections persist (see above); you don't store anything yourself.
Going live
const job = await goLive({
publisherSessionId, // REQUIRED — your existing live session
destinations: ["twitch", "kick"], // which connected platforms to fan out to
overlay: { htmlBanner: "https://cdn.you.com/banner.html", enabled: true }, // optional
recording: false, // optional
trackNames: ["audio", "video"], // optional — match what your session publishes
});
await stopLive();- One encode → many destinations. Restreaming to 2 platforms or 5 costs the same on the source; it's a single encode fanned out.
- One live stream per streamer.
goLive()throws if a job is already live for this streamer — guarding against accidental double-clicks / re-go-lives (the #1 cause of duplicate + stale jobs). CallstopLive()first to intentionally restart. Screen / collab / layout are live changes — usesetStreamState(),startScreenShare(),acceptCollab(), never a secondgoLive(). participantOnly: truegoes live as a compositing-only participant — a collab guest with no socials: the job runs (pullable into a host's composite) but fans out nowhere. Pass nodestinations.- Watch links. Once broadcasts are created,
job.broadcastscarries{ platform, watchUrl }for each destination — handy for "Watch on Twitch ↗". arm({ destinations })pre-selects destinations so you can firegoLive(with just thepublisherSessionId) the instant your session starts — useful for auto-start when a streamer goes live on your platform.
Screen share & live layout
Screen share is a separate layer on your existing stream — never a re-publish or a new goLive:
await startScreenShare(); // video-only screen layer (solo → screen_camera, collab → collab_screen)
await startScreenShare({ audio: true }); // ALSO capture tab/system audio → mixed into the broadcast (host mic + screen audio)
await stopScreenShare(); // reverts the layout (closes BOTH tracks if audio was published)startScreenShare({ audio: true }) returns { sessionId, trackName, audio } — audio:true means a
screen-audio track was actually published (the user picked a tab/window that exposes audio in their
browser). When audio is true the SDK publishes the screen audio under the track name "screen-audio"
so the backend can route it into the broadcast's audio mixer alongside the host mic. Pass audio:false
(or omit it) to keep the screen video-only (the historical behavior).
Push presence/layout live (debounced, best-effort) with setStreamState:
setStreamState({ screenShare: true, camera: true, collab: false,
cameraPosition: "bottom-right", cameraSize: "small" });Live destination control (enable / disable per site, mid-stream)
You can enable or disable an individual restream site AFTER the stream has gone live, without restarting the broadcast on the OTHER sites. This is a true zero-restart operation: the backend flips a per-destination RTMP valve, so the encoder + RTMP tee + every other destination keep flowing untouched.
const { destinations } = await getDestinations();
// destinations: [{ index:0, platform:"twitch", enabled:true },
// { index:1, platform:"kick", enabled:true }, ...]
// Disable Twitch live (its viewers see the platform's "stream ended" UI; Kick + YouTube keep streaming)
await setDestinationEnabled(0, false);
// Re-enable Twitch live (viewers see the stream reconnect)
await setDestinationEnabled(0, true);Not supported live: connecting a new restream account that wasn't connected at go-live
(adding a fresh RTMP pad to a live GStreamer tee is unreliable). To add a brand-new site, call
stopLive() then goLive() again with the new destination connected. Connecting / disconnecting
OAuth accounts (the social connection) is a separate, non-live operation done via connect() /
disconnect() before / between streams.
Collaboration (split-screen)
Two creators appear side-by-side. Your operator picks the model per client:
Per-creator (default) — each creator keeps their own live stream; the composite is fanned to both creators' own socials:
// A (host, already live) invites B
await requestCollab({ collaboratorUserId, publisherSessionId, destinations: ["twitch", "kick"] });
// B (already live) accepts with their live session
await acceptCollab(inviteId, { publisherSessionId });When a collaborator leaves or drops, each still-live creator automatically reverts to solo — no stream stops. Send setStreamState({ collab: false }) for an immediate clean transition.
Merged (host + challenger) — only the host creates the stream; the challenger joins as a feed, with no job of their own:
// Challenger: publish a camera, then join — NO goLive:
await acceptCollab(inviteId, { publisherSessionId }); // participantOnly guest- A new challenger calling
acceptCollabwhile the host is live swaps the current one out — live, on the host's same session (the host never re-publishes). - Live swap is feed-only: a swapped-in challenger must have no own socials. An audience-carrying swap is refused — end the collab and start fresh.
Realtime — open the collab stream to react to invites/starts/ends live:
studio.on("collab-composite", (e) => enterCollabUI(e.job)); // you're in the composite
studio.on("collab-reassigned", (e) => followJob(e.job)); // your job changed (reverted to solo / swapped out)
studio.on("collab-ended", () => exitCollabUI());
await startCollabRealtime();The SDK auto-adopts the new job.id on collab-composite / collab-reassigned, so your job state follows the right stream automatically.
Live chat & viewers
viewers; // { total, platforms: { twitch: 12, kick: 5 } } — polled ~10s
comments; // merged incoming chat from all destinations (live via SSE)
await sendChat("gg!"); // fan a message OUT to every connected chat
await sendChat("ty", ["kick"]); // …or only some platformscommentsis the merged inbound feed across all destinations, delivered over SSE and de-duplicated (history replays safely on reconnect). It's capped to the most recent 200 messages.- Each comment:
{ id, platform, author, authorAvatar, message, timestamp }. sendChatis outbound fan-out — one message posted to every (or selected) connected chat. Requires thechat:writescope.
Banners / overlays
Pass an overlay to goLive to composite a banner onto the outgoing stream
(rendered + burned in server-side, so it shows on every destination):
goLive({
publisherSessionId,
destinations: ["twitch"],
overlay: { htmlBanner: "https://cdn.you.com/scoreboard.html", enabled: true },
});htmlBanner— URL to a transparent HTML5 page. It can run its own JS/data feed (scoreboards, tickers, live stats); the service renders it headlessly and composites it full-frame every tick.topBanner/bottomBanner— transparent PNG URLs for simple top/bottom bars.- Update PNG bars on a live job without restarting it:
await updateOverlay({ topBanner, bottomBanner, hideTop, hideBottom });
Auth modes: publishable key vs. token
| | Publishable key (pk_live_…) | Token (tokenEndpoint) |
|---|---|---|
| Client backend | None | One small route |
| userId | Passed from the browser | Bound server-side in the token |
| Security | Origin-locked + scoped (good) | Hard per-user binding (best) |
| Best for | Quick start, demos, trusted UIs | Production — anywhere the browser shouldn't pick which user it streams as |
The publishable key is origin-locked and scoped, which is enough for many apps. For
production (and anywhere a user could tamper with a userId), mint a short-lived
token on your server with your secret API key and point the SDK at it:
// YOUR backend — one route. The secret key never reaches the browser.
app.get("/api/restream-token", requireAuth, async (req, res) => {
const r = await fetch("https://restream-api.fomo.gg/api/v1/auth/session", {
method: "POST",
headers: { "X-API-Key": process.env.RESTREAM_API_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ userId: req.user.id }), // ← bound to the logged-in user, server-side
});
res.json(await r.json()); // { token, expiresIn }
});// browser — no publishableKey, no userId; the token carries (and locks) the user.
useRestream({ apiBase: "https://restream-api.fomo.gg", tokenEndpoint: "/api/restream-token" });The SDK fetches the token, attaches it to every request, and transparently re-mints it when it expires (including reopening the chat stream).
Output resolution & quality
The restream output matches the resolution of your publisher session (up to 1080p). Bitrate, H.264 profile, and encoding are handled server-side — you don't configure an encoder. To deliver 1080p, publish your session at 1080p; a 720p session restreams at 720p. Adding more destinations does not reduce quality (single encode, fanned out).
API reference
useRestream(options) → state + actions
Options: { apiBase, publishableKey?, tokenEndpoint?, userId?, timeoutMs? }
— provide either publishableKey (+ userId) or tokenEndpoint. timeoutMs
defaults to 15000. On mount it auto-loads connections; job.status is polled live
(queued → running → stopped); incoming chat is de-duplicated.
Returns:
| Field | Type | Notes |
|---|---|---|
| connections | Connection[] | linked accounts (tokens redacted); auto-loaded on mount |
| job | Job \| null | current restream job (status polled) |
| viewers | { total, platforms } | polled viewer counts |
| comments | Comment[] | live merged inbound chat (SSE), latest 200 |
| error | Error \| null | last error (err.status carries the HTTP code) |
| connect(platform) | Promise | hosted OAuth popup |
| disconnect(platform) | Promise | |
| listConnections() | Promise<Connection[]> | manual refresh |
| arm({ destinations }) | — | pre-select destinations for goLive |
| goLive(params) | Promise<Job> | see Going live |
| stopLive() | Promise | |
| refreshJob() | Promise<Job> | re-fetch job status |
| sendChat(message, platforms?) | Promise | outbound chat fan-out |
| updateOverlay(opts) | Promise | live banner update |
| setStreamState(patch) | — | push live presence/layout (screen/camera/collab, position/size) |
| startScreenShare() / stopScreenShare() | Promise | screen as a live layer (no re-publish) |
| requestCollab(opts) / acceptCollab(id, opts) | Promise | invite / join a collab — see Collaboration |
| declineCollab(id) / revokeCollab(id) / endCollab(id?) | Promise | decline / cancel / end a collab |
| setCollabRestream(id, on) | Promise | toggle a guest's own-socials fan-out |
| listCollabInvites() · collabInvites | Promise · state | sent + received invites |
| startCollabRealtime() / stopCollabRealtime() | Promise | collab event stream (SSE) |
| studio | RestreamStudio | the underlying controller |
Convenience hooks
useConnections(options) → { connections, connect, disconnect, refresh },
useLiveChat(studio) → { comments, send },
useViewers(studio) → { viewers }.
Framework-agnostic core
Not using React? Import the controller directly:
import { RestreamStudio } from "restream-sdk/core";
const studio = new RestreamStudio({ apiBase, publishableKey, userId });
const off = studio.on("comments", (list) => render(list)); // also: connections, job, viewers, comment, error
await studio.listConnections();
await studio.connect("twitch");
await studio.goLive({ publisherSessionId, destinations: ["twitch"] });
// ... studio.destroy() to tear down timers/streamsTypeScript types ship with the package (restream-sdk and restream-sdk/core).
Data shapes
type Platform = "twitch" | "kick" | "youtube" | "facebook" | "instagram";
interface Connection {
platform: Platform;
platformUsername: string; // display name on the platform
platformUserId?: string;
isActive?: boolean; // false = do not arm; creator must reconnect/enable channel
needsReauth?: boolean; // true = OAuth/token needs reconnect
tokenHealth?: "active" | "expiring" | "needs_reauth" | "disabled" | string;
}
interface Job {
id: string;
status: "queued" | "running" | "stopping" | "stopped" | "failed" | "ended";
broadcasts?: { platform: Platform; watchUrl?: string; broadcastId?: string }[];
}
interface LiveDestination {
index: number;
platform: string;
source?: string | null; // direct_oauth / direct_rtmp / restream_bridge
status: string | null; // stream_destinations.status
verificationStatus?: string; // pending / retrying / verified / failed
verificationError?: string | null; // precise platform error when available
error?: string | null; // UI shortcut, null once verified
attempts?: number;
lastVerifiedAt?: string | null;
liveUrl: string | null;
watchUrl?: string | null;
enabled: boolean;
}
interface Comment {
id: string;
platform: Platform;
author: string;
authorAvatar?: string | null;
message: string;
timestamp: number;
}
interface Viewers {
total: number;
platforms: Record<Platform, number>; // e.g. { twitch: 12, kick: 5 }
}Events — subscribe with studio.on(type, cb) (the hook subscribes for you):
connections · job · viewers · comment · comments · error · state ·
screenshare · and collab: collab-invite · collab-accepted · collab-declined ·
collab-revoked · collab-ended · collab-expired · collab-composite ·
collab-reassigned (your job id changed — the SDK adopts it) · collab-snapshot.
Scopes
A publishable key (or token) carries scopes; the operator sets them at creation:
| Scope | Allows |
|---|---|
| connect | manage social connections (connect / list / disconnect) |
| restream | start/stop a job + overlay updates |
| read | job status, viewers, comments |
| chat:write | send chat |
A 403 "Missing required scope" means your key needs that scope added.
Next.js / SSR notes
- Use the hooks in a Client Component (
"use client").connect()uses popups andwindow, so it runs in the browser only. - Put the publishable key in a
NEXT_PUBLIC_env var (it's safe — origin-locked). The secret API key (token mode) stays server-side only. - The package is ESM and side-effect-free (tree-shakeable). It uses
fetch,EventSource, andwindow.open— all standard browser APIs.
Error handling
Every action rejects on failure; the hook also surfaces the latest error as
error. Errors carryerr.status(the HTTP status, or0for network/timeout):try { await goLive({ publisherSessionId, destinations }); } catch (e) { if (e.status === 403) showUpgrade(); else toast(e.message); }Common statuses:
401(bad/expired credential — token mode re-mints automatically),403(origin not allowed, or missing scope),409(a stream is already live for this streamer, or a duplicate collab),0(network/timeout). Note:goLive()also throws client-side if a job is already live — callstopLive()first.Realtime is resilient: transient SSE drops reconnect automatically and chat history de-dupes on replay; job-status polling retries on transient failures.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
| connect() does nothing | Popup blocked — allow popups for your origin (call it from a click handler). |
| Origin not allowed for this key (403) | Your app's origin isn't on the key's allowlist — ask the operator to add it. |
| failed to fetch / network error | Wrong apiBase, or the origin/key mismatch blocked CORS. |
| Connections empty after refresh | You passed a different userId — it must be stable per streamer. |
| Job goes running but nothing on the platforms | The publisherSessionId must be a live session actively sending media when you goLive(). |
| Worker seems to wait / no audio | Your session has no audio track — pass trackNames: ["video"]. |
| goLive() throws "a stream is already live" | Expected (0.7.0+) — one live stream per streamer. Call stopLive() first; use setStreamState() / startScreenShare() / acceptCollab() for live changes (don't re-goLive). |
| Missing required scope (403) | Key needs that scope (connect / restream / read / chat:write). |
Integration checklist
- [ ] Operator created a publishable key scoped
connect, restream, read, chat:writeand allowlisted your origin(s). - [ ] You pass a stable, unique
userIdper streamer. - [ ] You can supply a live
publisherSessionIdfrom your existing stream at go-live time (withtrackNamesif audio-less). - [ ] Popups are allowed for your origin (for
connect). - [ ] (Production) You stood up the token endpoint and switched the SDK to
tokenEndpointsouserIdis server-bound. - [ ] You render
connections,job.status,job.broadcastswatch links,viewers, andcomments.
Questions, a key request, or an origin to allowlist? Contact your Restream Service operator.
