Skip to content

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

json201 Created
{
  "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.

Webhook payload fields
FieldTypeRequiredDescription
typestringYesThe event name, such as track.published.
project_idstringYesThe project the event belongs to.
roomstringYesThe room name as you created it, without the project prefix.
occurred_attimestampYesRFC 3339 in UTC, taken from the media plane's clock where it supplies one.
participant_identitystringNoOn every participant and track event, and on ingress events.
participant_sidstringNoThe media server's handle for that one join.
rolestringNoThe velo.role attribute the participant carries.
track_sidstringNoOn track events, and on publish limit events when a track can be attributed.
track_kindstringNoaudio, video or data.
track_sourcestringNocamera, microphone, screen_share, screen_share_audio or unknown.
track_mutedbooleanNoThe track's muted state at the moment of the event.
egress_idstringNoOn recording and broadcast events.
recording_statusstringNostarting, active, ending, complete, failed, aborted or limit_reached.
broadcast_statusstringNoThe same set of values, for a broadcast.
ingress_idstringNoOn ingress events.
ingress_statusstringNoinactive, buffering, publishing, error or complete.
publish_kbpsintegerNoOn publish limit events, the bitrate the media node measured.
publish_max_kbpsintegerNoOn publish limit events, the ceiling the role's token carries.
jsontrack.published
{
  "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.

participant.role_changed fields
FieldTypeRequiredDescription
rolestringYesThe role the participant now holds.
previous_rolestringNoWhat they held before. Absent if they carried no role.
changed_by_identitystringNoSet when a room token made the change.
changed_by_api_key_prefixstringNoSet when a project API key made the change.
jsonparticipant.role_changed
{
  "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. Carries publish_kbps and publish_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.

http
POST /velo/webhooks HTTP/1.1
Content-Type: application/json
X-Velo-Event: track.published
X-Velo-Signature: sha256=6b1f0d47c3a2e95b8f7c0d1e2a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d

The 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.
typescript
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
curl -G https://api.usevelo.xyz/v1/webhook-deliveries \
  -H "Authorization: Bearer $VELO_API_KEY" \
  -d status=failed \
  -d event_type=recording.ended
Delivery fields worth reading
FieldTypeRequiredDescription
statusstringYespending, delivered or failed.
attemptsintegerYesHow many times Velo has posted this delivery.
next_attempt_attimestampNoPresent only while the status is pending.
last_errorstringYesThe transport or status error from the most recent attempt.
response_statusinteger or nullYesThe HTTP status your endpoint returned, or null if the request never completed.
payloadobjectYesThe 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
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?

Edit this page on GitHub