Skip to content
Server

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

EventSent when
session.startedA session starts
session.endedA session ends
participant.joinedA participant joins
participant.leftA participant leaves
usage.aggregatedUsage has been totalled
project.suspendedA project is suspended
project.resumedA project is resumed
balance.low_warningThe balance is running low
balance.grace_startedThe balance entered its grace period
balance.cutoff_executedService was cut off for lack of balance
test.pingYou 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:

HeaderDescription
voxera-signaturet=<timestamp>,v1=<hex>
voxera-timestampWhen this attempt was signed, in Unix seconds
voxera-event-idThe event id, the same on every retry
voxera-delivery-idThe 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 2xx within 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 429 with Retry-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.