# restream-sdk & REST API changelog

Audience: client teams (fomo web, fomo iOS, any REST integrator). The SDK entries matter to
SDK consumers; the **REST entries matter to everyone — including hand-rolled clients like iOS
that have no SDK.** Breaking or semantics-changing entries are marked ⚠️.

Format rule (adopted 2026-08-17 after the `job.interrupted` incident): every entry states what
changed in what version/deploy, what the client-visible contract is, and — for ⚠️ entries — the
exact migration. Docs may describe intent; this file is the contract. When the two disagree,
this file wins, and the doc is fixed.

---

## 2026-09-07 — ⚠️ REST: Disconnect on a bridged platform now actually disconnects it

**No SDK release.** `DELETE /connect/:platform` for a platform routed through the Restream bridge
used to answer 409 with advice. It now performs the disconnect.

### What changed

| | before | now |
|---|---|---|
| We hold a local OAuth grant | 409 (fixed earlier today) | revoked + deleted |
| Genuine Restream channel | `409 BRIDGE_CHANNEL_MANAGED_IN_RESTREAM` | channel deactivated, `204` |
| No channel at all | 409 | `404 BRIDGE_CHANNEL_NOT_FOUND` + `manageUrl` |
| Restream token lacks `channels.write` | 409 | `409 BRIDGE_CHANNEL_WRITE_NOT_GRANTED` — reconnect Restream |

**New: `POST /connect/:platform/enable`** turns a deactivated bridge channel back on. It ships in
the same change on purpose — a disconnect with no way back would be worse than the refusal it
replaces. It cannot CREATE a channel: adding one means consenting to the destination inside
Restream, which is their OAuth, so a creator with no channel gets `BRIDGE_CHANNEL_NOT_FOUND` and
the add-channel URL. That is the one case where the second dashboard is unavoidable.

### Why this was ours and not yours

The channel is in the creator's **own** Restream account — the bridge reads it with a per-user
token, not a shared one — and 14 of the 17 stored Restream connections carry `channels.write`.
`RestreamPlatform` has had `PATCH /user/channel/{id} {active}` since it was written, with no caller
anywhere. We had the token, the scope and the call, and answered a Disconnect press by telling the
creator to go and do it in a dashboard many of them have never opened.

### ⚠️ Migration

A client that special-cases `BRIDGE_CHANNEL_MANAGED_IN_RESTREAM` to render "Manage in Restream" will
no longer receive it from the disconnect path — the code is gone from that route. Treat `204` as a
completed disconnect and re-list; the row comes back with `tokenHealth: "disabled"` so you can offer
Connect, which is `POST /connect/:platform/enable`.

Connecting for the first time is unchanged and already correct: `connectMethod` for a bridged
platform is `"restream"`, so `studio.connectPlatform("youtube")` opens the Restream add-channel flow
rather than Google consent. A YouTube button that opens a Google popup is calling `connect()`
directly instead of `connectPlatform()`.

---

## 0.19.0 — 2026-09-07 — SDK: thrown errors keep the response body

Published as `restream-sdk@0.19.0`. Reported by FOMO web. Additive — no migration.

### What was wrong

`_req()` built its Error from `data.error` and kept only `status`. Everything else the server sent
was discarded, including `code` and `manageUrl`.

Every typed refusal this API returns is identified by `code`, and several carry a `manageUrl` a UI
is expected to render as a link. Keeping only `error` made `BRIDGE_CHANNEL_MANAGED_IN_RESTREAM`,
`BRIDGE_CHANNEL_WRITE_NOT_GRANTED`, `RTMP_URL_REQUIRED`, `RECORDING_UNADDRESSABLE` and a `502
DELETE_INCOMPLETE` mutually indistinguishable — a client could see that something failed but not
which thing, so it could not branch on it. FOMO hand-rolled two calls to reach fields the SDK
already had and threw away.

### What changed

Errors thrown by any SDK call now carry:

| field | meaning |
|---|---|
| `err.status` | HTTP status (0 for network/timeout) — unchanged |
| `err.message` | the server's `error` string — unchanged |
| `err.code` | machine-readable refusal code, or `null` |
| `err.manageUrl` | present when the server supplied one |
| `err.body` | the parsed response body, verbatim — **authoritative** |

```js
try {
  await client.disconnect("youtube");
} catch (err) {
  if (err.code === "BRIDGE_CHANNEL_MANAGED_IN_RESTREAM") showLink(err.manageUrl);
  else if (err.code === "DELETE_INCOMPLETE") keepCardVisible(err.body.failed);
}
```

Branch on `err.code`. Read anything else from `err.body`, which carries fields added after this
entry was written — `code` and `manageUrl` are conveniences lifted out of it, not the contract.

---

## 0.18.0 — 2026-09-07 — ⚠️ SDK: `routeFor()` stops guessing when routing is not loaded

Published as `restream-sdk@0.18.0`. Reported by FOMO web.

### What was wrong

Routing (`oauth` | `restream` | `off`) is a **per-client admin setting**, fetched from
`GET /api/v1/connect/platforms`. The SDK also carried a static table —
`NATIVE_CONNECT_PLATFORMS = ["youtube", "facebook"]` — used "only as a fallback" before that fetch
resolved.

fomo.gg routes **youtube through the Restream bridge**. So with routing unloaded,
`routeFor("youtube")` returned a confident `"oauth"` — wrong for the largest client — and every
caller that trusted it inherited the error. `restreamAddChannelUrl("youtube")` returned a native
OAuth URL: not a failure, just the wrong destination, silently.

No constant compiled into the SDK can know a per-client setting. One that answers anyway is
guessing, and the caller cannot tell a guess from knowledge.

### ⚠️ What changed

- `routeFor(platform)` returns **`null`** whenever routing has not been loaded. It never returns a
  built-in default. The static tables are deleted.
- **New:** `isRoutingLoaded(): boolean` — distinguishes "not fetched yet" from "this client has no
  entry for that platform". Both previously looked like `null` only in the second case.
- `connectPlatform()`, `connectRestreamChannel()` and `restreamAddChannelUrl()` now throw an
  explicit *routing not loaded* error instead of mislabelling it `routed "off"` or
  `not routed through the Restream bridge`. Those messages blamed client configuration for a fetch
  that had not happened.
- `restreamAddChannelUrl()` is synchronous and cannot fetch routing, so it now **throws** rather
  than returning a possibly-wrong URL. This is the one call that can newly throw where it
  previously returned a string.

### Migration

`connectPlatform()` already loads routing itself — **if that is what you call, nothing changes.**

If you call `routeFor()`, `restreamAddChannelUrl()` or `connectRestreamChannel()` directly, ensure
routing is loaded first:

```js
await client.loadRouting();          // once, on init
if (!client.isRoutingLoaded()) { /* fetch failed — retry or degrade, do not assume */ }
const route = client.routeFor("youtube");   // "restream" on fomo.gg
```

A `null` from `routeFor()` now means *"I do not know"*. Treat it as a reason to load routing or
show a neutral state — never as a reason to pick a default.

---

## 0.17.0 — 2026-09-07 — SDK: the ingest-URL guard matches the server again

Published as `restream-sdk@0.17.0`. Pair it with the REST change below; 0.16.0 works but will let
an Instagram connect leave without an `rtmpUrl` and be refused by the API.

### `submitStreamKey()` requires `rtmpUrl` for creator-supplied-ingest platforms

0.15.0 required it for every platform (too broad — it blocked Instagram entirely). 0.16.0 required
it for X only, which was right on the day it shipped, because the API still defaulted Instagram's
host. That default is gone, so the guard follows: Instagram and X both need the URL, Kick still
does not.

**Which platforms need it is now the server's answer, not a list in this file.** `requiresRtmpUrl`
from `GET /connect/platforms` is cached by `listOAuthPlatforms()` / `loadRouting()`; the literal
pair remains only as a fallback for an unloaded client or an older backend. The bridge list once
lived in three places and drifted silently — this one lives in one.

### New: `requiresRtmpUrlFor(platform)`

```js
studio.requiresStreamKey("instagram")    // true  → show a key form
studio.requiresRtmpUrlFor("instagram")   // true  → that form needs an INGEST URL field too
studio.requiresRtmpUrlFor("kick")        // false → key only
```

Two different questions, and only the first had an answer before. A form built on
`requiresStreamKey` alone renders one field and submits; the submit is then refused for a field the
client was never told about.

### Not changed

No transport, auth, streaming or event behaviour. The failure text names where the creator copies
the URL from, so it can be shown to them as-is.

---

## 2026-09-07 — ⚠️ REST: `rtmpUrl` is REQUIRED for Instagram and X, and is never defaulted

**No SDK release.** A connect call that omits `rtmpUrl` for these two platforms now fails at the
form instead of streaming to a host nobody chose.

### What changed

`POST /connect/instagram/manual` and `POST /connect/x/manual` answer:

```
400  { "error": "rtmpUrl is required — copy the RTMPS URL shown next to the stream key at
                 instagram.com/live/producer",
       "code": "RTMP_URL_REQUIRED" }
```

The Instagram adapter used to substitute `rtmps://live-upload.instagram.com:443/rtmp/` whenever the
URL was absent. It no longer does; a connection without an ingest URL builds no destination and
says so.

### Why — the default was worse than a missing value

That host is real: it CNAMEs onto `edgetee-upload.xx.fbcdn.net`, Meta's ingest tier, and answers on
443. Live Producer, though, issues a **regional** edge per creator — `edgetee-upload-bom5-1…` is
Mumbai — and the generic name geo-resolves for the machine doing the pushing, which is our media
worker, not the creator. Resolved from our side it lands on
`livestream-edgetee-upload-shv-01-hyd1.facebook.com`: Hyderabad. Nothing errors. The broadcast just
leaves through an edge the creator never picked, and no log anywhere records a decision.

The default was unreachable while both SDKs threw without an `rtmpUrl`. `restream-sdk@0.16.0`
removed that throw (per-route guard, 2026-09-06), which made it reachable — reported the same week
by FOMO web, who had removed the identical hardcoded value from their own form.

### ⚠️ Who this breaks

**A caller that omitted `rtmpUrl` used to succeed. It now gets a 400.** That caller was not doing
anything wrong: `docs/INSTAGRAM_DIRECT_STREAM_KEY.md` described the field as *"optional at the
API"* and told integrators to send `rtmps://live-upload.instagram.com:443/rtmp/` themselves if
their SDK insisted on one. Anyone who followed that advice is exactly who this breaks, which is
why it is marked ⚠️ rather than filed as a fix. The doc is corrected.

| caller | affected? | why |
|---|---|---|
| `restream-sdk` **0.17.0+** | no | throws client-side first, with the same rule the server applies |
| `restream-sdk` **0.16.0** | **yes, for Instagram** | its per-route guard requires `rtmpUrl` for X only, so an Instagram connect with no URL is sent and refused |
| `restream-sdk` **0.15.0** | no | threw without an `rtmpUrl` for every platform — the reason the default was unreachable |
| **iOS `0.3.0` / Android `0.2.2`** | no | `rtmpUrl: String` is non-optional in both, so it is always sent. Passing `""` is refused, correctly |
| **hand-rolled REST** | **yes, if it omitted the field** | the case the old doc invited |

### Migration

Send the URL the creator pasted, verbatim, alongside the key — both values sit side by side in
Live Producer (and in studio.x.com/producer/sources for X), so this is one extra copy-paste and no
new screen. **Do not hardcode an ingest host, ours included.** Kick is unaffected: its ingest is a
single global host we hold ourselves, and `/connect/kick/manual` still takes no `rtmpUrl`.

**Do not hardcode the platform list either.** `GET /connect/platforms` now returns
`requiresRtmpUrl: { <platform>: boolean }` beside `requiresStreamKey`, from the same server-side
definition the connect route enforces. Render the second field from that map and the answer stays
right when the set changes; `requiresStreamKey` alone tells you to show a key form, not that the
form needs two fields.

Existing connections are untouched — every Instagram and X connection in production already stores
a creator-supplied URL (checked, 7 Sep), so nothing that works today stops working. One related
guard: Instagram's ingest column is shared with its OAuth token storage, so a value that is not an
`rtmp(s)://` URL is now refused by name rather than pushed to as a destination.

---

## 2026-09-06 — ⚠️ REST: Facebook streams to the creator's PROFILE, not an auto-picked Page

**No SDK release and no client code change — but the destination of every Facebook broadcast
changes.** Read this even if you have no SDK.

### ⚠️ Connecting Facebook no longer selects a Page for the creator

Connect used to call `/me/accounts` and make the account's **first Page** — `list[0]`, in whatever
order Graph returned it — the streaming target. The creator was never asked and never told. An
account that managed any Page could not reach its own profile at all, because the auto-pick ran
again on every reconnect. It was reported as a bug twice (2026-07-05, 2026-09-05), both times as
"it's not going to my profile", which is precisely what was happening.

Connecting now targets the creator's **own profile**. The account's Pages are still listed and
stored, so a creator can move to a Page deliberately — none is chosen for them.

**Migration.** Nothing to change in a client. What moves:

| | |
|---|---|
| New connections & reconnects | Profile, automatically |
| Existing connections | Keep their Page until the creator reconnects, or until you send `pageId: "profile"` (below) |
| Creators who want a Page | One call — `selectFacebookPage("<pageId>")`, unchanged |

Watch links change shape with the target: a Page broadcast is
`facebook.com/<pageId>/videos/<videoId>`, a profile broadcast falls back to Facebook's own
permalink. Do not parse a page id out of a Facebook watch URL.

**Facebook's own rules still apply.** Publishing to a profile uses `publish_video`; publishing to a
Page additionally uses the `pages_*` scopes. Both are in the connect scope set, but what Meta has
actually approved for the app decides whether a given creator's profile broadcast is accepted.

### `POST /api/v1/connect/facebook/page` accepts `pageId: "profile"`

The endpoint could only ever re-select among the account's own Pages, so there was no way back to
the profile. Sending the literal string `"profile"` as `pageId` now points the connection at it:

```js
// stream to the creator's own profile
await studio.selectFacebookPage("profile");
// back to a Page
await studio.selectFacebookPage("423989147471495");
```

`"profile"` is a plain `pageId` value on purpose — `studio.selectFacebookPage(pageId)` in every
shipped SDK reaches it with no upgrade.

The response keeps `activePage` and adds `activeTarget` (`"page"` | `"profile"`) so a caller does
not have to infer the target from a null page id:

```json
{ "ok": true, "platform": "facebook",
  "activePage": { "target": "profile", "pageId": null, "profileId": "…", "name": "…" },
  "activeTarget": "profile" }
```

Error codes are unchanged (`facebook_not_connected`, `invalid_page`, `facebook_reauth_required`,
`facebook_unreachable`, `facebook_page_switch_failed`). `invalid_page` is not raised for
`"profile"` — a creator's own profile is never in their `pages` list.

### `GET /api/v1/connect/list` says which target is active

The facebook connection now carries `facebookTarget` (`"page"` | `"profile"`) and
`platformProfileId`. Both are additive. A picker needs them: `pages` alone cannot express "stream
to my profile", and a null `platform_page_id` was the only signal that the profile was selected.

**Facebook's own rules still apply.** Publishing to a profile needs `publish_video`, which is
granted per connection at consent time — this endpoint changes where we publish, not what Meta has
approved. A switch is rejected (and the existing connection left untouched) if the stored
long-lived user token no longer resolves.

---

## 2026-09-06 — SDK 0.16.0: `submitStreamKey()` validates per route

`rtmpUrl` is no longer required for every platform. It is required only for **X**, whose legacy
`/connect/x/key` endpoint needs it; the `/connect/<platform>/manual` route that Instagram and Kick
use accepts an absent `rtmpUrl` and falls back to the platform's own ingest.

0.15.0 refused the documented Instagram call — paste a key, let the server supply Instagram's
ingest — **before sending anything**, on a field the API is happy to omit:

```js
// 0.15.0
if (!rtmpUrl || !streamKey) throw new Error("rtmpUrl and streamKey are required");
```

Reported by FOMO web, 2026-09-04. Each call now fails on what its own endpoint actually needs:

```js
// 0.16.0
if (!streamKey) throw new Error("streamKey is required");
if (isX && !rtmpUrl) throw new Error("rtmpUrl is required for x");
if (!isX && !username) throw new Error("username is required for " + p);
```

`username` becoming an explicit requirement on the `/manual` route is not new behaviour — the
endpoint has always returned `400 platformUsername and staticStreamKey are required`. The SDK now
says so at the call site instead of letting it become a server round-trip.

**No migration for callers already sending both.** A caller that omitted `rtmpUrl` for Instagram
and hit the throw will now succeed; a caller that omitted `username` gets a clearer error in the
same place it already failed.

---

## 2026-08-30 — SDK 0.16.0 + REST: security hardening (no client change required)

Nothing a pk-mode client (fomo web) does changes. Token-mode clients get a safer SSE connection
automatically; REST-only integrators should read the three REST notes.

### SDK — token mode: the JWT no longer travels in the EventSource URL
`startRealtime()` and `startCollabRealtime()` in **token mode** now mint a single-use, short-lived
ticket (`POST /api/v1/auth/sse-ticket`, authenticated by header) and connect with `?ticket=`.
On a server that predates tickets the SDK falls back to the previous `?token=` query, so the
upgrade is safe in either order. **pk mode is unchanged**: the publishable key stays in the query,
so native EventSource reconnect keeps working without a manual reopen.

### REST — `POST /api/v1/auth/sse-ticket` (new)
Authenticated like any other call (header token / secret / pk). Returns `{ ticket, expiresIn }`;
the ticket is valid once, for `expiresIn` seconds, on `/jobs/:id/comments/stream` and
`/collab/invites/stream` as `?ticket=`. Query auth (`?token=`, `?pk=`) is still accepted on those
streams — it now logs a server-side deprecation notice. A long-lived **secret** key is accepted
only in the `X-API-Key` header, never in a URL (unchanged from before; restated as the contract).

### REST — `POST /api/v1/jobs/:id/comments` is rate-limited per user
5 sends per 10 s per `clientUserId` (per job when no user id is present); server-to-server calls
with a secret key are exempt. Over the limit: `429 { code: "RATE_LIMITED", retryAfter }` with a
`Retry-After` header. Tunable server-side (`CHAT_RL_MAX`, `CHAT_RL_WINDOW_SEC`).

### REST — health endpoints return booleans, never messages
`/api/health`, `/health` and `/api/v1/health` all return
`{ ok, db, redis, status, timestamp, checks: { db, redis } }` with `"ok"`/`"error"` only — no
error text, no config warnings. `/api/server-health` (process/host inventory) now requires the
admin key (`X-API-Key` or `Bearer`); unauthenticated calls get 401.

### Server operators
The API refuses to boot in production unless `ENCRYPTION_KEY` (64 hex), `ADMIN_SECRET` and a
`TOKEN_SIGNING_SECRET` distinct from `ADMIN_SECRET` are all set. HTML banner URLs are resolved
and checked before Chromium loads them: private, loopback and link-local targets are refused.

---

## 2026-08-28 — SDK 0.15.0 + REST: tell us when the publish was never confirmed

### `publishUnconfirmed` — the client knows, and now so do we
An app that runs its own publish-wait, sees it FAIL, and goes live anyway (rather than lose a
merely-slow camera) was the only thing that knew. When the server then found no tracks on the
publisher, all it could report was Cloudflare's wording:

```
Host publisher session disconnected before media could be pulled …
Cloudflare: Track not found on remote peer
```

That reads like a server fault. It is not — and **29 failed streams in a week** were this exact
case, with a full handoff cycle spent debugging the pull side.

**SDK — `studio.goLive({ publishUnconfirmed: true })`. REST — `publishUnconfirmed: true`** in the
`POST /api/v1/jobs` body (matters to REST-only integrators too).

It is **declarative, not a gate**: it never blocks or slows go-live, and a slow camera that does
arrive still works exactly as before — the server keeps its full retry budget precisely so that
case survives. All it changes is the failure text when no media ever arrives:

> The creator's camera never started sending before go-live. The app reported its publish check had
> already failed (publishUnconfirmed) and went live anyway, so no media ever reached us to
> restream — nothing was sent to any platform.

The flag is persisted to `restream_jobs.metadata.publishUnconfirmed`, so a post-mortem can tell
"the publisher was never confirmed sending" apart from "the publisher was fine and something
downstream broke". Those were previously indistinguishable.

### `publishWaitTimeoutMs` — goLive's publish-wait is finally tunable
`goLive()`'s internal `waitUntilPublishing` hardcoded 10s. Wanting a different budget forced the
two-step `waitUntilPublishing(pc, { timeoutMs })` + `waitForPublishing: false`, which is **correct
usage** (and stays supported) but existed only because of that hardcoded value.

```ts
studio.goLive({ publisherPeerConnection: pc, publishWaitTimeoutMs: 4000 })
```

### `X-Restream-SDK-Version` on every request
Which SDK version a client actually ships was unknowable server-side, so diagnosing a
version-gated bug meant asking and waiting — one handoff was spent on an ask a single header would
have answered. Sent on **every** request, and recorded at `restream_jobs.metadata.sdkVersion` on
go-live (null for REST-only callers). `SDK_VERSION` is also exported; a test fails the build if it
ever drifts from `package.json`.

## 2026-08-26 — SDK 0.14.0 + REST: FOMO's own audience counts toward the total

### The headline viewer number was missing FOMO's own watchers
Social platforms are polled for their viewer counts, but nothing outside FOMO can observe how many
people are watching ON FOMO — so that audience was silently absent from every total. A creator with
3 watchers on FOMO, 6 on Kick, 5 on Twitch and 3 on YouTube was shown **14, not 17**.

**REST — `POST /api/v1/jobs/:id/viewers`** (matters to every integrator, SDK or not):

```json
{ "platform": "fomo", "count": 3 }
```

`platform` is optional and defaults to `"fomo"`. The response is the merged view, so the caller
sees the combined number it just contributed to without a second round trip. `GET /viewers` and the
`viewers` event are unchanged in shape — `fomo` simply appears in the breakdown and in `total`.

**SDK — `studio.reportViewers(count)`.**

### Send an ABSOLUTE count, roughly every 10–15s, and stop when the stream ends
The latest report REPLACES the previous one; it is not a delta. Increments would drift permanently
the first time a report was dropped, and the number would never recover for that stream.

Only `fomo` may be self-reported. Any other platform is rejected with `403
PLATFORM_NOT_SELF_REPORTABLE` — Kick/Twitch/YouTube counts come from those platforms' own APIs and a
client must not be able to overwrite them. The job must be live (`409 JOB_NOT_ACTIVE` otherwise),
the caller must be a verified participant, and `count` must be a non-negative integer.

### A stale FOMO count disappears on its own
The viewer hash TTL is refreshed by every write, so a stream still being polled for Kick would keep
a `fomo` field alive indefinitely — showing viewers on a page nobody is watching. `fomo` therefore
carries its OWN freshness marker (~45s, about three missed reports) that expires independently of
social polling. Stop reporting and the count leaves the breakdown; resume and it comes back.

---

## 2026-08-26 — SDK 0.13.6 Stop and Go Live survive a failed API request

### ⚠️ Pressing Stop no longer depends on the API call succeeding — but FOMO must change too

`stopLive()` awaited the `DELETE` **before** any local teardown, so a rejected request — a network
blip, a `502` while the backend redeployed, a timeout — skipped every cleanup line that followed it.

Effect in production on 2026-08-26, job `1ee3d83d`: a creator pressed Stop, the request never
reached the backend, and the job stayed `running` for six more minutes, holding a media worker at
~5 cores and still broadcasting to five platforms. It ended only because an unrelated RTMP
destination happened to reset the connection. Nothing in the system was going to stop it.

Local teardown now runs FIRST and cannot be skipped by a server failure, each step guarded
independently. This also fixes a second bug: `stopLive()` never called `stopScreenShare()`, so even
a **successful** stop left the SDK's own Cloudflare screen-share session publishing.

**⚠️ MIGRATION — this SDK does not own your camera.** fomo captures and publishes the camera and
passes us only a `publisherSessionId`. `stopLive()` can only guarantee what the SDK owns. Your Stop
handler must stop your own publisher FIRST and UNCONDITIONALLY:

```js
async function stopStream() {
  try { await stopFomoCameraPublisher(); } catch (e) { report(e); }   // 1. never gated on the API
  let serverError = null;
  try { await studio.stopLive(); } catch (err) { serverError = err; } // 2. then ask the server
  if (serverError) { /* broadcast HAS ended locally; show "ending…", not "Stopped" */ }
}
```

If your camera shutdown sits downstream of `await studio.stopLive()`, a failed Stop still strands
the stream exactly as before, and the backend's RTP-quiescence watchdog will not fire because RTP
is still arriving.

**Three outcomes are now distinguishable on `job`**, because "we stopped" and "the server agrees"
are different things:

| field | meaning |
| --- | --- |
| `sdkMediaStopped` | everything the SDK owns is down. Always true once `stopLive()` settles. Named for its scope — it says nothing about your camera. |
| `serverStopConfirmed` | the backend acknowledged. Only then is the stop complete. |
| `serverStopError` | why it did not. `stopLive()` also rejects, so a failed stop is never reported as a clean one. |

`job.status` still becomes `"stopping"` on success, exactly as before — no new transition to handle.

### Go Live retries a lost request

The same incident lost a go-live: no job row, no inbound request, and a retry moments later worked.
`goLive()` now retries transient failures. This is safe only because the server de-dupes on
`(client, creator, publisher_session_id)` under an advisory lock and returns the existing job with
`deduplicated: true` — so every attempt reuses the **same** `publisherSessionId` and a duplicate
live job cannot be created. A `deduplicated` response is success.

### Retry policy, both operations

3 attempts, 500 ms doubling to a 4 s cap. Retries `status 0` (network and timeout), `429` and `5xx`
only. Ordinary `4xx` is a decision, not a fault, and fails immediately. `401` is unchanged —
`_req` refreshes and retries it internally in token mode.

Repeated `stopLive()` calls coalesce: a second call while one is in flight receives the *same*
promise, so a double-click cannot run teardown twice or race its own retries.

---

## 2026-08-26 — SDK 0.13.5 SSE resilience (comments + collab invites)

### Both live streams now recover from a CLOSED EventSource
`EventSource` retries on its own **only** while `readyState` is `CONNECTING` — a network-level
drop. When the server answers with anything that is not a `200 text/event-stream` — a `401` on an
expired token, a `502`/`503` during a backend deploy — the browser **fails the connection, moves to
`CLOSED`, and never retries**. Both SSE streams reopened only when `tokenEndpoint` was configured,
so publishable-key clients (fomo web) had no recovery path at all.

Effect in production on 2026-08-26: a backend deploy restarted the API two hours into a creator's
stream. Comments kept being collected and written server-side for the rest of the session;
`PUBSUB NUMSUB` on the job's comment channel read `0`. The creator's chat panel stayed empty until
the page was reloaded. The collab invite stream fails the same way, and carries the
`collab-composite` / `collab-reassigned` job-adoption events — so a dead one leaves the UI tracking
a job the creator is no longer broadcasting under.

Fixed on `/jobs/:id/comments/stream` and `/collab/invites/stream`, in **both** auth modes:

- a `CLOSED` stream is reopened; a `CONNECTING` drop is still left to the browser (reopening there
  would duplicate every message and leak a socket per drop)
- reopen attempts back off 3s → 6s → 12s → 24s → 30s (capped), and reset to 3s once a stream
  actually opens. A ten-minute outage no longer costs ~200 attempts per open tab.
- exactly one EventSource is live at a time, including under concurrent opens
- `stopRealtime()` / `stopCollabRealtime()` / `destroy()` never reconnect — including when called
  while an open is still awaiting its auth token, which previously resumed and put a stopped
  client back on the air holding a socket the stop could no longer reach

**No API change and no migration.** Upgrade the dependency and redeploy:

```
npm install restream-sdk@0.13.5
```

Clients that cannot upgrade immediately still receive comments via the REST polling fallback added
in 0.13.2 (~5s latency instead of instant), but their collab invite stream stays dead after a
backend restart until the page is reloaded.

## 2026-08-24 — SDK 0.13.4 type fix

### `RestreamStudio.getDestinations()` is now declared
`getDestinations()` already existed at runtime and was already typed on the React hook interface.
The class declaration was missing it, so framework-agnostic SDK consumers had to narrow or cast
even though the method was real. The `RestreamStudio` type now declares the same return shape:

`Promise<{ ok: boolean; destinations: LiveDestination[]; liveControlSupported: boolean }>`

No runtime behavior changed.

## 2026-08-24 — SDK 0.13.3 + REST destination/token visibility

### Connection token health is part of the typed contract
`GET /api/v1/connect/list` already returns `isActive`, `needsReauth`, `tokenStatus`,
`tokenHealth`, `expiresAt`, and `hasRefreshToken` on native OAuth connections; Restream bridge
channels return the same health shape with inactive channels marked `disabled`.

The SDK types now declare those fields. Clients should not arm a destination when
`isActive === false` or `needsReauth === true`.

### `GET /api/v1/jobs/:id/destinations` now includes failure details
Each destination now carries `source`, `verificationStatus`, `verificationError`, `error`,
`attempts`, `lastVerifiedAt`, and `watchUrl` in addition to the existing live-control fields.
This is the intended read path for per-platform UI such as:

> five platforms selected, one fails, the stream stays live and the failed platform shows why.

No client migration is required for existing consumers; the fields are additive.

## 2026-08-24 — SDK 0.13.2 + REST comment hardening

### Live comments now have a REST polling fallback
The SDK still opens the existing `comments/stream` SSE connection for instant chat, but it now
also polls `GET /api/v1/jobs/:id/comments` during the normal live-status poll. If the browser,
mobile webview, proxy, or deploy drops the SSE connection, comments are caught up from history
instead of disappearing from the FOMO UI.

### `GET /comments` now falls back to PostgreSQL history
The comments endpoint still reads Redis first for fast live history, but it now merges in recent
`chat_messages` rows from PostgreSQL. This covers Redis reconnects, app refreshes, and late
joiners without requiring a special repair. The response shape is unchanged.

No client migration: upgrade the SDK for the polling fallback. Hand-rolled clients can keep using
the same REST endpoint.

## 2026-08-19 — SDK 0.13.1 (fix) + a production config correction

### `x` was missing from the SDK bridge set — reported by the fomo backend team
0.13.0 shipped the backend half of X support but not the SDK's static fallback, which still read
`new Set(["tiktok", "instagram"])`. With routing not yet fetched, `routeFor("x")` returned `null`
and `connectPlatform("x")` threw:

```
"x" is not available to connect for this client (routed "off").
```

That message blames client configuration for what was a missing entry in a constant. Fixed.

### ⚠️ The same gap existed in production config, and that one actually bit
`RESTREAM_ALLOWED_PLATFORMS` was set to `"tiktok,instagram"` on the API service, which overrides
the code default — so the server resolved **`x → off`** and would have kept doing so even with
the SDK fixed. The worker had the variable unset (code default, which does include `x`), so the
two halves disagreed: connect refused X while the media plane would have bridged it happily.
The variable now includes `x` on both.

**If you self-host or set this variable:** it is an ALLOWLIST that replaces the default wholesale,
not an addition to it. Any platform absent from it routes `off`.

## 2026-08-19 — SDK 0.13.0 + REST (no breaking changes)

### ⚠️⚠️ CRITICAL — `waitUntilPublishing()` threw on the HEALTHY path, blocking go-live entirely
**Affects every SDK from 0.6.x (2026-07-03, commit `5fdecab`) through 0.12.3. Fixed in 0.13.0.**
Reported by the fomo backend team, who lost most of a day of restreaming to it.

`waitUntilPublishing()` registered a `connectionstatechange` listener that set an internal
`dead` flag **unconditionally**:

```js
const onFail = () => { dead = true; };            // fires on EVERY transition
pc.addEventListener("connectionstatechange", onFail);
…
if (dead || state === "failed" || state === "closed") throw
```

`connectionstatechange` also fires on the **normal** `connecting → connected` transition, so a
**successful** connect set `dead` and threw:

```
waitUntilPublishing: connection connected before media flowed
```

That message names `connected` — a healthy state — which is the tell. Because `goLive()` awaits
this call *before* issuing its POST, the request was never sent: **zero POSTs, no preflight, no
job created**. The failure hit precisely the clients doing the right thing by passing
`publisherPeerConnection`; anyone omitting it skipped the wait and was unaffected.

`onFail` now inspects the state and only latches on the genuinely terminal `failed` / `closed`.
A transient `disconnected` blip that recovers no longer kills the wait. Covered by 5 regression
tests that fail on the old implementation.

**Migration:** upgrade to `restream-sdk@^0.13.0`. If you worked around this by catching the
error and retrying with `waitForPublishing: false`, you can now remove that workaround — and
should, because it disabled the media-flowing gate the call exists to provide.


### REST + SDK: replays that are still processing are now visible to the client
`GET /api/v1/jobs/recordings?userId=…` returned `status='ready'` rows ONLY. While a replay was
still uploading/transcoding the app therefore received an **empty list** and rendered "no stream
playback available" — indistinguishable from "this creator has no replays".
Pass **`includeProcessing=1`** (SDK: `getCreatorRecordings(userId, { includeProcessing: true })`)
to also receive in-flight replays. Those carry:
- `processing: true`, `playable: false`, `playbackUrl: null`
- `processingMessage` — a ready-to-render sentence explaining the replay is still processing

The **default response is unchanged** — omit the flag and you get today's ready-only list.
Render a "Processing…" card for `processing: true` items instead of an empty state.

### REST: `durationSeconds` is populated again
Every recording was persisted with `duration_seconds = 0/null` because the worker never probed
the finished file, so replay cards showed `0:00`. The worker now ffprobes the finalized MP4 and
persists the real duration; the 62 historical replays that were still readable were backfilled.

### Recording-only jobs (no destination armed) now actually produce a replay
A job flagged `recordingOnly` reached the pipeline builder as "outputless" and had BOTH tees
routed into fakesinks — the pipeline never contained a filesink, so **every** record-only stream
finished with recording status `file_not_found`. Fixed server-side; no client change required.
⚠️ Client requirement that still stands: pass `publisherPeerConnection` (or `pc`) to `goLive()`.
Without it the SDK skips `waitUntilPublishing`, and because a record-only go-live has no
destination setup to buy the publisher headroom, the server-side pull races the publisher and
loses — that is what killed 8 of the first 11 record-only jobs. SDK 0.12.3+ warns in the console.

### X (Twitter) is now a routable streaming destination
X has RTMP ingest but no public broadcast-creation API, so — exactly like TikTok and Instagram —
it is **bridge-only**: the creator connects X inside their Restream account and we fan out to it
through the bridge. There is no direct X OAuth connect flow to build.
`ROUTABLE_PLATFORMS` now contains `x`, and the bridge allowlist defaults to
`tiktok, instagram, x`. Restream's own naming for this destination has changed repeatedly, so
`"Twitter"`, `"X (Twitter)"`, `"Twitter/X"` and `"X/Twitter"` all normalise to the single id `x`
— send `"x"` in `restreamChannels` and match on `"x"` in destination rows.

---

## 2026-08-18 — deployed with this commit (⚠️ visual change)

### ⚠️ Screen + camera presentation is now the global standard: screen FULL-FRAME, camera corner PiP
Previously `screen_camera` (and the Reserve-Always live screen-share transitions) rendered a
50/50 stacked split (screen top half, camera bottom half). That contradicted fomo's own
layout-picker presentation, the industry default (Zoom/Teams/Meet/StreamYard/OBS), and the
overlay system's own screen-share PiP zone. **Now, in every path** (build-time 2-feed composite,
live screen-share attach, and the solo-screen reconciler): the **screen fills 1920×1080** and the
**camera renders as a 520×340 corner PiP** (bottom-right, keep-aspect). Geometry is ONE shared
constant (`SCREEN_CAMERA_PIP`) identical to the overlay-protection rect.
Behavior notes for clients: `screenHasCameraPip: true` / `preComposited` still mean "hide our
camera layer entirely — the shared surface already has one" (screen full-frame, no PiP) —
unchanged. Only the default (camera visible alongside an external window/tab share) changed
shape. No API change; purely server-side render. Existing per-creator admin layout templates
(OverlayTemplateService) that define explicit screen_camera regions STILL OVERRIDE the default —
if any template was hand-tuned to the 50/50 shape, re-tune it to the full-frame+PiP intent.

## 2026-08-17 (4) — SDK 0.12.3

### SDK 0.12.3: `goLive()` warns when the publish-wait is silently skipped
Root-caused via fomo's idle path: their client called `goLive()` with NO peer connection, so
`waitUntilPublishing` never ran (guarded on `publisherPc`), and go-live fired at
ICE-"connected" (transport-up) — before RTP flowed. Recording-without-destinations jobs lost
the race 9/9 because no destination setup bought the publisher headroom (armed jobs were
masked by the same bug). Same silent-drop class as unrecognized options. `goLive()` now
warns when the publish-wait is skipped for a missing pc. **Contract guidance: clients must
pass the publisher peer connection (or gate on RTP outbound packetsSent > 0 themselves) —
transport-connected ≠ media-flowing.** No behavior change for callers that pass a pc.

## 2026-08-17 (3) — no deploy needed (semantics documentation)

### `masterPlaybackUrl` semantics — DEFINITIVE: canonical/primary, NOT "HLS master"
Caught by fomo's review: "master" is standard HLS vocabulary for the top-level variant
playlist, and this field can return a **progressive MP4** (specifically: legacy rows without
a stored `master_playback_url`/`hls_playback_url`, and generally post-2026-08-17 rows where
it was populated `master_playback_url || hls_playback_url || playbackUrl` — the last
fallback being the MP4). Decision: **the field means "our canonical recording URL," not an
HLS term.** If you need the HLS manifest, read `hlsPlaybackUrl` — that is the only field
guaranteed to be a manifest when HLS exists. Do not type-switch on `masterPlaybackUrl`
`endsWith(".m3u8")`. No code change shipped with this entry; behavior unchanged; documented
per the standing rule (a field whose name implies a meaning that differs from its value gets
a changelog line).

## 2026-08-17 (2) — deployed + SDK 0.12.2

### SDK 0.12.2: `goLive()` warns on unrecognized options
One `console.warn` per unknown key, naming the key and pointing here. Converts the
silent-drop class (option silently unforwarded when the installed SDK predates the field)
from invisible to self-reporting. Motivated by fomo's near-miss: a mainline merge conflict
resolution (^0.12.0 vs ^0.12.1) that would have compiled, passed CI, deployed, and silently
dropped `recordingOnly`. No behavior change for valid options.

### REST: legacy rows with an `.m3u8` in `playbackUrl` are repaired at read time
Rows written before the 2026-08-17 playbackUrl fix still store the manifest in
`playback_url` (no DB backfill was run). `mapRecording` now detects those rows and
reconstructs the progressive MP4 URL from `r2_key` (set at upload, never overwritten —
HLS uploads under a separate prefix). Affects `GET /jobs/:id/recording`,
`GET /jobs/recordings`, `GET /jobs/lookup`. **Do this before any backfill reads these
endpoints** — otherwise the manifest gets copied back out. `hlsPlaybackUrl` unchanged.

## 2026-08-17 — deployed (commit `7abdcd7`)

### ⚠️ REST: `playbackUrl` is now ALWAYS the progressive MP4 (HLS manifest moved to `hlsPlaybackUrl`)
Previously, after HLS conversion completed, `markHlsPlaybackComplete` overwrote the stored
`playback_url` with the adaptive `.m3u8`. Consequences: `GET /api/v1/jobs/:id/recording`,
`GET /api/v1/jobs/recordings`, and webhook #2's `playbackUrl` all returned a manifest —
playable only where native HLS exists (Safari), silent black/no-play in Chrome/Firefox and
plain `<video>`/AVPlayer-without-HLS paths.
**Now:** `playbackUrl` is the progressive MP4 for the recording's lifetime. The manifest is
available in `hlsPlaybackUrl` / `masterPlaybackUrl` (same value as before, new home).
**Migration:** if you consumed `playbackUrl` expecting the manifest, read `hlsPlaybackUrl`
instead. If you play `playbackUrl` directly — you were the bug's victim; no change needed.
Webhook #1 (MP4, seconds after stop) and #2 (HLS fields populated) both still fire.

## 2026-08-16 — deployed (commits through `45c0b6b`)

### SDK 0.12.1 (`restream-sdk`)
- `goLive({ recordingOnly: true })` passes through to the backend: records the session with
  no RTMP fan-out, so creators with zero social platforms armed still get a replay.
  ⚠️ **Version-gated field:** on < 0.12.1 the SDK silently drops the flag (does not forward
  unknown keys) — pin `^0.12.1`. Over REST, `recordingOnly` has been honored on
  `POST /api/v1/jobs` since 2026-08-16 regardless of SDK version.

### SDK 0.12.0
- pk-mode auth: `stopLive()`, `sendChat()`, `updateOverlay()`, `updateLiveMeta()` now send
  the acting `userId` (query param) required by the live-mutation participant guard.
- ⚠️ REST note: calls to live-mutation routes in **publishable-key mode** MUST carry the actor
  — `clientUserId` in the body where a body exists, `?userId=` otherwise (DELETE/no-body).
  Hand-rolled clients: attach to every mutation call.

### REST: `job.failed` now dispatched from ALL reconcilers
Previously two of three recovery paths flipped jobs to `failed` DB-only (orphan/unclaimed jobs,
dead-worker jobs) — clients gating cards on webhooks never heard, cards froze on "processing".
Now every reconciler emits `job.failed` (best-effort per job). Failed ≠ recording: streams
recovered this way produced no recording; expect `recording.failed` from the recording sweeper
where a recording row existed.

### REST: goLive retry semantics (documented, unchanged behavior)
The go-live dedupe matches only **active** statuses (`queued/starting/connecting/running`).
A `failed` job is invisible to it: re-issuing goLive for the same
`clientUserId + publisherSessionId` creates a **fresh job with a new jobId** — full pipeline,
new broadcasts, recording re-armed. Retry-after-failure is safe and is not a no-op. Treat the
new `jobId` as authoritative; reset per-job state (comment cursors, etc.).

## 2026-08-15 — deployed (commit `aafe98c`) — ⚠️ retroactive entry

### REST: participant guard on live mutations (breaking for pk-mode callers)
`enforceBoundJobParticipant` added to all `/jobs/:id` mutation routes: `POST /:id/meta`,
`DELETE /:id`, `POST /:id/comments`, `PATCH /:id/overlay`, `POST /:id/state`, `POST /:id/mode`,
`POST /:id/live-layout`, `POST /:id/telemetry`, `POST /:id/overlay-images`,
`PATCH /:id/destinations/:idx`.
**Breaking as shipped:** the guard read only the token-bound identity; publishable-key callers
send a body/query actor and were rejected **401 `CREATOR_SESSION_REQUIRED` silently**. This
broke fomo web's live coin switching for a day. Fixed 2026-08-16 (`d3399b3`): pk-mode callers
may supply `clientUserId` (body) or `?userId=` (query); the claim is verified against the
job's owner/collaborator set. GETs are unaffected (read scope only, no participant check).
**Lesson (both teams):** breaking REST changes get a same-day line in this file. This entry is
retroactive because that rule didn't exist yet.

## Withdrawn / never-shipped (do NOT build against)

- **`job.interrupted`** — described in a 2026-08-05 handoff as shipping with graceful deploys.
  **Never implemented.** No emitter, no such job status. What actually ships: SIGTERM drain
  waits for live jobs to end naturally (status → normal `job.stopped`), and dead-worker cases
  fire `job.failed`. When genuinely implemented, the literal status string + resume semantics
  will appear here first.
