Operate
Webhooks: Room, Stream and Chat Events
Receive VoltKit RTC events on your server when rooms start and end, people join and leave, or streams go live, and verify each request's signature.
A webhook tells your backend what happened, when it happens. Without one you can only poll, which means learning about a join seconds late or calling the API constantly.
Use webhooks to record who watched and for how long, to drive "X is live" notifications, and to keep your own view of a room in step with reality.
Set one up
On the Webhooks tab of your project, enter an https URL and choose the events you want. You are shown a signing secret once, and your browser downloads it as a text file. You need it to verify every delivery: store it in your server's environment as VOLTKIT_RTC_WEBHOOK_SECRET, then delete the file. Saving the webhook again issues a new secret.
The endpoint must be reachable from the public internet. Private and local addresses are refused.
Events
| Event | Fires when | Notable data |
|---|---|---|
room.started | A room is created | hostIdentity |
room.ended | A room is closed | durationSeconds |
participant.joined | Someone joins | identity, role |
participant.left | Someone disconnects, leaves or is removed | identity |
role.changed | A promotion or demotion | role, previousRole, by |
track.published | A publisher starts camera or microphone | kind |
track.unpublished | A track stops | |
stream.started | A broadcast stream goes on air | streamId |
stream.ended | A broadcast stream goes off air | streamId, durationSeconds |
usage.limit_reached | The project ran out of minutes or hit a usage limit while something was live | reason, graceSeconds, endsAt |
chat.message | A chat message is stored | The message |
chat.message is off unless you tick it. A busy channel produces far more events than everything else combined.
What you receive
POST /hooks/voltkit HTTP/1.1
Content-Type: application/json
X-Stream-Event: participant.joined
X-Stream-Delivery: evt_9f2c41d0
X-Stream-Attempt: 1
X-Stream-Timestamp: 1787291928
X-Stream-Signature: t=1787291928,v1=9a3f...
{
"id": "evt_9f2c41d0",
"type": "participant.joined",
"tenantId": "vk_4f2a9c0d1e3b5a67",
"roomId": "show-42",
"createdAt": 1787291928739,
"data": { "identity": "user-99", "role": "viewer" }
}
tenantId is your App ID. For stream and chat events, roomId holds the stream id or the channel name.
Verify the signature
Always verify. Without it, anyone who finds your endpoint can post fake events to it.
The signature is an HMAC-SHA256 of "<timestamp>.<raw body>" using your signing secret. The timestamp is part of what is signed, so a captured request cannot be replayed later.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret, toleranceSeconds = 300) {
const match = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header ?? "");
if (!match) return false;
const [, timestamp, signature] = match;
// Refuse anything old: this is what stops a replay.
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // the RAW body, before JSON.parse
.digest("hex");
return expected.length === signature.length
&& timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
Delivery
- Answer
2xxquickly, within 10 seconds, and do the work afterwards. - Failed deliveries are retried five more times, spaced 5 s, 30 s, 2 min, 10 min and 30 min apart.
- A
4xxanswer stops the retries, except408and429.410 Goneis treated as "stop sending". - Deliveries are at-least-once. The same event can arrive twice. Use
id, which stays the same across retries, to ignore duplicates. - Order is not guaranteed. Use
createdAtif order matters to you.
Test it
Press Send test event on the Webhooks tab. A signed webhook.test event is delivered to your endpoint, so you can confirm that it is reachable and that your signature check passes before any real traffic depends on it.
The same tab shows how many deliveries succeeded, how many were dropped after all retries, and the last error.
Last updated . Something unclear or wrong? Tell us.