npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

convex-livekit

v0.0.9

Published

Sync LiveKit rooms, participants, tracks, egress, and ingress into Convex reactively, and manage them directly from Convex functions.

Readme

convex-livekit

Sync LiveKit rooms, participants, tracks, egress, and ingress into your Convex database reactively, and manage rooms, participants, tracks, egress, and ingress directly from Convex functions.

npm version Convex Component npm downloads License: Apache-2.0

convex-livekit demo

const livekit = new LiveKit(components.convexLivekit, {
  apiKey: process.env.LIVEKIT_API_KEY!,
  apiSecret: process.env.LIVEKIT_API_SECRET!,
  host: process.env.LIVEKIT_HOST!,
});

const { sid, name } = await livekit.createRoom(ctx, { name: "standup" });
const { token } = await livekit.createRoomToken({
  roomName: name,
  identity: "user_123",
});

// Stays live from here — status, participants, tracks, and egress all
// update reactively as LiveKit's webhooks arrive.
const room = useQuery(api.example.getRoom, { name });

What this does

convex-livekit gives your Convex app a live, queryable view of LiveKit rooms, the people in them, and their published tracks — kept up to date by LiveKit's webhooks — plus a set of actions for managing rooms, participants, and authenticating clients:

  • Reactive room & participant trackingroom_started/room_finished, participant_joined/participant_left, and participant_connection_aborted (an unexpected disconnect, handled the same as a clean leave) update Convex rows, so useQuery in your React app re-renders as rooms open and people join or leave.
  • Track trackingtrack_published/track_unpublished sync each participant's tracks (source: camera, microphone, screen share; muted snapshot; type), so you can tell not just who's in a room but whether their mic or camera is actually live.
  • Egress tracking and controlegress_started/egress_updated/egress_ended events are recorded, and startRoomCompositeEgress/stopEgress let you start or stop a room recording or livestream directly, so you can show and drive recording/streaming status live.
  • Ingress tracking and managementcreateIngress/updateIngress/deleteIngress provision RTMP, WHIP, or pulled-URL endpoints that publish into a room as a regular participant, and ingress_started/ingress_ended keep their live state (buffering, publishing, error) synced.
  • Room, participant, track, egress, and ingress management from your backend — call createRoom, deleteRoom, updateRoomMetadata, removeParticipant, updateParticipant, mutePublishedTrack, startRoomCompositeEgress, stopEgress, createIngress, updateIngress, and deleteIngress from Convex actions, and mint room-join access tokens with createRoomToken for your clients to connect with.
  • Resilient server API calls — every outbound call to LiveKit retries on 429/5xx responses and network failures with exponential backoff and jitter, honoring a Retry-After header when LiveKit sends one.
  • Cryptographically verified webhooks — every inbound webhook's signed JWT is verified (signature, issuer, expiry, and a body-hash check) before anything is written, matching LiveKit's own webhook verification scheme.

This is a Convex component: its rooms, participants, tracks, egress, ingress, and webhookEvents tables live in an isolated schema, not your app's schema, and are only reachable through the functions this component exposes.

Table of Contents

Install

npm install convex-livekit

Quick Start

1. Add the component

// convex/convex.config.ts
import { defineApp } from "convex/server";
import convexLivekit from "convex-livekit/convex.config";

const app = defineApp();
app.use(convexLivekit);

export default app;

2. Set environment variables

npx convex env set LIVEKIT_API_KEY APIxxxxxxxx
npx convex env set LIVEKIT_API_SECRET your-api-secret
npx convex env set LIVEKIT_HOST https://your-project.livekit.cloud

These come from your LiveKit Cloud project settings (or your self-hosted server's configured key/secret pair).

3. Mount the webhook handler

// convex/http.ts
import { httpRouter } from "convex/server";
import { components } from "./_generated/api";
import { LiveKit } from "convex-livekit";

const livekit = new LiveKit(components.convexLivekit, {
  apiKey: process.env.LIVEKIT_API_KEY!,
  apiSecret: process.env.LIVEKIT_API_SECRET!,
  host: process.env.LIVEKIT_HOST!,
});

const http = httpRouter();

http.route({
  path: "/webhooks/livekit",
  method: "POST",
  handler: livekit.webhookHandler,
});

export default http;

4. Register the webhook in your LiveKit project

In your LiveKit Cloud project settings (or your self-hosted server's webhook config), set the webhook URL to https://<your-deployment>.convex.site/webhooks/livekit. LiveKit signs every webhook with your project's own API key/secret pair — there's no separate webhook secret to configure.

5. Initialize the client

// convex/example.ts
import { action, query } from "./_generated/server";
import { components } from "./_generated/api";
import { LiveKit } from "convex-livekit";
import { v } from "convex/values";

const livekit = new LiveKit(components.convexLivekit, {
  apiKey: process.env.LIVEKIT_API_KEY!,
  apiSecret: process.env.LIVEKIT_API_SECRET!,
  host: process.env.LIVEKIT_HOST!,
});

export const listRooms = query({
  args: {},
  handler: async (ctx) => {
    return await livekit.listRooms(ctx, {});
  },
});

Setup

The component needs no schema changes in your app — its tables (rooms, participants, tracks, egress, webhookEvents) live entirely inside the component's own isolated schema. All you need is the webhook mounted (step 3) and a LiveKit client instance wherever you call its methods.

Unlike the other components in this series, convex-livekit never stores a long-lived credential in a header — every server API call and every room-join token is a fresh, short-lived JWT this component signs itself with your API key/secret, following the same convention as LiveKit's own server SDKs.

Usage

Create a room

export const openRoom = action({
  args: { name: v.string(), maxParticipants: v.optional(v.number()) },
  handler: async (ctx, args) => {
    return await livekit.createRoom(ctx, args);
  },
});

Returns { sid, name } and immediately records the room in Convex — you don't have to wait for the room_started webhook to see it in a query.

Mint a join token for a client

export const getJoinToken = action({
  args: { roomName: v.string(), identity: v.string() },
  handler: async (ctx, args) => {
    return await livekit.createRoomToken(args);
  },
});

createRoomToken touches no database — it's a plain signing operation, so it also works from a query if you'd rather not spend an action round-trip. The returned { token } is what you pass to a LiveKit client SDK (room.connect(url, token)).

Remove a participant

export const kick = action({
  args: { roomName: v.string(), identity: v.string() },
  handler: async (ctx, args) => {
    await livekit.removeParticipant(ctx, args);
    return null;
  },
});

Update a participant, or mute their track

export const setAgentState = action({
  args: { roomName: v.string(), identity: v.string(), state: v.string() },
  handler: async (ctx, args) => {
    await livekit.updateParticipant(ctx, {
      roomName: args.roomName,
      identity: args.identity,
      attributes: { "lk.agent.state": args.state },
    });
    return null;
  },
});

export const muteMic = action({
  args: { roomName: v.string(), identity: v.string(), trackSid: v.string() },
  handler: async (ctx, args) => {
    await livekit.mutePublishedTrack(ctx, { ...args, muted: true });
    return null;
  },
});

Both call LiveKit's server API first, then patch the corresponding Convex row so the change is visible in queries immediately — no round trip through a webhook required.

Record or stream a room (egress)

export const startRecording = action({
  args: { roomName: v.string() },
  handler: async (ctx, args) => {
    return await livekit.startRoomCompositeEgress(ctx, {
      roomName: args.roomName,
      filepath: `recordings/${args.roomName}-{time}.mp4`,
    });
  },
});

export const stopRecording = action({
  args: { egressId: v.string() },
  handler: async (ctx, args) => {
    return await livekit.stopEgress(ctx, args);
  },
});

startRoomCompositeEgress records or livestreams the whole room (mixed audio/video of every participant) to a file, one or more RTMP(S) URLs, or both. It uses LiveKit's StartRoomCompositeEgress RPC — the file/stream actually lands wherever your LiveKit server's own storage config (S3/GCP/Azure/local) sends it. Both calls patch the egress row immediately, same pattern as the room/participant/track actions above.

Bring an external stream into a room (ingress)

export const createStreamKey = action({
  args: { roomName: v.string(), identity: v.string() },
  handler: async (ctx, args) => {
    return await livekit.createIngress(ctx, {
      inputType: "rtmp",
      name: `${args.roomName}-obs`,
      roomName: args.roomName,
      participantIdentity: args.identity,
      participantName: args.identity,
    });
  },
});

Returns { ingressId, url, streamKey } — hand url/streamKey to OBS or any RTMP encoder, and it joins the room as a regular participant. updateIngress changes a reusable (RTMP/WHIP) ingress's target room or identity; deleteIngress removes it permanently (unlike the other tables, the row is actually deleted, not kept as history — see Database Schema).

Read rooms, participants, tracks, egress, and ingress reactively

const rooms = useQuery(api.example.listRooms, {});
const participants = useQuery(api.example.listParticipantsByRoom, {
  roomName: "standup",
});
const tracks = useQuery(api.example.listTracksByRoom, { roomName: "standup" });
const ingress = useQuery(api.example.listIngressByRoom, { roomName: "standup" });

Every room_started/room_finished, participant_joined/participant_left/participant_connection_aborted, track_published/track_unpublished, egress_started/egress_updated/egress_ended, and ingress_started/ingress_ended webhook event patches or inserts a row, so these queries re-render live — no polling.

Agent state

LiveKit Agents broadcasts what an agent is doing — listening, thinking, speaking — through the participant's attributes map (conventionally under an lk.agent.state key). convex-livekit reads that same field: it's synced into participants.attributes on participant_joined/left/connection_aborted, and you can also set it yourself with updateParticipant. That means a Convex-backed UI can show live agent state with a plain useQuery, and your backend can both read and drive it — for example, muting a user's microphone track with mutePublishedTrack while an agent is mid-response, then unmuting it for the user's turn. See the Limitations section for what this can and can't stay in sync with.

API Reference

Actions (need ctx from an action)

| Method | Description | | ----------------------------------------------------------------------------------------------------| --------------------------------------------------------------------------| | createRoom(ctx, { name, emptyTimeout?, maxParticipants?, metadata? }) | Creates a room via the server API and records it. Returns { sid, name }. | | deleteRoom(ctx, { name }) | Deletes a room via the server API and marks it finished. | | updateRoomMetadata(ctx, { name, metadata }) | Updates a room's metadata via the server API and patches the stored row. | | removeParticipant(ctx, { roomName, identity }) | Disconnects a participant via the server API and marks them left. | | updateParticipant(ctx, { roomName, identity, metadata?, name?, attributes?, permission? }) | Updates a participant's metadata, name, attributes, or permissions via the server API and patches the stored row. | | mutePublishedTrack(ctx, { roomName, identity, trackSid, muted }) | Mutes or unmutes a participant's track via the server API and patches the stored row. | | startRoomCompositeEgress(ctx, { roomName, layout?, audioOnly?, videoOnly?, filepath?, streamUrls? }) | Starts a room-composite recording and/or livestream. Returns { egressId, status }. | | stopEgress(ctx, { egressId }) | Stops a running egress. Returns { status }. | | createIngress(ctx, { inputType, name, roomName, participantIdentity, participantName, url?, enableTranscoding? }) | Provisions an RTMP/WHIP/URL ingress endpoint. Returns { ingressId, url?, streamKey? }. | | updateIngress(ctx, { ingressId, name?, roomName?, participantIdentity?, participantName? }) | Updates a reusable ingress's config via the server API and patches the stored row. | | deleteIngress(ctx, { ingressId }) | Permanently removes an ingress endpoint via the server API and deletes the stored row. |

Plain methods (no ctx — touch no database)

| Method | Description | | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------| | createRoomToken({ roomName, identity, name?, canPublish?, canSubscribe?, canPublishData?, metadata?, ttlSeconds? }) | Signs and returns a room-join access token. Defaults to a 10-minute expiry and publish+subscribe permissions. |

Queries (work from actions, queries, or mutations)

| Method | Description | | ---------------------------------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------| | getRoom(ctx, { name }) | Fetch one room by its name. | | listRooms(ctx, { limit? }) | Most recently updated rooms, newest first. | | listParticipantsByRoom(ctx, { roomName, limit? }) | Most recently updated participants for a room, newest first. | | getEgress(ctx, { egressId }) | Fetch one egress job by its id. | | listEgressByRoom(ctx, { roomName, limit? }) | Most recently updated egress jobs for a room, newest first. | | getTrack(ctx, { trackSid }) | Fetch one track by its LiveKit track sid. | | listTracksByRoom(ctx, { roomName, limit? }) | Most recently updated tracks for a room, newest first. | | listTracksByParticipant(ctx, { roomName, participantIdentity, limit? }) | Most recently updated tracks for one participant in a room, newest first. | | getIngress(ctx, { ingressId }) | Fetch one ingress endpoint by its id. | | listIngressByRoom(ctx, { roomName, limit? }) | Most recently updated ingress endpoints for a room, newest first. | | getStats(ctx) | Counts: total rooms, currently-live rooms, currently-joined participants, egress jobs, total/currently-published tracks, ingress endpoints, and webhook deliveries. | | listRecentParticipants(ctx, { limit? }) | Most recently updated participants across every room, newest first. | | listRecentEgress(ctx, { limit? }) | Most recently updated egress jobs across every room, newest first. | | listRecentWebhookEvents(ctx, { limit? }) | Most recently received webhook deliveries, newest first. |

Webhook

| Property | Description | | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | webhookHandler | An httpAction that verifies, deduplicates, and processes LiveKit's room, participant, track, egress, and ingress webhook events. Mount it at any route. |

Type Reference

type LiveKitOptions = {
  apiKey: string;
  apiSecret: string;
  host: string; // e.g. "https://your-project.livekit.cloud"
};

type CreateRoomArgs = {
  name: string;
  emptyTimeout?: number; // seconds of emptiness before LiveKit closes the room
  maxParticipants?: number;
  metadata?: string;
};

type CreateRoomTokenArgs = {
  roomName: string;
  identity: string;
  name?: string; // participant display name
  canPublish?: boolean; // default true
  canSubscribe?: boolean; // default true
  canPublishData?: boolean; // default true
  metadata?: string;
  ttlSeconds?: number; // default 600 (10 minutes)
};

type UpdateParticipantArgs = {
  roomName: string;
  identity: string;
  metadata?: string;
  name?: string;
  attributes?: Record<string, string>; // LiveKit Agents uses this for agent state
  permission?: {
    canSubscribe?: boolean;
    canPublish?: boolean;
    canPublishData?: boolean;
    hidden?: boolean;
  };
};

type MutePublishedTrackArgs = {
  roomName: string;
  identity: string;
  trackSid: string;
  muted: boolean;
};

type StartRoomCompositeEgressArgs = {
  roomName: string;
  layout?: string;
  audioOnly?: boolean;
  videoOnly?: boolean;
  filepath?: string; // writes the recording to this path via your storage config
  streamUrls?: string[]; // livestreams to one or more RTMP(S) URLs
};

type CreateIngressArgs = {
  inputType: "rtmp" | "whip" | "url";
  name: string;
  roomName: string;
  participantIdentity: string;
  participantName: string;
  url?: string; // required when inputType is "url"
  enableTranscoding?: boolean; // WHIP ingress cannot disable transcoding
};

type UpdateIngressArgs = {
  ingressId: string;
  name?: string;
  roomName?: string;
  participantIdentity?: string;
  participantName?: string;
};

type Room = {
  name: string;
  sid?: string;
  status: "started" | "finished";
  numParticipants?: number;
  maxParticipants?: number;
  emptyTimeout?: number;
  metadata?: string;
  startedAt?: number;
  endedAt?: number;
  createdAt: number;
  updatedAt: number;
};

type Participant = {
  participantSid: string;
  roomName: string;
  identity: string;
  name?: string;
  state: "joined" | "left";
  metadata?: string;
  attributes?: Record<string, string>;
  joinedAt?: number;
  leftAt?: number;
  createdAt: number;
  updatedAt: number;
};

type Track = {
  trackSid: string;
  roomName: string;
  participantIdentity: string;
  type: string; // "audio" | "video" | "data"
  source: string; // "unknown" | "camera" | "microphone" | "screen_share" | "screen_share_audio"
  name?: string;
  muted: boolean;
  mimeType?: string;
  publishedAt?: number;
  unpublishedAt?: number;
  createdAt: number;
  updatedAt: number;
};

type Egress = {
  egressId: string;
  roomName?: string;
  status: string; // "EGRESS_STARTING" | "EGRESS_ACTIVE" | "EGRESS_ENDING" | "EGRESS_COMPLETE" | "EGRESS_FAILED" | "EGRESS_ABORTED"
  error?: string;
  startedAt?: number;
  endedAt?: number;
  createdAt: number;
  updatedAt: number;
};

type Ingress = {
  ingressId: string;
  name?: string;
  roomName: string;
  participantIdentity: string;
  participantName?: string;
  inputType: "rtmp" | "whip" | "url";
  url?: string;
  streamKey?: string; // RTMP encoder credential — treat like the API secret
  reusable?: boolean;
  enabled?: boolean;
  state?: string; // "ENDPOINT_INACTIVE" | "ENDPOINT_BUFFERING" | "ENDPOINT_PUBLISHING" | "ENDPOINT_ERROR" | "ENDPOINT_COMPLETE"
  createdAt: number;
  updatedAt: number;
};

Webhook Events

The webhook handler processes ten of LiveKit's webhook event types:

  • room_started / room_finished — upsert or finalize the room's row.
  • participant_joined — upserts the participant's row (keyed by their sid, LiveKit's per-session participant id, distinct from identity), including their attributes map.
  • participant_left / participant_connection_aborted — both mean the participant is gone (a clean leave vs. an unexpected disconnect) and are handled identically, marking the row left.
  • track_published / track_unpublished — upsert or mark unpublished the track's row, keyed by its sid.
  • egress_started / egress_updated / egress_ended — upsert the egress job's row.
  • ingress_started / ingress_ended — upsert the ingress endpoint's row, including its live state (buffering, publishing, error, complete).

Every request's Authorization header (a signed JWT, with or without a Bearer prefix — LiveKit's docs show it bare) is verified in full: its HS256 signature is recomputed with your apiSecret and compared, its iss claim must match your apiKey, its exp claim must not be expired (with a 60-second clock-skew allowance), and its sha256 claim must match the SHA-256 digest of the raw request body — this last check is what LiveKit's own WebhookReceiver does, and it means a payload can't be replayed with a different body even if a valid-looking token were somehow reused. Events are deduplicated by their own id field, which LiveKit includes on every webhook delivery.

Database Schema

rooms: {
  name: string;              // indexed: by_name
  sid?: string;
  status: "started" | "finished";
  numParticipants?: number;
  maxParticipants?: number;
  emptyTimeout?: number;
  metadata?: string;
  startedAt?: number;
  endedAt?: number;
  createdAt: number;
  updatedAt: number;
}

participants: {
  participantSid: string;    // indexed: by_participantSid
  roomName: string;          // indexed: by_roomName, and by_room_and_identity with identity
  identity: string;
  name?: string;
  state: "joined" | "left";
  metadata?: string;
  attributes?: Record<string, string>;
  joinedAt?: number;
  leftAt?: number;
  createdAt: number;
  updatedAt: number;
}

tracks: {
  trackSid: string;             // indexed: by_trackSid
  roomName: string;             // indexed: by_roomName, and by_participant with participantIdentity
  participantIdentity: string;
  type: string;                 // "audio" | "video" | "data"
  source: string;                // "unknown" | "camera" | "microphone" | "screen_share" | "screen_share_audio"
  name?: string;
  muted: boolean;
  mimeType?: string;
  publishedAt?: number;
  unpublishedAt?: number;
  createdAt: number;
  updatedAt: number;
}

egress: {
  egressId: string;          // indexed: by_egressId
  roomName?: string;         // indexed: by_roomName
  status: string;
  error?: string;
  startedAt?: number;
  endedAt?: number;
  createdAt: number;
  updatedAt: number;
}

ingress: {
  ingressId: string;             // indexed: by_ingressId
  name?: string;
  roomName: string;              // indexed: by_roomName
  participantIdentity: string;
  participantName?: string;
  inputType: "rtmp" | "whip" | "url";
  url?: string;
  streamKey?: string;            // RTMP encoder credential — treat like the API secret
  reusable?: boolean;
  enabled?: boolean;
  state?: string;                 // "ENDPOINT_INACTIVE" | "ENDPOINT_BUFFERING" | "ENDPOINT_PUBLISHING" | "ENDPOINT_ERROR" | "ENDPOINT_COMPLETE"
  createdAt: number;
  updatedAt: number;
  // Deleted for real on deleteIngress, unlike every other table here — see
  // Limitations.
}

webhookEvents: {
  eventId: string;   // indexed: by_eventId — LiveKit's own webhook event id
  eventType: string; // the `event` field, e.g. "room_started"
  payload: string;   // raw JSON body, for auditing/replay
  receivedAt: number;
}

This schema lives entirely inside the component's isolated namespace — it will never collide with tables in your app's own convex/schema.ts.

Room Identity

LiveKit rooms have both a name (the identifier your app chooses, and what you'd pass to createRoom, deleteRoom, or a client's room.connect()) and a sid (a unique id LiveKit assigns to that specific session of the room — a new one every time the room is recreated after being fully closed). This component's rooms table is keyed by name, not sid: each row represents "the current or most recent live session of this named room," with sid stored as a field for reference. This matches how applications actually think about rooms — you create a room called "standup" and reuse that name, rather than tracking a new opaque id every time it reopens.

Participants, by contrast, are keyed by participantSid — a participant's own per-session id — since the same identity (e.g. a user id) can legitimately hold multiple simultaneous or sequential sessions across reconnects, and you generally want each to show up as its own row. Tracks are keyed by their own trackSid, which is unique per publish — republishing the same logical camera or microphone gets a new row, and the old one stays as history with unpublishedAt set.

Authentication

Every write this component makes — creating a room, minting a join token — is a freshly-signed HS256 JWT built from your apiKey/apiSecret, matching LiveKit's own access-token format: an iss claim (your API key), nbf/exp claims bounding its validity, and a video grant object describing what the token is allowed to do (roomCreate/roomAdmin for server API calls, roomJoin for tokens handed to clients). Server-API tokens this component signs internally expire after 10 minutes; client join tokens default to the same but accept a ttlSeconds override. There is no persistent server-side session — every call is authenticated independently, the same way LiveKit's own Node/Go/Python server SDKs work.

Every outbound call also retries on 429 and 5xx responses and on network-level failures (up to 3 attempts total), with exponential backoff plus jitter between attempts, honoring a Retry-After header when LiveKit sends one. Other 4xx responses (a bad room name, a missing permission) fail immediately — retrying a real client error would only waste time.

Example App

example/ is a small React app (npm run dev, then open localhost:5173) with five tabs, plus a sidebar Activity log that records every action call as it happens:

  • Rooms — create a room (name, max participants, empty timeout, metadata), then expand any room to see its participants and their tracks live, mute a track, remove a participant, update the room's metadata, delete the room outright, or start/stop a room-composite recording with startRoomCompositeEgress/stopEgress.
  • Join Live — mints a real join token with createRoomToken and opens an actual WebRTC connection with your camera and microphone, rendered with LiveKit's own @livekit/components-react VideoConference UI. This browser tab becomes a genuine participant — join it in two tabs to see both sides update reactively.
  • Ingress — provision an RTMP, WHIP, or pulled-URL endpoint with createIngress, see its live state for a given room, and remove it with deleteIngress.
  • Webhooks — every LiveKit webhook delivery this deployment has received, most recent first.
  • History — recent rooms, participants, tracks, and egress jobs across every room, not just the one you're currently looking at.

To run it: set LIVEKIT_API_KEY/LIVEKIT_API_SECRET/LIVEKIT_HOST as Convex environment variables (see Setup), add VITE_LIVEKIT_URL=wss://your-project.livekit.cloud to the repo root's .env.local for the Join Live tab, register the webhook (see Quick Start), then run npm run dev.

Testing

npm run test
npm run typecheck

Tests use convex-test at two levels. src/component/lib.test.ts covers the component's mutations and queries directly: room lifecycle transitions (confirming markRoomFinished and patchRoomMetadata only touch their own fields), participant join/leave tracking and attributes, track publish/unpublish/mute, the room participant-count sync staying current without disturbing status or metadata, egress upsert behavior, ingress upsert/delete behavior (confirming removeIngress actually deletes the row, unlike the keep-history mutations for every other table), webhook idempotency via checkAndRecordEvent, and the dashboard queries. example/convex/http.test.ts separately exercises the actual httpAction end to end — signing requests with an independent HS256 implementation (not the component's own) to verify the handler rejects a missing auth header, a wrong secret, a wrong issuer, an expired token, and a tampered body, and correctly dispatches participant_connection_aborted, the track events, and the ingress lifecycle.

Limitations

  • Track muted, participant attributes, and ingress state are snapshots, not continuously reactive: LiveKit has no webhook for a live mute toggle, an attributes change, or an ingress state transition outside of ingress_started/ ingress_ended. These fields only refresh when their triggering webhook arrives, or when this component's own mutePublishedTrack / updateParticipant / createIngress / updateIngress calls succeed. A participant muting themselves client-side, or an agent changing its own attributes without going through updateParticipant, won't be reflected until the next event that does carry it.
  • Egress control is scoped to room-composite recording/streaming (startRoomCompositeEgress/stopEgress) — LiveKit's per-participant and per-track egress variants (StartParticipantEgress, StartTrackCompositeEgress, StartTrackEgress, StartWebEgress), and the newer unified StartEgress endpoint, aren't wrapped. Room-composite covers the two common cases (record the whole room, or livestream it); the others can be added on the same pattern.
  • deleteIngress actually deletes the Convex row, unlike every other table here, which keeps history after the underlying resource ends (a room finishes, a track unpublishes) — a deleted ingress endpoint is a removed resource, not a lifecycle state, so a stale row wouldn't mean anything.
  • SIP is out of scope entirely — it's a different product surface (telephony), not an extension of the server-side control plane this component wraps.
  • Only the RoomService methods needed for the common case (create, delete, update metadata, remove/update participant, mute a track) are wrapped; multi-room operations (moveParticipant, forwardParticipant) and messaging (sendData) are not.
  • Outbound calls to LiveKit retry on 429/5xx/network failures (see Authentication), but there's no client-side rate limiting ahead of that — a burst of calls can still all hit LiveKit at once and rely on the retry to absorb any resulting 429s.

Troubleshooting

Webhook returns 401 "Invalid signature" — confirm LIVEKIT_API_KEY/LIVEKIT_API_SECRET in your Convex deployment exactly match the key/secret pair configured for the webhook in your LiveKit project (a project can have multiple key/secret pairs — the webhook must be signed with the same one this component verifies against).

Webhook returns 401 "Body hash mismatch" — something between LiveKit and your Convex deployment is modifying the request body (a proxy re-encoding it, for example). This check compares against the raw bytes LiveKit signed, so the body must reach your httpAction untouched.

createRoom throws a 401/403 — the signed server-API token's video grant didn't include the permission the call needs (roomCreate for CreateRoom/DeleteRoom, roomAdmin for UpdateRoomMetadata/RemoveParticipant/UpdateParticipant/MutePublishedTrack); this is handled internally per-method, so a 401/403 here more often means the apiKey/apiSecret pair itself doesn't have access to the project at LIVEKIT_HOST.

Rooms never appear in queries — confirm the webhook URL in your LiveKit project settings points at your deployment's .convex.site domain, and check the project's webhook delivery log (if available) for non-200 responses.

Contributing

See CONTRIBUTING.md.

Changelog

See CHANGELOG.md.