Reference/Webhooks
Webhooks
Velo posts an event to every receiver you register, signed with a secret only you and Velo hold. This page lists every event Velo emits, the fields each one carries, how to verify a delivery, and what happens when your endpoint is down.
Registering a receiver
A receiver is a url on your side. Velo resolves it before it stores it, so a url pointing at a private network address is refused with 400 blocked_url. There is no per-event subscription: every active endpoint on the project receives every event the project produces.
curl -X POST https://api.usevelo.xyz/v1/webhook-endpoints \
-H "Authorization: Bearer $VELO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/velo/webhooks"}'The response is the only place the signing secret appears. Listing endpoints afterwards returns the url and nothing else, because Velo keeps the secret for signing and never hands it back.
{
"id": "6f1b0e3a-1c3e-4a4a-9f2d-0a2c9b7e5d11",
"url": "https://example.com/velo/webhooks",
"secret": "whsec_00000000000000000000000000000000",
"active": true,
"created_at": "2026-08-06T09:41:02Z"
}Copy the secret into your secret store before you close the response. If you lose it, delete the endpoint and register it again to get a new one.
The payload
Every delivery is a single JSON object. Four fields are always present. The rest appear only on the events they belong to, so a room.finished payload has no participant and a participant.joined payload has no track.
| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | The event name, such as track.published. |
| project_id | string | Yes | The project the event belongs to. |
| room | string | Yes | The room name as you created it, without the project prefix. |
| occurred_at | timestamp | Yes | RFC 3339 in UTC, taken from the media plane's clock where it supplies one. |
| participant_identity | string | No | On every participant and track event, and on ingress events. |
| participant_sid | string | No | The media server's handle for that one join. |
| role | string | No | The velo.role attribute the participant carries. |
| track_sid | string | No | On track events, and on publish limit events when a track can be attributed. |
| track_kind | string | No | audio, video or data. |
| track_source | string | No | camera, microphone, screen_share, screen_share_audio or unknown. |
| track_muted | boolean | No | The track's muted state at the moment of the event. |
| egress_id | string | No | On recording and broadcast events. |
| recording_status | string | No | starting, active, ending, complete, failed, aborted or limit_reached. |
| broadcast_status | string | No | The same set of values, for a broadcast. |
| ingress_id | string | No | On ingress events. |
| ingress_status | string | No | inactive, buffering, publishing, error or complete. |
| publish_kbps | integer | No | On publish limit events, the bitrate the media node measured. |
| publish_max_kbps | integer | No | On publish limit events, the ceiling the role's token carries. |
{
"type": "track.published",
"project_id": "prj_9c2f4a71b0d8",
"room": "clinic-1187",
"participant_identity": "practitioner-22",
"participant_sid": "PA_7hR2nQ4wKdV1",
"role": "practitioner",
"track_sid": "TR_3kM8pXc0LbA9",
"track_kind": "video",
"track_source": "camera",
"track_muted": false,
"occurred_at": "2026-08-06T09:44:17Z"
}One event has a shape of its own. participant.role_changed is raised by the control plane rather than the media plane, so instead of the track and egress fields it carries the role the participant came from and who moved them.
| Field | Type | Required | Description |
|---|---|---|---|
| role | string | Yes | The role the participant now holds. |
| previous_role | string | No | What they held before. Absent if they carried no role. |
| changed_by_identity | string | No | Set when a room token made the change. |
| changed_by_api_key_prefix | string | No | Set when a project API key made the change. |
{
"type": "participant.role_changed",
"project_id": "prj_9c2f4a71b0d8",
"room": "clinic-1187",
"participant_identity": "patient-4410",
"participant_sid": "PA_5tB1yF7uJmQ2",
"role": "observer",
"previous_role": "guest",
"changed_by_api_key_prefix": "vk_live_7Qm2",
"occurred_at": "2026-08-06T09:52:40Z"
}Every event
Twenty events, and no others. Anything the media plane reports that does not map to one of these is recorded for Velo's own bookkeeping and never delivered to you.
- room.started
- The first participant joined and the media plane opened the room.
- room.finished
- The room closed, either because it emptied past its timeout or because you ended it. Every open session in the room is closed at the same moment.
- participant.joined
- Someone connected. Carries the role their token was minted for, if it named one.
- participant.left
- Someone disconnected cleanly.
- participant.connection_aborted
- A join failed before it completed, or the connection dropped without a clean leave. Treated the same as a leave for session accounting.
- participant.role_changed
- A participant was moved to another role through the participant role endpoint. Raised by the control plane rather than the media plane.
- participant.publish_limit_exceeded
- The media node measured the participant's total published bitrate above the ceiling their role carries in
velo.publish.max_kbps, and the overage was sustained. Carriespublish_kbpsandpublish_max_kbps. - participant.publish_limit_cleared
- The same participant came back under the ceiling and stayed there.
- track.published
- A participant started publishing a track. Carries the track's sid, kind, source and muted state.
- track.unpublished
- A track stopped. Velo records the end time against the participant's session.
- recording.started
- The egress backing a recording began.
- recording.updated
- The recording moved to a new status. Velo suppresses an update that reports the status the recording is already in, so you do not receive a stream of identical events.
- recording.ended
- The egress finished and the file is final.
- recording.failed
- The egress ended in the failed status. Sent instead of recording.ended, not as well as it.
- broadcast.started
- The egress pushing the room to RTMP began.
- broadcast.updated
- The broadcast moved to a new status, with the same suppression as recordings.
- broadcast.ended
- The broadcast finished.
- broadcast.failed
- The broadcast ended in the failed status, typically because a destination refused the stream.
- ingress.started
- An encoder connected to an ingress endpoint and started publishing into the room.
- ingress.ended
- The encoder disconnected or the endpoint went inactive.
Recordings and broadcasts are both egress on the media plane. Velo decides which pair of events to send by looking up the egress id: it matches a recording, or it matches a broadcast, or the event advances neither and is dropped. That is why you never see both for one egress.
Verifying the signature
Every request carries two headers. X-Velo-Event repeats the event name so you can route without parsing, and X-Velo-Signature proves the body came from Velo.
POST /velo/webhooks HTTP/1.1
Content-Type: application/json
X-Velo-Event: track.published
X-Velo-Signature: sha256=6b1f0d47c3a2e95b8f7c0d1e2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5dThe signature is the literal sha256= followed by the lowercase hex HMAC-SHA256 of the exact request body, keyed with the endpoint secret. Three things matter.
- Sign the raw bytes, not a re-serialised object. Parse the JSON after the check, never before it.
- Compare in constant time. A plain string comparison leaks the signature one byte at a time.
- The whole string is signed including the sha256= prefix in the header, so compare the full header value against the full computed value.
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.VELO_WEBHOOK_SECRET;
app.post("/velo/webhooks", express.raw({ type: "application/json" }), (req, res) => {
const expected = "sha256=" + createHmac("sha256", secret).update(req.body).digest("hex");
const received = req.get("X-Velo-Signature") ?? "";
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
handle(event.type, event);
res.status(204).end();
});Reject anything that does not match before you act on it.
Velo does not send a timestamp header, so there is no replay window to enforce. Deliveries carry no identifier of their own in the body either; if you need to deduplicate, key on the fields that make an event unique, such as type, participant_sid and occurred_at.
Retries and attempts
A worker picks up due deliveries every five seconds, in batches of fifty, and posts each one with a ten second timeout. Any 2xx counts as delivered. Anything else, including a timeout or a connection failure, counts as a failed attempt.
- Attempt 1
- Immediately, on the next worker tick after the event is queued.
- Attempt 2
- One minute after the first failure.
- Attempt 3
- Five minutes after the second.
- Attempt 4
- Thirty minutes after the third.
- Attempt 5
- Two hours after the fourth. This is the last one.
Five attempts over roughly two and a half hours. After the fifth failure the delivery is marked failed and Velo stops on its own. It is never dropped: the payload, the attempt count, the last error and the status your endpoint returned are all kept.
Answer quickly and do the work afterwards. Ten seconds is the whole budget, and a receiver that blocks on its own downstream work will start failing deliveries that were otherwise fine.
Reading failures back
Every attempt is readable through the API. This is where you look when a receiver stopped hearing from Velo, because it tells you whether Velo stopped sending or your endpoint stopped answering.
curl -G https://api.usevelo.xyz/v1/webhook-deliveries \
-H "Authorization: Bearer $VELO_API_KEY" \
-d status=failed \
-d event_type=recording.ended| Field | Type | Required | Description |
|---|---|---|---|
| status | string | Yes | pending, delivered or failed. |
| attempts | integer | Yes | How many times Velo has posted this delivery. |
| next_attempt_at | timestamp | No | Present only while the status is pending. |
| last_error | string | Yes | The transport or status error from the most recent attempt. |
| response_status | integer or null | Yes | The HTTP status your endpoint returned, or null if the request never completed. |
| payload | object | Yes | The exact body that was posted, byte for byte the body the signature covers. |
A delivery whose status is failed can be re-queued. Retrying resets the attempt count to zero and the status to pending, so the full schedule runs again. A delivery that is still pending or already delivered is refused with 409 delivery_not_retryable.
curl -X POST \
https://api.usevelo.xyz/v1/webhook-deliveries/2b7c9f11-4d3a-4c58-9e01-77b6f2a4c8de/retry \
-H "Authorization: Bearer $VELO_API_KEY"The full shape of both endpoints, with every filter and field, is in the REST API reference.
Was this page useful?