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
| Target | Set up with | Receives | Signed with |
|---|---|---|---|
| Workspace endpoints | POST /api/v1/webhooks | The event types it subscribes to, for every call in the workspace | The endpoint’s own secret |
An agent’s webhook_url | The agent’s Webhook & number tab | Every event of that agent’s calls | The 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"
}
secretis returned only here, once. Store it now; later you see onlysecret_hint.
| Field | Type | Default | Rules |
|---|---|---|---|
url | string | required | https. Plain http only for localhost or 127.0.0.1. Up to 500 characters. |
description | string | "" | Up to 200 characters. |
events | array | ["*"] | "*" for every event, or a list of event types from Webhook events. |
active | boolean | true | An 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"]}}
}
}
| Field | Meaning |
|---|---|
id | The event id. The same event sent to two targets has the same id. |
type | The event type. See Webhook events. |
created_at | When the event happened, in UTC. |
workspace_id, call_id, agent_id | Which call it belongs to. |
data.message | A one-line description. |
data.level | info, warning or error. |
data.latency_ms | For timing events, the latency; otherwise null. |
data.* | Fields specific to the event type. |
Headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Voxa-Webhooks/1.0 |
X-Voxa-Event | The event type. |
X-Voxa-Delivery | The delivery id (whd_...). Retries reuse it, so use it to drop duplicates. |
X-Voxa-Signature | t=<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_...).
- Read the raw body, before any JSON parsing.
- Split the header into
tandv1. - Reject the request if
tis more than 5 minutes from now (this stops replays). - Compute the HMAC and compare it to
v1in 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
2xxwithin 10 seconds. Do slow work after you respond. - Redirects are not followed; a
3xxcounts 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_atif order matters. - Duplicates. A delivery can arrive more than once. Use
X-Voxa-Delivery(or the eventidplus 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 status | Meaning |
|---|---|
pending | Waiting for its first attempt. |
delivered | Your server answered 2xx. |
retrying | It failed and will be tried again at next_attempt_at. |
failed | Every 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.