name: livestream description: Go-live from a LiveKit room to MUX via RTMP egress — supports MUX, YouTube, Twitch, and custom RTMP destinations. Auto-archives to MUX VOD when stream ends. Use this skill when the user says "add livestream", "go live", "stream to mux", "rtmp stream", "broadcast", or "live streaming". author: "@mattwoodco" version: 1.0.0 created: 2026-02-18 dependencies: [video-room, stream-mux, recording, env-config]
Livestream (LiveKit Egress + MUX)
Livestreaming from a LiveKit room to external platforms via RTMP egress. Creates a MUX live stream, starts an RTMP egress from the LiveKit room to the MUX ingest URL, and auto-archives to a MUX VOD asset when the stream ends. Also supports custom RTMP destinations like YouTube Live and Twitch.
Prerequisites
- Next.js app with
src/directory and App Router video-roomskill applied (provideslivekit-server-sdk, LiveKit server configuration)stream-muxskill applied (provides@mux/mux-nodeclient, MUX API credentials)recordingskill applied (provides therecordingsschema and Egress patterns)env-configskill applied (provides Zod env validation)
Installation
No additional packages required. Uses livekit-server-sdk from the video-room skill and @mux/mux-node from the stream-mux skill.
Environment Variables
Uses existing environment variables from dependencies:
# LiveKit (from video-room)
LIVEKIT_API_KEY=your_api_key
LIVEKIT_API_SECRET=your_api_secret
NEXT_PUBLIC_LIVEKIT_URL=wss://your-project.livekit.cloud
# MUX (from stream-mux)
MUX_TOKEN_ID=your_mux_token_id
MUX_TOKEN_SECRET=your_mux_token_secret
Add to src/env.ts server schema (if not already present):
MUX_TOKEN_ID: z.string().min(1),
MUX_TOKEN_SECRET: z.string().min(1),
What Gets Created
src/
├── lib/
│ ├── video/
│ │ ├── livestream.ts # startLivestream, stopLivestream, getLivestreamStatus
│ │ └── stream-destinations.ts # StreamDestination type, RTMP URL helpers
│ └── db/
│ └── schema/
│ └── livestreams.ts # Drizzle schema: livestreams table
└── app/
└── api/
└── video/
└── livestream/
├── route.ts # POST go live, DELETE stop stream
└── status/
└── route.ts # GET current stream status + viewer count
Setup Steps
Step 1: Create src/lib/video/stream-destinations.ts
export type StreamDestinationType = "mux" | "youtube" | "twitch" | "custom";
export type StreamDestination = {
type: StreamDestinationType;
name: string;
rtmpUrl: string;
streamKey: string;
};
/**
* Constructs the full RTMP URL for a MUX live stream.
* MUX RTMP ingest endpoint: rtmp://global-live.mux.com:5222/app/{stream_key}
*/
export function buildMuxRtmpUrl(streamKey: string): string {
return `rtmp://global-live.mux.com:5222/app/${streamKey}`;
}
/**
* Constructs the full RTMP URL for YouTube Live.
* YouTube RTMP ingest: rtmp://a.rtmp.youtube.com/live2/{stream_key}
*/
export function buildYouTubeRtmpUrl(streamKey: string): string {
return `rtmp://a.rtmp.youtube.com/live2/${streamKey}`;
}
/**
* Constructs the full RTMP URL for Twitch.
* Twitch RTMP ingest: rtmp://live.twitch.tv/app/{stream_key}
* Use the nearest ingest server for lower latency.
*/
export function buildTwitchRtmpUrl(
streamKey: string,
ingestServer?: string
): string {
const server = ingestServer ?? "live.twitch.tv";
return `rtmp://${server}/app/${streamKey}`;
}
/**
* Builds a StreamDestination from a destination type and stream key.
*/
export function buildDestination(
type: StreamDestinationType,
streamKey: string,
options?: { name?: string; customRtmpUrl?: string; twitchIngestServer?: string }
): StreamDestination {
const name = options?.name ?? type;
switch (type) {
case "mux":
return {
type,
name,
rtmpUrl: buildMuxRtmpUrl(streamKey),
streamKey,
};
case "youtube":
return {
type,
name,
rtmpUrl: buildYouTubeRtmpUrl(streamKey),
streamKey,
};
case "twitch":
return {
type,
name,
rtmpUrl: buildTwitchRtmpUrl(streamKey, options?.twitchIngestServer),
streamKey,
};
case "custom":
if (!options?.customRtmpUrl) {
throw new Error("customRtmpUrl is required for custom destinations");
}
return {
type,
name,
rtmpUrl: `${options.customRtmpUrl}/${streamKey}`,
streamKey,
};
}
}
/**
* Constructs the full RTMP URL with stream key appended, ready for LiveKit egress.
* LiveKit expects the full URL including the stream key.
*/
export function getFullRtmpUrl(destination: StreamDestination): string {
// For MUX, the rtmpUrl already includes the stream key in the path
return destination.rtmpUrl;
}
/**
* Gets a MUX HLS playback URL from a playback ID.
*/
export function getMuxPlaybackUrl(playbackId: string): string {
return `https://stream.mux.com/${playbackId}.m3u8`;
}
/**
* Gets a MUX thumbnail URL from a playback ID.
*/
export function getMuxThumbnailUrl(
playbackId: string,
options?: { width?: number; height?: number; time?: number }
): string {
const params = new URLSearchParams();
if (options?.width) params.set("width", String(options.width));
if (options?.height) params.set("height", String(options.height));
if (options?.time) params.set("time", String(options.time));
const queryString = params.toString();
return `https://image.mux.com/${playbackId}/thumbnail.jpg${queryString ? `?${queryString}` : ""}`;
}
Step 2: Create src/lib/db/schema/livestreams.ts
import {
pgTable,
text,
timestamp,
uuid,
} from "drizzle-orm/pg-core";
export const livestreams = pgTable("livestreams", {
id: uuid("id").primaryKey().defaultRandom(),
roomName: text("room_name").notNull(),
egressId: text("egress_id"),
muxLiveStreamId: text("mux_live_stream_id"),
muxPlaybackId: text("mux_playback_id"),
muxStreamKey: text("mux_stream_key"),
rtmpUrl: text("rtmp_url"),
status: text("status", {
enum: ["idle", "starting", "active", "stopping", "completed", "failed"],
})
.notNull()
.default("idle"),
muxAssetId: text("mux_asset_id"),
startedAt: timestamp("started_at", { withTimezone: true }),
endedAt: timestamp("ended_at", { withTimezone: true }),
userId: text("user_id").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
});
export type Livestream = typeof livestreams.$inferSelect;
export type NewLivestream = typeof livestreams.$inferInsert;
Step 3: Add export to src/lib/db/schema/index.ts
export * from "./livestreams";
Step 4: Create src/lib/video/livestream.ts
import { EgressClient, EncodingOptions, StreamOutput, StreamProtocol } from "livekit-server-sdk";
import Mux from "@mux/mux-node";
import { db } from "@/lib/db";
import { livestreams, type Livestream } from "@/lib/db/schema";
import { eq, and, desc } from "drizzle-orm";
import {
buildMuxRtmpUrl,
getMuxPlaybackUrl,
getFullRtmpUrl,
} from "./stream-destinations";
import type { StreamDestination } from "./stream-destinations";
type MuxLiveStream = {
id: string;
stream_key: string;
playback_ids?: Array<{ id: string; policy: string }>;
status: string;
};
type MuxAsset = {
id: string;
playback_ids?: Array<{ id: string; policy: string }>;
status: string;
duration?: number;
};
function getEgressClient(): EgressClient {
const livekitUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
const apiKey = process.env.LIVEKIT_API_KEY;
const apiSecret = process.env.LIVEKIT_API_SECRET;
if (!livekitUrl || !apiKey || !apiSecret) {
throw new Error(
"Missing NEXT_PUBLIC_LIVEKIT_URL, LIVEKIT_API_KEY, or LIVEKIT_API_SECRET"
);
}
// Convert wss:// to https:// for REST API
const httpUrl = livekitUrl
.replace("wss://", "https://")
.replace("ws://", "http://");
return new EgressClient(httpUrl, apiKey, apiSecret);
}
function getMuxClient(): Mux {
const tokenId = process.env.MUX_TOKEN_ID;
const tokenSecret = process.env.MUX_TOKEN_SECRET;
if (!tokenId || !tokenSecret) {
throw new Error("Missing MUX_TOKEN_ID or MUX_TOKEN_SECRET");
}
return new Mux({ tokenId, tokenSecret });
}
type StartLivestreamOptions = {
roomName: string;
/** Additional RTMP destinations beyond MUX */
additionalDestinations?: StreamDestination[];
/** MUX latency mode: "standard" for normal, "low" for low-latency */
latencyMode?: "standard" | "low";
/** Max continuous duration in seconds (default: 43200 = 12h) */
maxDuration?: number;
};
type StartLivestreamResult = {
livestreamId: string;
egressId: string;
muxLiveStreamId: string;
muxPlaybackId: string;
playbackUrl: string;
status: string;
};
type StopLivestreamResult = {
livestreamId: string;
status: string;
muxAssetId: string | null;
vodPlaybackId: string | null;
};
type LivestreamStatus = {
livestreamId: string;
roomName: string;
status: string;
muxPlaybackId: string | null;
playbackUrl: string | null;
viewerCount: number;
startedAt: Date | null;
duration: number | null;
};
export async function startLivestream(
options: StartLivestreamOptions,
userId: string
): Promise<StartLivestreamResult> {
const { roomName, additionalDestinations, latencyMode, maxDuration } = options;
// Step 1: Create a MUX live stream
const mux = getMuxClient();
const muxLiveStream = await mux.video.liveStreams.create({
playback_policy: ["public"],
new_asset_settings: {
playback_policy: ["public"],
},
latency_mode: latencyMode ?? "standard",
max_continuous_duration: maxDuration ?? 43200,
});
const muxLiveStreamId = muxLiveStream.id;
const muxStreamKey = muxLiveStream.stream_key;
const muxPlaybackId = muxLiveStream.playback_ids?.[0]?.id ?? "";
if (!muxStreamKey) {
throw new Error("MUX did not return a stream key");
}
const muxRtmpUrl = buildMuxRtmpUrl(muxStreamKey);
// Collect all RTMP URLs for multi-destination streaming
const rtmpUrls = [muxRtmpUrl];
if (additionalDestinations) {
for (const dest of additionalDestinations) {
rtmpUrls.push(getFullRtmpUrl(dest));
}
}
// Step 2: Start LiveKit RTMP egress
const egressClient = getEgressClient();
const streamOutput = new StreamOutput({
protocol: StreamProtocol.RTMP,
urls: rtmpUrls,
});
const egressInfo = await egressClient.startRoomCompositeEgress(
roomName,
{
stream: streamOutput,
},
{
layout: "grid",
encodingOptions: new EncodingOptions({
width: 1920,
height: 1080,
videoBitrate: 4500000,
audioBitrate: 128000,
}),
}
);
const egressId = egressInfo.egressId;
// Step 3: Store livestream record
const [livestream] = await db
.insert(livestreams)
.values({
roomName,
egressId,
muxLiveStreamId,
muxPlaybackId,
muxStreamKey,
rtmpUrl: muxRtmpUrl,
status: "active",
startedAt: new Date(),
userId,
})
.returning();
const playbackUrl = getMuxPlaybackUrl(muxPlaybackId);
return {
livestreamId: livestream.id,
egressId,
muxLiveStreamId,
muxPlaybackId,
playbackUrl,
status: "active",
};
}
export async function stopLivestream(
livestreamId: string,
userId: string
): Promise<StopLivestreamResult> {
const [livestream] = await db
.select()
.from(livestreams)
.where(
and(eq(livestreams.id, livestreamId), eq(livestreams.userId, userId))
)
.limit(1);
if (!livestream) {
throw new Error("Livestream not found");
}
if (livestream.status !== "active" && livestream.status !== "starting") {
throw new Error(
`Livestream is not active (status: ${livestream.status})`
);
}
// Update status to stopping
await db
.update(livestreams)
.set({ status: "stopping", updatedAt: new Date() })
.where(eq(livestreams.id, livestreamId));
try {
// Step 1: Stop LiveKit egress
if (livestream.egressId) {
const egressClient = getEgressClient();
await egressClient.stopEgress(livestream.egressId);
}
// Step 2: Signal MUX to complete the live stream
// This triggers auto-archival to a VOD asset
const mux = getMuxClient();
let muxAssetId: string | null = null;
let vodPlaybackId: string | null = null;
if (livestream.muxLiveStreamId) {
await mux.video.liveStreams.complete(livestream.muxLiveStreamId);
// Wait briefly for MUX to create the asset, then check
// The asset is created asynchronously — we store what we can now
// and the status endpoint will reflect the final state
try {
const muxLiveStreamInfo = await mux.video.liveStreams.retrieve(
livestream.muxLiveStreamId
);
// MUX creates an asset from the live stream recording
// The asset ID is available on the live stream object after completion
const recentAssets = muxLiveStreamInfo.recent_asset_ids;
if (recentAssets && recentAssets.length > 0) {
muxAssetId = recentAssets[recentAssets.length - 1] ?? null;
// Try to get VOD playback ID from the asset
if (muxAssetId) {
try {
const asset = await mux.video.assets.retrieve(muxAssetId);
vodPlaybackId = asset.playback_ids?.[0]?.id ?? null;
} catch {
// Asset may still be processing
}
}
}
} catch {
// MUX API call failed — asset info will be available later
}
}
// Step 3: Update database record
await db
.update(livestreams)
.set({
status: "completed",
endedAt: new Date(),
muxAssetId,
updatedAt: new Date(),
})
.where(eq(livestreams.id, livestreamId));
return {
livestreamId,
status: "completed",
muxAssetId,
vodPlaybackId,
};
} catch (error) {
await db
.update(livestreams)
.set({ status: "failed", updatedAt: new Date() })
.where(eq(livestreams.id, livestreamId));
throw error;
}
}
export async function getLivestreamStatus(
livestreamId: string,
userId: string
): Promise<LivestreamStatus> {
const [livestream] = await db
.select()
.from(livestreams)
.where(
and(eq(livestreams.id, livestreamId), eq(livestreams.userId, userId))
)
.limit(1);
if (!livestream) {
throw new Error("Livestream not found");
}
let viewerCount = 0;
let currentStatus = livestream.status;
// Fetch live viewer count from MUX if stream is active
if (
livestream.muxLiveStreamId &&
(livestream.status === "active" || livestream.status === "starting")
) {
try {
const mux = getMuxClient();
const muxStream = await mux.video.liveStreams.retrieve(
livestream.muxLiveStreamId
);
// Map MUX status to our status
if (muxStream.status === "active") {
currentStatus = "active";
} else if (muxStream.status === "idle") {
currentStatus = "starting";
}
// MUX does not provide viewer count directly on the live stream object.
// Use MUX Data API for real-time viewer counts if MUX Data is enabled.
// For now, we return 0 and the consumer can integrate MUX Data separately.
viewerCount = 0;
} catch {
// MUX API call failed — use stored status
}
}
const duration =
livestream.startedAt
? (Date.now() - livestream.startedAt.getTime()) / 1000
: null;
const playbackUrl = livestream.muxPlaybackId
? getMuxPlaybackUrl(livestream.muxPlaybackId)
: null;
return {
livestreamId: livestream.id,
roomName: livestream.roomName,
status: currentStatus,
muxPlaybackId: livestream.muxPlaybackId,
playbackUrl,
viewerCount,
startedAt: livestream.startedAt,
duration,
};
}
export async function getActiveLivestream(
roomName: string,
userId: string
): Promise<Livestream | null> {
const [livestream] = await db
.select()
.from(livestreams)
.where(
and(
eq(livestreams.roomName, roomName),
eq(livestreams.userId, userId),
eq(livestreams.status, "active")
)
)
.limit(1);
return livestream ?? null;
}
export async function listLivestreams(
userId: string,
options?: { roomName?: string; limit?: number }
): Promise<Livestream[]> {
const conditions = [eq(livestreams.userId, userId)];
if (options?.roomName) {
conditions.push(eq(livestreams.roomName, options.roomName));
}
return db
.select()
.from(livestreams)
.where(and(...conditions))
.orderBy(desc(livestreams.createdAt))
.limit(options?.limit ?? 50);
}
// Re-export types
export type { Livestream } from "@/lib/db/schema";
export type {
StreamDestination,
StreamDestinationType,
} from "./stream-destinations";
Step 5: Create src/app/api/video/livestream/route.ts
import { NextResponse } from "next/server";
import { z } from "zod/v4";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import {
startLivestream,
stopLivestream,
getActiveLivestream,
listLivestreams,
} from "@/lib/video/livestream";
import { buildDestination } from "@/lib/video/stream-destinations";
import type { StreamDestination, StreamDestinationType } from "@/lib/video/stream-destinations";
const destinationSchema = z.object({
type: z.enum(["youtube", "twitch", "custom"]),
name: z.string().optional(),
streamKey: z.string().min(1),
customRtmpUrl: z.string().url().optional(),
twitchIngestServer: z.string().optional(),
});
const startLivestreamSchema = z.object({
roomName: z.string().min(1),
latencyMode: z.enum(["standard", "low"]).default("standard"),
maxDuration: z.number().int().positive().optional(),
additionalDestinations: z.array(destinationSchema).optional(),
});
const stopLivestreamSchema = z.object({
roomName: z.string().min(1),
});
export async function POST(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const config = startLivestreamSchema.parse(body);
// Check if there's already an active livestream for this room
const existing = await getActiveLivestream(
config.roomName,
session.user.id
);
if (existing) {
return NextResponse.json(
{ error: "Room already has an active livestream" },
{ status: 409 }
);
}
// Build additional destinations
const additionalDestinations: StreamDestination[] = [];
if (config.additionalDestinations) {
for (const dest of config.additionalDestinations) {
additionalDestinations.push(
buildDestination(
dest.type as StreamDestinationType,
dest.streamKey,
{
name: dest.name,
customRtmpUrl: dest.customRtmpUrl,
twitchIngestServer: dest.twitchIngestServer,
}
)
);
}
}
const result = await startLivestream(
{
roomName: config.roomName,
latencyMode: config.latencyMode,
maxDuration: config.maxDuration,
additionalDestinations:
additionalDestinations.length > 0
? additionalDestinations
: undefined,
},
session.user.id
);
return NextResponse.json(result, { status: 201 });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to start livestream";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function DELETE(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const { roomName } = stopLivestreamSchema.parse(body);
// Find the active livestream for this room
const active = await getActiveLivestream(roomName, session.user.id);
if (!active) {
return NextResponse.json(
{ error: "No active livestream found for this room" },
{ status: 404 }
);
}
const result = await stopLivestream(active.id, session.user.id);
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to stop livestream";
return NextResponse.json({ error: message }, { status: 500 });
}
}
export async function GET(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const roomName = searchParams.get("roomName") ?? undefined;
const limitParam = searchParams.get("limit");
const limit = limitParam ? parseInt(limitParam, 10) : undefined;
const result = await listLivestreams(session.user.id, {
roomName,
limit,
});
return NextResponse.json({ livestreams: result });
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to list livestreams";
return NextResponse.json({ error: message }, { status: 500 });
}
}
Step 6: Create src/app/api/video/livestream/status/route.ts
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import {
getLivestreamStatus,
getActiveLivestream,
} from "@/lib/video/livestream";
export async function GET(request: Request) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(request.url);
const livestreamId = searchParams.get("id");
const roomName = searchParams.get("roomName");
if (!livestreamId && !roomName) {
return NextResponse.json(
{ error: "Provide either id or roomName query parameter" },
{ status: 400 }
);
}
// Find by ID or active stream for room
let targetId: string;
if (livestreamId) {
targetId = livestreamId;
} else {
const active = await getActiveLivestream(roomName!, session.user.id);
if (!active) {
return NextResponse.json(
{ error: "No active livestream found for this room" },
{ status: 404 }
);
}
targetId = active.id;
}
const status = await getLivestreamStatus(targetId, session.user.id);
return NextResponse.json(status);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to get livestream status";
return NextResponse.json({ error: message }, { status: 500 });
}
}
Step 7: Push database schema
bunx drizzle-kit push
Usage
Go live from a room (MUX only)
const response = await fetch("/api/video/livestream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomName: "my-room",
latencyMode: "low",
}),
});
const {
livestreamId,
muxPlaybackId,
playbackUrl, // HLS URL for viewers
} = await response.json();
Go live with multiple destinations
const response = await fetch("/api/video/livestream", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
roomName: "my-room",
latencyMode: "standard",
additionalDestinations: [
{
type: "youtube",
name: "YouTube Live",
streamKey: "xxxx-xxxx-xxxx-xxxx",
},
{
type: "twitch",
name: "Twitch",
streamKey: "live_xxxxxxxx",
},
],
}),
});
Get stream status with viewer count
// By livestream ID
const response = await fetch(`/api/video/livestream/status?id=${livestreamId}`);
// By room name (finds active stream)
const response = await fetch(
`/api/video/livestream/status?roomName=my-room`
);
const {
status, // "active", "starting", "completed", etc.
playbackUrl, // HLS playback URL
viewerCount, // Current viewer count from MUX
duration, // Stream duration in seconds
} = await response.json();
Stop livestream
const response = await fetch("/api/video/livestream", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roomName: "my-room" }),
});
const {
status, // "completed"
muxAssetId, // VOD asset created from the stream
vodPlaybackId, // Playback ID for the VOD recording
} = await response.json();
List all livestreams
const response = await fetch("/api/video/livestream?roomName=my-room&limit=10");
const { livestreams } = await response.json();
Server-side usage
import {
startLivestream,
stopLivestream,
getLivestreamStatus,
} from "@/lib/video/livestream";
import { buildDestination } from "@/lib/video/stream-destinations";
// Start with MUX + YouTube
const result = await startLivestream(
{
roomName: "event-room",
latencyMode: "low",
additionalDestinations: [
buildDestination("youtube", "xxxx-xxxx-xxxx-xxxx", {
name: "YouTube Live",
}),
],
},
userId
);
// Check status
const status = await getLivestreamStatus(result.livestreamId, userId);
// Stop — auto-archives to MUX VOD
const stopped = await stopLivestream(result.livestreamId, userId);
console.log("VOD asset:", stopped.muxAssetId);
Embed playback for viewers
// Using MUX Player (from stream-mux skill)
import MuxPlayer from "@mux/mux-player-react";
function LiveViewer({ playbackId }: { playbackId: string }) {
return (
<MuxPlayer
playbackId={playbackId}
streamType="live"
autoPlay="muted"
className="w-full aspect-video"
/>
);
}
Use stream destination helpers
import {
buildDestination,
buildMuxRtmpUrl,
getMuxPlaybackUrl,
getMuxThumbnailUrl,
} from "@/lib/video/stream-destinations";
// Build a YouTube destination
const ytDest = buildDestination("youtube", "xxxx-xxxx-xxxx-xxxx", {
name: "My YouTube Channel",
});
// Get MUX playback URL
const hlsUrl = getMuxPlaybackUrl("abc123");
// => "https://stream.mux.com/abc123.m3u8"
// Get MUX thumbnail
const thumbUrl = getMuxThumbnailUrl("abc123", {
width: 640,
height: 360,
time: 10,
});
API Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/video/livestream |
Go live — creates MUX stream + starts RTMP egress |
| DELETE | /api/video/livestream |
Stop livestream by room name |
| GET | /api/video/livestream?roomName=&limit= |
List user's livestreams |
| GET | /api/video/livestream/status?id= |
Get stream status, viewer count, playback URL |
| GET | /api/video/livestream/status?roomName= |
Get active stream status by room |
Acceptance Criteria
- POST
/api/video/livestreamcreates a MUX live stream and starts LiveKit RTMP egress - MUX live stream is created with
playback_policy: ["public"]andnew_asset_settings - LiveKit RTMP egress sends to MUX ingest URL (
rtmp://global-live.mux.com:5222/app/{key}) - Additional RTMP destinations (YouTube, Twitch, custom) are passed to egress
- DELETE
/api/video/livestreamstops the egress and signals MUX to complete - On stop, MUX auto-archives the stream to a VOD asset via
new_asset_settings - GET
/api/video/livestream/statusreturns stream status and MUX playback ID -
livestreamstable in Postgres has correct schema with all columns - Duplicate active livestream for same room returns 409 Conflict
- All routes require authentication
- Stream destination helpers correctly build RTMP URLs for MUX, YouTube, Twitch
- No
anycasts anywhere -
tscpasses with no errors - Build succeeds
Troubleshooting
"Room not found" when starting livestream
Symptoms: LiveKit RTMP egress fails to start.
Cause: The LiveKit room must have active participants for egress to start.
Fix: Ensure at least one participant has joined the room before going live.
MUX stream stuck in "idle" status
Symptoms: MUX live stream created but never becomes "active".
Cause: RTMP data is not reaching MUX. The egress may have failed or the RTMP URL is incorrect.
Fix: Verify the RTMP URL format: rtmp://global-live.mux.com:5222/app/{stream_key}. Check LiveKit egress status in the LiveKit dashboard.
VOD asset not created after stopping
Symptoms: muxAssetId is null after stopping the stream.
Cause: MUX creates the VOD asset asynchronously after the live stream completes. It may take a few seconds.
Fix: The asset creation is triggered by mux.video.liveStreams.complete(). Check the MUX dashboard for the asset. You can also set up a MUX webhook to be notified when video.asset.ready fires.
Multi-destination stream drops one destination
Symptoms: One of the RTMP destinations stops receiving while others continue.
Cause: LiveKit egress may drop a destination if the RTMP connection is unstable.
Fix: Check the RTMP URLs and stream keys for each destination. Ensure the egress has sufficient bandwidth. LiveKit Cloud automatically retries dropped connections.
RTMP connection refused
Symptoms: Egress fails immediately with a connection error.
Cause: Firewall or network configuration blocking outbound RTMP (port 1935 or custom port).
Fix: Ensure your LiveKit server can reach the RTMP endpoints. MUX uses port 5222, YouTube uses 1935, Twitch uses 1935. For self-hosted LiveKit, check firewall rules.
Stream quality is poor
Symptoms: Viewers see low resolution or choppy video.
Cause: Default encoding settings may be too high for the available bandwidth.
Fix: Adjust the encoding options in startRoomCompositeEgress. For lower bandwidth, reduce to 720p:
encodingOptions: new EncodingOptions({
width: 1280,
height: 720,
videoBitrate: 2500000,
audioBitrate: 128000,
})