restream-sdk
v0.1.3
Published
Plug-and-play headless React hooks to add multi-platform restreaming (connect socials, go live, live chat + viewers) 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
- 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.
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. Calling
goLive()again for the sameuserIdsupersedes the previous job (the new one takes over). This prevents duplicate streams if a streamer double-clicks or reloads mid-stream. - 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.
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 |
| 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;
}
interface Job {
id: string;
status: "queued" | "running" | "stopping" | "stopped" | "failed" | "ended";
broadcasts?: { platform: Platform; watchUrl?: string; broadcastId?: string }[];
}
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 }
}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/supersede (a newer stream took over),0(network/timeout).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"]. |
| Starting a 2nd stream stops the first | Expected — one live stream per streamer; a new goLive supersedes the old. |
| 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.
