DEV Community

Mason K
Mason K

Posted on

Ship a 'Go Live' button: OBS in, LL-HLS out, webhooks in between

TL;DR

We're adding live streaming to a SaaS dashboard: a backend endpoint that creates a stream, OBS as the broadcaster over RTMPS, LL-HLS playback with hls.js, and a webhook handler that keeps the UI honest. Working "go live" flow in an afternoon.

📦 Code: github.com/USER/repo (replace before publishing)

Webinars, coaching sessions, company town halls: sooner or later your product gets the "can users go live?" ticket. The hard parts (ingest servers, transcoding, CDN delivery) are exactly the parts you should not build. We'll use FastPix as the managed layer here; the same flow works nearly line-for-line on Mux, Cloudflare Stream, or api.video.

What we're building:

  1. A backend endpoint that creates a live stream and returns a stream key
  2. An OBS setup broadcasters can follow in two minutes
  3. A viewer page playing LL-HLS with hls.js
  4. A webhook handler that flips the webinar between scheduled → live → ended

1. Create the stream server-side 🛠️

You need API credentials (Access Token ID + Secret Key). FastPix uses Basic auth on the server API. Node 20.x, plain fetch, no SDK required (though official Node.js/Python/Go/Ruby/PHP/Java/C# SDKs exist if you prefer).

// server/routes/streams.js
import { Router } from "express";
const router = Router();

const AUTH = "Basic " + Buffer.from(
  `${process.env.FP_TOKEN_ID}:${process.env.FP_SECRET}`
).toString("base64");

router.post("/webinars/:id/stream", async (req, res) => {
  const r = await fetch("https://api.fastpix.io/v1/live/streams", {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: AUTH },
    body: JSON.stringify({
      playbackSettings: { accessPolicy: "public" },
    }),
  });
  if (!r.ok) return res.status(502).json({ error: "stream create failed" });

  const stream = await r.json();
  // persist against your webinar row:
  // streamId, streamKey (SECRET!), playbackId
  await db.webinar.update(req.params.id, {
    streamId: stream.streamId,
    streamKey: stream.streamKey,
    playbackId: stream.playbackIds?.[0]?.id,
    status: "scheduled",
  });
  res.json({ ok: true });
});

export default router;
Enter fullscreen mode Exit fullscreen mode

⚠️ Note: the stream key is a credential. Anyone who has it can broadcast as your customer. Show it once in the UI, store it encrypted, and offer a "reset key" button.

Two IDs come back and they have opposite audiences:

Value Who gets it Purpose
streamKey The broadcaster only Authenticates ingest
playbackId Every viewer Builds the playback URL

2. Point OBS at it 🎥

Your broadcasters will use OBS or something that behaves like it. RTMPS is the default ingest protocol (SRT is there too if your users broadcast from unreliable networks). The setup you'll paste into your help docs:

SettingsStream
  Service:    Custom
  Server:     rtmps://<ingest host from the dashboard/API response>
  Stream Key: <streamKey from step 1>
Enter fullscreen mode Exit fullscreen mode

Hit "Start Streaming" in OBS. Within a few seconds the platform starts transcoding into an adaptive ladder. You don't configure renditions; ABR generation is automatic.

3. Play it with hls.js 📺

Playback is a standard LL-HLS manifest:

https://stream.fastpix.io/<playbackId>.m3u8
Enter fullscreen mode Exit fullscreen mode

Safari plays HLS natively. Everything else needs MSE, which is what hls.js (1.6.16 at the time of writing) is for:

// app/components/LivePlayer.jsx
import { useEffect, useRef } from "react";
import Hls from "hls.js";

export function LivePlayer({ playbackId }) {
  const videoRef = useRef(null);
  const src = `https://stream.fastpix.io/${playbackId}.m3u8`;

  useEffect(() => {
    const video = videoRef.current;
    if (video.canPlayType("application/vnd.apple.mpegurl")) {
      video.src = src; // Safari
      return;
    }
    const hls = new Hls({ lowLatencyMode: true });
    hls.loadSource(src);
    hls.attachMedia(video);
    return () => hls.destroy();
  }, [src]);

  return <video ref={videoRef} controls playsInline muted autoPlay />;
}
Enter fullscreen mode Exit fullscreen mode

FastPix also ships a prebuilt web/iOS/Android player if you'd rather not own the player surface. For paid content, switch accessPolicy off public and mint short-lived JWTs server-side; don't ship permanent public URLs for gated streams.

💡 Tip: LL-HLS gets you latency in the low seconds. If you need sub-second conversational latency (auctions, telehealth), that's WebRTC territory and a different architecture.

4. Webhooks drive the UI, not polling 🔔

The stream has a lifecycle your app needs to mirror: the encoder connects, the stream goes active, the encoder drops, maybe reconnects, the broadcast ends, the recording becomes ready. Every managed platform emits webhooks for these.

// server/routes/webhooks.js
router.post("/webhooks/video", express.json(), async (req, res) => {
  const { type, data } = req.body;

  switch (type) {
    case "video.live_stream.active":
      await db.webinarByStream(data.streamId).update({ status: "live" });
      break;
    case "video.live_stream.disconnected":
      // encoder dropped; reconnect window keeps the session alive
      await db.webinarByStream(data.streamId).update({ status: "reconnecting" });
      break;
    case "video.live_stream.idle":
      await db.webinarByStream(data.streamId).update({ status: "ended" });
      break;
    case "video.asset.ready":
      // live-to-VOD recording is ready: attach the replay
      await db.webinarByStream(data.streamId).update({
        status: "replay_ready",
        replayPlaybackId: data.playbackId,
      });
      break;
  }
  res.sendStatus(200); // ack fast, do heavy work async
});
Enter fullscreen mode Exit fullscreen mode

Event names vary slightly per platform; check the webhook reference for exact types and payloads.

Two rules that save you from 2 AM pages:

  • Idempotency. Webhooks arrive duplicated and out of order. Make every transition safe to replay (e.g., ignore active if you're already ended).
  • Distinguish "disconnected" from "ended". Presenters drop off hotel Wi-Fi constantly. The platform's reconnect window keeps the stream session alive through short drops; your UI should show "reconnecting", not kill the room.

The video.asset.ready branch is the sleeper feature: live-to-VOD recording means every broadcast automatically becomes an on-demand replay. Wire it to the same webinar page and your event content compounds instead of evaporating.

Test the whole loop end to end ✅

$ curl -s -X POST localhost:3000/webinars/42/stream | jq
{ "ok": true }

# start OBS → watch your webhook log
POST /webhooks/video  type=video.live_stream.active
# viewer page flips to LIVE

# stop OBS, wait out the reconnect window
POST /webhooks/video  type=video.live_stream.idle
POST /webhooks/video  type=video.asset.ready
# webinar page now shows the replay
Enter fullscreen mode Exit fullscreen mode

If the viewer page never flips to LIVE, it's almost always one of: webhook endpoint not publicly reachable (use a tunnel in dev), wrong stream key in OBS, or your handler acking before persisting.

Production checklist before real customers 📋

  • [ ] Stream key shown once, stored encrypted, reset button wired
  • [ ] Webhook endpoint verified with the platform's signing secret (don't accept unsigned POSTs)
  • [ ] Idempotent transitions tested by replaying the same webhook twice
  • [ ] Kill-the-encoder drill: stop OBS mid-stream, confirm UI shows "reconnecting", restart OBS, confirm recovery
  • [ ] Replay page renders from video.asset.ready without manual steps
  • [ ] Signed playback (JWT) for anything gated; public URLs only for genuinely public streams
  • [ ] A "stream health" admin view (even just raw webhook log) so support can answer "is it us or them"

The kill-the-encoder drill is the one teams skip and the one that matters most. Presenters lose connectivity during real events constantly; if your UI slams to "ended" on a blip, the audience leaves and doesn't come back.

What's next

  • Signed playback with JWTs for paid/gated streams, and simulcast if your users also want to hit YouTube/Twitch simultaneously; both are API-level features, no re-architecture.
  • The full API surface used here is in the FastPix API reference. New accounts get $25 in credits without a card, which is plenty for this build. And if you're on Mux, Cloudflare Stream, or api.video instead: the create-stream/webhook/playback pattern in this post maps over almost 1:1.

Disclosure: this tutorial was prepared for FastPix, whose team reviews and publishes it; the patterns shown transfer to any managed video platform.

Top comments (0)