Skip to content
VoxaDocs
Navigation
Open console →

Webhooks

Webhooks

Voxa posts each event of every call to your server as it happens, signed with a secret only you and Voxa know, and retries deliveries that fail, so a slow or broken endpoint never affects a live call.

Where events go

TargetSet up withReceivesSigned with
Workspace endpointsPOST /api/v1/webhooksThe event types it subscribes to, for every call in the workspaceThe endpoint’s own secret
An agent’s webhook_urlThe agent’s Webhook & number tabEvery event of that agent’s callsThe workspace’s default secret

Both can be set. An event goes to every matching target.

Delivery runs in the background: Voxa saves each delivery and a worker posts it. Your endpoint’s speed never changes how the call sounds.

Register an endpoint

Managing endpoints needs the keys.manage permission, which API keys have.

curl -X POST https://voxa.abhinavyadav.in/api/v1/webhooks \
  -H "X-API-Key: $VOXA_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.example.com/voxa/events",
    "description": "CRM sync",
    "events": ["call.started", "call.ended", "call.completed"]
  }'
{
  "id": "we_5f0c2a7e9b1d4c3a8e6f0b2d4c6a8e0f",
  "url": "https://crm.example.com/voxa/events",
  "description": "CRM sync",
  "events": ["call.completed", "call.ended", "call.started"],
  "active": true,
  "secret_hint": "whsec_Abc1…9xYz",
  "created_at": "2026-10-01T15:02:11.481000+05:30",
  "created_by": "key:backend",
  "secret": "whsec_Abc1...full secret...9xYz"
}

secret is returned only here, once. Store it now; later you see only secret_hint.

FieldTypeDefaultRules
urlstringrequiredhttps. Plain http only for localhost or 127.0.0.1. Up to 500 characters.
descriptionstring""Up to 200 characters.
eventsarray["*"]"*" for every event, or a list of event types from Webhook events.
activebooleantrueAn inactive endpoint gets nothing.

A workspace can have up to 20 endpoints. GET /api/v1/webhooks/events lists the event types you can subscribe to.

Manage endpoints

# List
curl https://voxa.abhinavyadav.in/api/v1/webhooks -H "X-API-Key: $VOXA_API_KEY"

# Change some fields (only the ones you send)
curl -X PATCH https://voxa.abhinavyadav.in/api/v1/webhooks/$ENDPOINT_ID \
  -H "X-API-Key: $VOXA_API_KEY" -H "Content-Type: application/json" \
  -d '{"active": false}'

# Delete
curl -X DELETE https://voxa.abhinavyadav.in/api/v1/webhooks/$ENDPOINT_ID -H "X-API-Key: $VOXA_API_KEY"

# Send a test "ping" now
curl -X POST https://voxa.abhinavyadav.in/api/v1/webhooks/$ENDPOINT_ID/test -H "X-API-Key: $VOXA_API_KEY"

The test sends one ping event straight away (no retries) and returns the delivery, including your server’s response.

The request

Every delivery is a POST with a JSON body:

{
  "id": "evt_7d0c6f0f2b8e4b0a9c1e3f5a7b9d1e3f",
  "type": "tool.result",
  "created_at": "2026-10-01T09:15:04.512000+00:00",
  "workspace_id": "6df74233a1b04c2e9f8d7c6b5a4e3d2c",
  "call_id": "00b1d622a8f04f4f8f3a3c2d9e5b7a11",
  "agent_id": "1b81a241c0e44f5c9d1f0e2a7c3b9d10",
  "data": {
    "message": "check_availability -> HTTP 200",
    "level": "info",
    "latency_ms": 184,
    "name": "check_availability",
    "status": 200,
    "result": {"status": 200, "result": {"free_slots": ["10:30", "12:00"]}}
  }
}
FieldMeaning
idThe event id. The same event sent to two targets has the same id.
typeThe event type. See Webhook events.
created_atWhen the event happened, in UTC.
workspace_id, call_id, agent_idWhich call it belongs to.
data.messageA one-line description.
data.levelinfo, warning or error.
data.latency_msFor timing events, the latency; otherwise null.
data.*Fields specific to the event type.

Headers:

HeaderValue
Content-Typeapplication/json
User-AgentVoxa-Webhooks/1.0
X-Voxa-EventThe event type.
X-Voxa-DeliveryThe delivery id (whd_...). Retries reuse it, so use it to drop duplicates.
X-Voxa-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Verify the signature

The signature is an HMAC-SHA256 of the string <t>.<raw request body>, keyed with your secret (whsec_...).

  1. Read the raw body, before any JSON parsing.
  2. Split the header into t and v1.
  3. Reject the request if t is more than 5 minutes from now (this stops replays).
  4. Compute the HMAC and compare it to v1 in constant time.

Python

import hashlib
import hmac
import time


def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(item.split("=", 1) for item in header.split(","))
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Node

import crypto from "node:crypto";

export function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, read the raw body with express.raw({ type: "application/json" }) on the webhook route.

Respond and retries

  • Respond with any 2xx within 10 seconds. Do slow work after you respond.
  • Redirects are not followed; a 3xx counts as a failure.
  • Retries. Any other status, a timeout or a connection error is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours. After the sixth failed attempt the delivery is failed.
  • Order. Events are sent roughly in order, but a retry can arrive after a later event. Sort by created_at if order matters.
  • Duplicates. A delivery can arrive more than once. Use X-Voxa-Delivery (or the event id plus your endpoint) to ignore repeats.

Delivery log

Every attempt is logged. Read it in the console under Webhooks, or through the API:

curl "https://voxa.abhinavyadav.in/api/v1/webhooks/deliveries?endpoint_id=$ENDPOINT_ID&status=failed" \
  -H "X-API-Key: $VOXA_API_KEY"

Filters: endpoint_id (agent for agent webhook URLs), status (comma-separated), call_id, limit (up to 200, default 50) and skip.

Delivery statusMeaning
pendingWaiting for its first attempt.
deliveredYour server answered 2xx.
retryingIt failed and will be tried again at next_attempt_at.
failedEvery attempt failed, or the endpoint was deleted.
# One delivery, with the request body and your last response
curl https://voxa.abhinavyadav.in/api/v1/webhooks/deliveries/$DELIVERY_ID -H "X-API-Key: $VOXA_API_KEY"

# Try it once more, now
curl -X POST https://voxa.abhinavyadav.in/api/v1/webhooks/deliveries/$DELIVERY_ID/retry -H "X-API-Key: $VOXA_API_KEY"

A manual retry is one extra attempt; it does not restart the automatic schedule.

Rotate a secret

# An endpoint's secret
curl -X POST https://voxa.abhinavyadav.in/api/v1/webhooks/$ENDPOINT_ID/rotate-secret -H "X-API-Key: $VOXA_API_KEY"

# The default secret, used for agents' webhook URLs
curl -X POST https://voxa.abhinavyadav.in/api/v1/webhooks/default-secret/rotate -H "X-API-Key: $VOXA_API_KEY"

Both return the new secret once. The old secret stops working at once, including for retries still waiting, so update your server first and keep accepting both secrets for a moment if you can.

Esc