The first version of most video API webhook handlers looks the same. Parse the JSON, switch on the event type, write a row. It works in staging. Then a participant’s laptop goes to sleep mid-call, their participant_left event shows up two minutes later, a second copy arrives after a server restart, and the usage report you send to your own customers is wrong.
Video API webhooks tell your backend when rooms start and end and when participants join and leave. They drive billing, attendance records, “your session has ended” emails, and post-call processing like transcription. This post covers the three things a production handler needs: verifying the signature correctly, handling duplicates and late events, and reconciling against the API when a delivery never arrives.
What video API webhooks actually send
Every major provider has some version of the same event set. Twilio Video’s status callbacks include room-created, room-ended, participant-connected, and participant-disconnected. LiveKit’s webhooks include room_started, room_finished, participant_joined, and participant_left, along with track and egress events.
CoveKit sends four event types: room_started, room_ended, participant_joined, and participant_left. Each request is a JSON POST to the URL you configure on your project:
{
"event_id": "evt_3f0c9a52-6d1e-4b8e-9a51-0f2b7f1c2d44",
"created_at": "2026-10-26T14:02:11Z",
"event": {
"type": "participant_left",
"payload": {
"meeting_id": "8f8bde4f-2df3-4c91-9cb6-26dfd287bc6e",
"session_id": "c1a7e0d2-5b3f-4e0a-8f6d-2a9b1c3e4f50",
"bytes_transferred": 48211903
}
},
"metadata": {
"appointment_id": "apt_1182"
}
}
The metadata field is whatever JSON you passed when you created the meeting. That is the cheapest way to tie an event back to a record in your own database, so set it at creation time:
curl -X POST https://api.covekit.net/v1/meetings \
-H "X-API-Key: $COVEKIT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"title": "Follow-up consult",
"join_mode": "host_approval",
"metadata": { "appointment_id": "apt_1182" }
}'
Store both the id (a UUID, which webhooks reference as meeting_id) and the public_id (mtg_..., which you use with an API key to fetch the meeting later) from the response.
Webhook signature verification
Your webhook URL is public. Anyone who finds it can post a fake room_ended event. Signature verification is how you tell a real event from a forged one.
CoveKit signs each request with HMAC-SHA256 using your project’s webhook secret, computed over the exact request body, and sends the hex digest in the X-CoveKit-Signature header. The verification logic is short, but there are two common ways to get it wrong.
Hash the raw body, not the parsed JSON
If your framework parses the body before your handler runs, you lose the original bytes. Re-serializing the parsed object can reorder keys or change whitespace, and the HMAC no longer matches. Stripe’s signature troubleshooting guide lists this as a leading cause of verification failures, and the same thing applies to every HMAC-signed webhook.
In Express, mount express.raw() on the webhook route only, before any global express.json().
Compare in constant time, and check length first
A plain === comparison can return early on the first mismatched byte, which leaks timing information. Node’s crypto.timingSafeEqual avoids that, but it throws if the two buffers have different lengths. A missing or truncated header will crash the handler instead of returning a 401 unless you check the length first.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.COVEKIT_WEBHOOK_SECRET; // starts with cvk_wh_sec_
function isValidSignature(rawBody, header) {
if (typeof header !== 'string') return false;
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(header, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/covekit', express.raw({ type: 'application/json' }), async (req, res) => {
if (!isValidSignature(req.body, req.get('X-CoveKit-Signature'))) {
return res.status(401).send('invalid signature');
}
const evt = JSON.parse(req.body.toString('utf8'));
// Reject stale events. created_at is inside the signed body, so it can't be altered.
const ageMs = Date.now() - Date.parse(evt.created_at);
if (ageMs > 5 * 60 * 1000) return res.status(200).send('stale, ignored');
await enqueue(evt); // do the real work off the request path
res.status(200).send('ok');
});
app.use(express.json()); // other routes
The freshness check matters because the signature covers the body but not the time it was sent. A captured request with a valid signature would otherwise verify forever. Since created_at is part of the signed payload, an attacker can’t change it without breaking the signature. Pick a window that fits your system: five minutes is common.
Return a 2xx quickly and push the work to a queue. Stripe’s best practices make the same point: slow handlers cause timeouts, and timeouts look like failures to the sender.
Write an idempotent webhook handler
Webhooks are at-least-once at best. LiveKit says it plainly in its post on managing webhook event streams: “Assume at least one delivery. You may receive duplicates. Your consumer should be idempotent.” Twilio’s docs warn that its SequenceNumber field “does not represent the true order” of events and that you should order by timestamp instead.
The usual advice is to dedupe on the event ID. That catches retries of the same delivery, but it misses a second case that is specific to presence events: two different events describing the same fact.
Here is how that happens in CoveKit. When a participant disconnects cleanly, the media server reports the leave and you get a participant_left. When a connection dies without a clean close (a crashed tab, a dropped network, a media server restart), the media server stops sending heartbeats for that session. A background reaper in the control plane checks every 60 seconds for sessions with no heartbeat in the last 90 seconds, marks them as left, and sends participant_left. If the media server’s own leave report also arrives later, you can receive a second participant_left for the same session_id, with a different event_id.
So key presence events on what they describe, not on the envelope:
CREATE TABLE covekit_events (
dedupe_key text PRIMARY KEY,
event_id text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
function dedupeKey(evt) {
const { type, payload } = evt.event;
if (type === 'participant_joined' || type === 'participant_left') {
return `${type}:${payload.session_id}`;
}
return `event:${evt.event_id}`;
}
async function handle(evt) {
const inserted = await db.query(
`INSERT INTO covekit_events (dedupe_key, event_id) VALUES ($1, $2)
ON CONFLICT (dedupe_key) DO NOTHING`,
[dedupeKey(evt), evt.event_id]
);
if (inserted.rowCount === 0) return; // already handled
await applyEvent(evt);
}
Room events fall back to event_id because CoveKit meetings can be reusable: the same meeting_id can start and end many times, so room_ended plus meeting_id is not unique. Make the side effect itself idempotent instead, for example by marking a specific booking as complete only if it is still open.
Expect late leaves
The reaper path also means a participant_left can arrive a couple of minutes after the person actually left. Don’t compute session length from when the webhook arrived. If you need a duration, record participant_joined and participant_left as facts and let the API give you the authoritative number (more on that below).
The same goes for room_ended. CoveKit waits for a 60 second grace period after the last participant leaves before tearing down the room, so someone who refreshes their browser doesn’t end the call for everyone. Your “meeting is over” email should go out after room_ended, not after the last participant_left.
Reconcile instead of trusting delivery
Even with retries, push delivery has no guarantees. LiveKit’s docs say so directly, and the same is true of any HTTP callback system. Your endpoint will be down for a deploy at some point. Today CoveKit makes one delivery attempt per event and records the result, so you can see failed deliveries in your project’s webhook log in the dashboard.
For anything that touches money or compliance, treat webhooks as a fast signal and the REST API as the source of truth. A small job that runs after a meeting should have ended can fetch it directly:
curl https://api.covekit.net/v1/meetings/mtg_7z9k1m2p4q8r \
-H "X-API-Key: $COVEKIT_API_KEY"
The response includes the meeting’s status and accrued_minutes, the sum of participant session time computed on the server. If your webhook-derived records disagree, the API wins, and you have a concrete number to log when you investigate.
A short checklist
- Verify
X-CoveKit-Signaturewith HMAC-SHA256 over the raw body bytes. - Check the header length before
timingSafeEqual, and return 401 on mismatch. - Reject events whose
created_atis older than your replay window. - Return 2xx fast and process on a queue.
- Dedupe presence events on
typeplussession_id, not onlyevent_id. - Send “meeting ended” side effects from
room_ended. - Reconcile billing and attendance against
GET /v1/meetings/{public_id}. - Put your own IDs in
metadatawhen you create the meeting.
You can send a signed test ping event to your endpoint from the Webhooks page in the CoveKit dashboard to check your verification code before real traffic arrives. The API documentation has the full payload reference.