# 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 `publisherSessionId` from 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](#install)
- [The integration contract (read this first)](#the-integration-contract-read-this-first)
- [What you get from your operator](#what-you-get-from-your-operator)
- [Quickstart](#quickstart-zero-backend)
- [The full streamer lifecycle](#the-full-streamer-lifecycle)
- [Where `publisherSessionId` comes from](#where-publishersessionid-comes-from)
- [The `userId` contract & persistence](#the-userid-contract--persistence)
- [Connecting accounts](#connecting-accounts)
- [Going live](#going-live)
- [Screen share & live layout](#screen-share--live-layout)
- [Collaboration (split-screen)](#collaboration-split-screen)
- [Live chat & viewers](#live-chat--viewers)
- [Banners / overlays](#banners--overlays)
- [Auth modes: publishable key vs. token](#auth-modes-publishable-key-vs-token)
- [Output resolution & quality](#output-resolution--quality)
- [API reference](#api-reference)
- [Data shapes](#data-shapes)
- [Scopes](#scopes)
- [Next.js / SSR notes](#nextjs--ssr-notes)
- [Error handling](#error-handling)
- [Troubleshooting](#troubleshooting)
- [Integration checklist](#integration-checklist)

---

## Install

```bash
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](#auth-modes-publishable-key-vs-token) |
| 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](#the-userid-contract--persistence). |
| 4 | **A `publisherSessionId`** | **Your existing stream** | The live media session you already produce, passed into `goLive()`. See [below](#where-publishersessionid-comes-from). |

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 call `goLive()` again** on reconnect, screen toggle, or collaborator change — those
> are *live* changes: use `setStreamState()`, `startScreenShare()`, and `acceptCollab()`.
> `goLive()` **throws if a stream is already live**; call `stopLive()` to cleanly end one
> before starting another.

---

## What you get from your operator

Ask whoever runs the Restream Service for:

1. **API base URL** — e.g. `https://restream-api.fomo.gg`.
2. **A publishable key** — `pk_live_…`, created in **Admin → Clients → Keys**, with:
   - **Scopes:** `connect`, `restream`, `read`, `chat:write` (see [Scopes](#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.
3. *(Production)* The **secret API key** — server-side only, for [token mode](#auth-modes-publishable-key-vs-token). **Never** put this in the browser.

---

## Quickstart (zero backend)

```jsx
"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:

```jsx
"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:

```js
// 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:

  ```js
  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 your `connections` state 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* `userId` on the next load, you'll get a different
> (empty) connection set. A stable `userId` is the whole persistence story.

In [token mode](#auth-modes-publishable-key-vs-token) 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

```js
await connect("twitch");   // opens a hosted OAuth popup, resolves when linked
await disconnect("kick");
connections;               // [{ platform, platformUsername, ... }] — tokens never exposed
```

- `connect(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](#the-userid-contract--persistence)); you
  don't store anything yourself.

---

## Going live

```js
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). Call `stopLive()` first to intentionally restart. Screen /
  collab / layout are *live* changes — use `setStreamState()`, `startScreenShare()`,
  `acceptCollab()`, never a second `goLive()`.
- **`participantOnly: true`** goes 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 no `destinations`.
- **Watch links.** Once broadcasts are created, `job.broadcasts` carries
  `{ platform, watchUrl }` for each destination — handy for "Watch on Twitch ↗".
- **`arm({ destinations })`** pre-selects destinations so you can fire `goLive`
  (with just the `publisherSessionId`) 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`:

```js
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`:

```js
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.

```js
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:

```js
// 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**:

```js
// Challenger: publish a camera, then join — NO goLive:
await acceptCollab(inviteId, { publisherSessionId });   // participantOnly guest
```

- A **new** challenger calling `acceptCollab` while 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:

```js
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

```js
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 platforms
```

- `comments` is 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 }`.
- `sendChat` is **outbound fan-out** — one message posted to every (or selected)
  connected chat. Requires the `chat:write` scope.

---

## 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):

```js
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:
  ```js
  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:

```js
// 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 }
});
```

```jsx
// 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](#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](#collaboration-split-screen) |
| `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:

```js
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/streams
```

TypeScript types ship with the package (`restream-sdk` and `restream-sdk/core`).

---

## Data shapes

```ts
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
  and `window`, 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`, and `window.open` — all standard browser APIs.

---

## Error handling

- Every action rejects on failure; the hook also surfaces the latest error as
  `error`. Errors carry `err.status` (the HTTP status, or `0` for network/timeout):

  ```js
  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 — call `stopLive()` 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:write` and allowlisted your origin(s).
- [ ] You pass a **stable, unique `userId`** per streamer.
- [ ] You can supply a **live `publisherSessionId`** from your existing stream at go-live time (with `trackNames` if audio-less).
- [ ] Popups are allowed for your origin (for `connect`).
- [ ] *(Production)* You stood up the **token endpoint** and switched the SDK to `tokenEndpoint` so `userId` is server-bound.
- [ ] You render `connections`, `job.status`, `job.broadcasts` watch links, `viewers`, and `comments`.

---

Questions, a key request, or an origin to allowlist? Contact your Restream Service operator.
