Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Convex LiveKit

Sync LiveKit rooms, participants, and egress into Convex reactively. Create rooms and mint access tokens from Convex functions.

Upstream source
git clone https://github.com/sholajegede/convex-livekit.git
cd convex-livekit

This upstream package has not been adapted or qualified for Bijection. Read the original documentation before adopting it.

Description

Sync LiveKit rooms, participants, and egress into Convex reactively. Create rooms and mint access tokens from Convex functions.

Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. Exercise the component in your deployment according to your project’s verification rules.

Open source

Maintained source: sholajegede/convex-livekit. License: See repository. Consult the repository for license terms, notices, and contributor attribution.

From the upstream publisher

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

Explore the demo ↗

Upstream overview

From the upstream package documentation.

convex-livekit syncs LiveKit room, participant, and egress state into Convex tables via verified webhooks, making that data available to reactive queries. It also provides Convex actions for room management (create, delete, update metadata, remove participants) and JWT token minting for client authentication. The component's tables are isolated in their own schema and are only accessible through the functions the component exposes.

Use cases

From the upstream package documentation.

  • Building a live audio or video app where the UI needs to reactively reflect who is in a room without polling, using useQuery against synced participant and room data
  • Generating room-join access tokens from a Convex action or query rather than running a separate token service
  • Displaying recording or streaming status live by tracking egress state (started, updated, ended) as webhook events arrive
  • Kicking or removing participants from a room server-side from a Convex action in response to app logic
  • Showing room presence indicators or participant counts that update in real time as users join and leave LiveKit rooms

How it works

From the upstream package documentation.

The component is registered in convex.config.ts using app.use(convexLivekit), which provisions isolated tables for rooms, participants, egress jobs, and webhook events. A LiveKit client instance is created by passing components.convexLivekit along with your LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and LIVEKIT_HOST environment variables. This instance is used across your Convex functions to call room management actions and queries.

Inbound webhook events from LiveKit are handled by mounting livekit.webhookHandler as an HTTP action at a route like /webhooks/livekit. Each incoming request is verified by checking the signed JWT (signature, issuer, expiry, and body hash) before any write occurs. Verified events patch or insert rows in the component's tables, so useQuery calls in your React app re-render automatically as rooms open, participants join or leave, and egress jobs change state.

Room management calls like createRoom, deleteRoom, updateRoomMetadata, and removeParticipant are Convex actions that make requests to the LiveKit server API and immediately update the local Convex state, without waiting for the corresponding webhook to arrive. Token minting via createRoomToken is a plain signing method that touches no database and can be called from a query, returning a short-lived JWT the client passes to the LiveKit SDK to connect.

Upstream package FAQ

Does convex-livekit add tables to my app's Convex schema?

No. convex-livekit is a Convex component, so its rooms, participants, tracks, egress, ingress, and webhookEvents tables live in an isolated schema separate from your app's schema. They are only accessible through the functions the component exposes, not through your app's direct database queries.

How does convex-livekit verify incoming LiveKit webhooks?

convex-livekit verifies every inbound webhook by checking the signed JWT's signature, issuer, expiry, and a body-hash claim before writing anything to the database. This matches LiveKit's own webhook verification scheme and uses your existing LiveKit API key and secret, so no separate webhook secret is required.

What happens to participant state when a client disconnects unexpectedly?

convex-livekit handles participant_connection_aborted events the same way it handles a clean participant_left event. The participant row in Convex is updated immediately, so useQuery consumers see the departure regardless of whether the disconnect was clean or abrupt.

Does convex-livekit work with self-hosted LiveKit servers?

Yes. The LiveKit client is initialized with a host parameter that accepts any LiveKit server URL, including self-hosted deployments. You set LIVEKIT_API_KEY, LIVEKIT_API_SECRET, and LIVEKIT_HOST as Convex environment variables pointing at your server, and register the webhook URL on your self-hosted server's webhook config instead of LiveKit Cloud settings.

Can I track whether a participant's camera or microphone is active, not just whether they are in the room?

Yes. convex-livekit handles track_published and track_unpublished webhook events and stores each track's source (camera, microphone, screen share), muted snapshot, and type in a separate tracks table. You can query this table reactively to determine whether a specific participant's mic or camera is currently live.

Use with agents and CLI

Give your coding agent the component’s source identity and setup guidance.

agent-prompt.md
Help me integrate Convex LiveKit with Bijection.

Read: https://bijection.com/components/convex-livekit/SKILL.md

Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.

Upstream README.md

View repository ↗

Documentation from sholajegede/convex-livekit, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.

What this does

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

  • Reactive room & participant tracking — room_started/room_finished and participant_joined/participant_left webhook events update Convex rows, so useQuery in your React app re-renders as rooms open and people join or leave.
  • Egress tracking — egress_started/egress_updated/egress_ended events are recorded too, so you can show recording/streaming status live.
  • Room management and tokens from your backend — call createRoom, deleteRoom, updateRoomMetadata, and removeParticipant from Convex actions, and mint room-join access tokens with createRoomToken for your clients to connect with.
  • 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, egress, 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, 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;
  },
});

Read rooms and participants reactively

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

Every room_started/room_finished and participant_joined/participant_left webhook event patches or inserts a row, so these queries re-render live — no polling.

API Reference

Actions (need ctx from an action)

MethodDescription
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.

Plain methods (no ctx — touch no database)

MethodDescription
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)

MethodDescription
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.
getStats(ctx)Counts: total rooms, currently-live rooms, currently-joined participants, egress jobs, 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

PropertyDescription
webhookHandlerAn httpAction that verifies, deduplicates, and processes LiveKit's room, participant, and egress 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 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;
  joinedAt?: number;
  leftAt?: 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;
};

Webhook Events

The webhook handler processes five of LiveKit's webhook event types (others are recorded for idempotency but otherwise ignored — see Limitations):

  • room_started / room_finished — upsert or finalize the room's row.
  • participant_joined / participant_left — upsert the participant's row, keyed by their sid (LiveKit's per-session participant id, distinct from identity).
  • egress_started / egress_updated / egress_ended — upsert the egress job's row.

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;
  joinedAt?: number;
  leftAt?: 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;
}

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.

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.

Example App

example/ is a small React app (npm run dev, then open localhost:5173) with four 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 live, remove one, update the room's metadata, or delete the room outright.
  • 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.
  • Webhooks — every LiveKit webhook delivery this deployment has received, most recent first.
  • History — recent rooms, participants, 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 and cover room lifecycle transitions (confirming markRoomFinished and patchRoomMetadata only touch their own fields), participant join/leave tracking (confirming joinedAt survives a later leftAt patch), the room participant-count sync staying current across room_started/participant_joined/participant_left without disturbing status or metadata, egress upsert behavior, webhook idempotency via checkAndRecordEvent, and the dashboard queries (getStats, listRecentParticipants, listRecentEgress, listRecentWebhookEvents).

Limitations

  • Only room_started/room_finished, participant_joined/participant_left, and the three egress_* events update tables; track_published/track_unpublished and ingress_* events are accepted (and recorded in webhookEvents for auditing) but not otherwise persisted.
  • Track-level state (which tracks a participant has published, mute state, resolution) is not tracked — this component covers room and participant lifecycle, not media-level detail.
  • SIP and Ingress resource management (bringing external RTMP/WHIP/SIP streams into a room) is out of scope; only the core RoomService methods needed for the common case (create, delete, update metadata, remove participant) are wrapped.
  • Rate limits are your LiveKit project's own — this component does not implement its own rate limiting or backoff.

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); 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.

Keywords