Convex LiveKit
Sync LiveKit rooms, participants, and egress into Convex reactively. Create rooms and mint access tokens from Convex functions.
git clone https://github.com/sholajegede/convex-livekit.git
cd convex-livekitThis 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.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- 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.
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_finishedandparticipant_joined/participant_leftwebhook events update Convex rows, souseQueryin your React app re-renders as rooms open and people join or leave. - Egress tracking —
egress_started/egress_updated/egress_endedevents are recorded too, so you can show recording/streaming status live. - Room management and tokens from your backend — call
createRoom,deleteRoom,updateRoomMetadata, andremoveParticipantfrom Convex actions, and mint room-join access tokens withcreateRoomTokenfor 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
- convex-livekit
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)
| 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. |
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. |
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
| Property | Description |
|---|---|
webhookHandler | An 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 theirsid(LiveKit's per-session participant id, distinct fromidentity).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
createRoomTokenand opens an actual WebRTC connection with your camera and microphone, rendered with LiveKit's own@livekit/components-reactVideoConferenceUI. 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 threeegress_*events update tables;track_published/track_unpublishedandingress_*events are accepted (and recorded inwebhookEventsfor 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
RoomServicemethods 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.