Webhooks
Receive signed session, usage and balance events on your server.
Voxera sends signed POST requests to your server when sessions, usage and balances change.
Add an endpoint
Open your project's Webhooks & Events tab, and choose Add Endpoint. The URL must use HTTPS and be publicly reachable. The endpoint's signing secret is shown when you create it, so store it on your server.
You can send a test event from the console. Test events have the type test.ping, and they're never billed.
Events
| Event | Sent when |
|---|---|
session.started | A session starts |
session.ended | A session ends |
participant.joined | A participant joins |
participant.left | A participant leaves |
usage.aggregated | Usage has been totalled |
project.suspended | A project is suspended |
project.resumed | A project is resumed |
balance.low_warning | The balance is running low |
balance.grace_started | The balance entered its grace period |
balance.cutoff_executed | Service was cut off for lack of balance |
test.ping | You sent a test from the console |
See Low balance and suspension for what the balance events mean.
Payload
{
"id": "evt_...",
"type": "session.ended",
"project_id": "...",
"created_at": "2026-11-03T10:00:00.000Z",
"data": {}
}Verify the signature
Each request carries these headers:
| Header | Description |
|---|---|
voxera-signature | t=<timestamp>,v1=<hex> |
voxera-timestamp | When this attempt was signed, in Unix seconds |
voxera-event-id | The event id, the same on every retry |
voxera-delivery-id | The delivery id |
The signature is an HMAC-SHA256 of timestamp.eventId.rawBody, keyed with the endpoint's secret. Hash the exact bytes you received, and don't parse and re-serialize them first. Reject timestamps more than 300 seconds old.
import crypto from "node:crypto";
import express from "express";
function verifyWebhook(rawBody, headers, secret) {
const parts = Object.fromEntries(
String(headers["voxera-signature"] ?? "").split(",").map((part) => part.split("=")),
);
const timestamp = Number(parts.t);
if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${headers["voxera-event-id"]}.${rawBody}`)
.digest("hex");
const a = Buffer.from(String(parts.v1));
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
const app = express();
app.post("/webhooks/voxera", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyWebhook(req.body, req.headers, process.env.VOXERA_WEBHOOK_SECRET)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString());
// Handle the event. Skip any event.id you've already processed.
res.status(200).send("OK");
});Delivery and retries
- Respond with a
2xxwithin 5 seconds. Do any slow work after you respond. - A failed delivery is retried with growing delays: about 2 seconds, then 4, 8 and so on, up to 5 minutes between attempts.
- If you respond
429withRetry-After, Voxera waits that long, between 1 and 300 seconds. - After 5 failed attempts, the delivery stops. You can see each attempt and its failure reason in the Webhooks & Events tab.
The same event can arrive more than once. It keeps the same id across retries, so use voxera-event-id to skip duplicates.
Rotate a secret
Rotate an endpoint's secret in the console, and choose how long the old one stays valid. The default is 24 hours. During that time, update your server to the new secret.