A working model of the three hard parts of a creator-first streaming backend: a signed Mux playback URL with the key kept server-side, a watch-time ledger that can split revenue by creator later, and the review to live content flow driven by Mux webhooks. Stack: TypeScript, Next.js API routes, Supabase, Mux, Vercel.
The browser calls GET /api/playback/:episodeId with its Supabase session. The route checks the viewer, the episode status and the kids-profile rule, then signs a short-lived JWT with the private key from the Vercel environment. Only the token goes back.
// app/api/playback/[episodeId]/route.ts (server only)
import Mux from '@mux/mux-node'
const mux = new Mux({ jwtSigningKey: process.env.MUX_SIGNING_KEY_ID,
jwtPrivateKey: process.env.MUX_SIGNING_PRIVATE_KEY })
export async function GET(req, { params }) {
const user = await requireUser(req) // Supabase Auth
const ep = await db.episode(params.episodeId)
if (!ep || ep.status !== 'live') return json(404)
if (user.profile.kind === 'kid' && !ep.kids_safe) return json(403)
const token = await mux.jwt.signPlaybackId(ep.mux_playback_id,
{ type: 'video', expiration: '2h', params: { user: user.id } })
return json({ url: `https://stream.mux.com/${ep.mux_playback_id}.m3u8?token=${token}` })
}
-- states: draft → processing → review → live (+ errored)
create table episodes (
id uuid primary key default gen_random_uuid(),
series_id uuid references series(id),
mux_upload_id text, mux_asset_id text unique, mux_playback_id text,
status text not null default 'draft'
check (status in ('draft','processing','review','live','errored')),
updated_at timestamptz default now());
-- webhook: verify Mux-Signature header (HMAC) → idempotent by event id
-- video.asset.ready → status = 'review' (creator/admin then flips to 'live')
-- video.asset.errored → status = 'errored' + reason stored
Store facts, not totals. One row per heartbeat (user, episode, seconds, day), with the creator id copied onto the row at write time so a later ownership change never rewrites history. Earnings stay a placeholder until payouts exist; the split is just a query.
create table watch_events ( id bigserial primary key, user_id uuid not null, profile_id uuid not null, episode_id uuid not null, creator_id uuid not null, -- denormalised on purpose seconds int not null check (seconds between 1 and 60), session_id uuid not null, seq int not null, day date not null default current_date, unique (session_id, seq)); -- retries never double count -- creator dashboard select creator_id, count(distinct session_id) views, sum(seconds)/3600.0 hours from watch_events where day >= now() - interval '30 days' group by 1; -- revenue split for a pool P: P * sum(seconds of creator) / sum(seconds of all)
| Creator | Views | Watch time, min | Share | Est. earnings, EUR |
|---|
| Auth and profiles | Supabase Auth, profiles with kind viewer / kid / creator, row level security on every table |
| Catalog | series and episodes tied to Mux playback ids |
| Playback | signed endpoint above, keys only in server env |
| Webhooks | asset ready / errored, signature checked, idempotent |
| Events | watch_events ledger, batched from the player every 30 s |
| Creator API | views, watch time, earnings placeholder; content status flow |
| Frontend | plugs into the existing preview through typed route handlers, no UI work from us |
GuardLabs demo. Browser-only model with generated keys and fake data; nothing is sent anywhere.