Concepts
Webhooks
Setting up an endpoint
- Build an HTTPS endpoint on your server that accepts
POSTwith a JSON body. It must be reachable from the internet: private and internal addresses are refused. - Register it — in the dashboard under Webhooks, or with POST /webhooks — and choose the events it should receive.
- Copy the signing
secretfrom the response. It is shown once. Store it like a password. - Verify the signature of every delivery, then answer
200quickly.
Event types
| Event | When it is sent |
|---|---|
call.started | A call was answered and the agent is on the line. |
call.completed | A call has ended and its analysis is ready. data.object is the call, with analysis filled in. |
call.failed | A call out was never connected. data.reason says why: no_answer (it rang out or was declined), busy, or not_connected (the number or the network refused it). Sent once per call. |
call.transferred | The agent transferred the caller to a person or another number. |
webhook.test | Sent by "Send test event" in the dashboard and by POST /webhooks/{webhook_id}/test. |
webhook.test needs no subscription: it is only sent when you ask for a test. Subscribe an endpoint to the others one by one; it receives nothing else.
The event payload
Every event has the same envelope. For call events, data.object is the full call — the same object GET /calls/{call_id} returns — including the metadata you sent when you started it.
id— unique per event, and kept when the same event is retried. De-duplicate on it.type— the event type.api_version— alwaysv1for this API.created_at— when the event happened (ISO 8601, UTC).data— the event’s content.
{
"id": "evt_5f0c2b9a7d3e4c1f8a6b2d9e0c4f7a13",
"object": "event",
"type": "call.completed",
"api_version": "v1",
"created_at": "2026-10-12T10:07:51.000Z",
"data": {
"object": {
"object": "call",
"id": "3c27f11c-8a1e-4d55-9b0e-1f4c2a7d9e10",
"agent_id": "7c3f1e90-5a44-4b21-9c07-2ed5b8f41a66",
"direction": "outbound",
"channel": "phone",
"status": "completed",
"from": "+4721000000",
"to": "+4712345678",
"started_at": "2026-10-12T10:04:12.000Z",
"ended_at": "2026-10-12T10:07:33.000Z",
"duration_seconds": 201,
"end_reason": "caller_hangup",
"metadata": {
"crm_contact_id": "48213"
},
"has_recording": true,
"analysis": {
"summary": "The caller booked a check-up on Tuesday 14 October at 12:00.",
"outcome": "resolved",
"sentiment": "positive",
"action_items": [
"Send booking confirmation by SMS"
],
"created_at": "2026-10-12T10:07:51.000Z"
}
}
}
}Delivery and retries
- Each event is a
POSTwith a JSON body. Your endpoint has 10 seconds to answer. - Any 2xx status acknowledges the event. Anything else, or no answer in time, counts as a failure.
- A failed delivery is retried, up to 5 attempts in total, waiting longer before each one (exponential backoff).
- The same event can arrive more than once — a retry after your server processed it but answered too late, for example. Store the event
idand skip ids you have seen. - Events can arrive out of order. When order matters, fetch the call with GET /calls/{call_id} and trust its
status. - Answer first, work later: put the event on a queue and return
200, then process it.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
Voximate-Signature | t=1760263671,v1=5257a869e7… |
Voximate-Event-Id | The event id, the same as id in the body. |
Voximate-Event-Type | The event type, the same as type in the body. |
User-Agent | Voximate-Webhooks/1.0 |
Verifying signatures
Anyone can send a request to your endpoint. The signature proves a delivery came from Voximate and was not changed on the way. The Voximate-Signature header has two parts:
t— when Voximate signed the delivery, in Unix seconds.v1— the signature: HMAC-SHA256, hex-encoded.
To verify a delivery:
- Read the raw request body exactly as it arrived. Do not parse and re-serialise the JSON first — a single changed space breaks the signature.
- Build the signed message:
t, a dot, then the raw body —${t}.${rawBody}. - Compute HMAC-SHA256 of that message, using the endpoint secret exactly as shown as the key (including its
whsec_prefix), and hex-encode it. - Compare it to
v1with a constant-time comparison. - Reject the delivery if
tis more than 300 seconds from your clock. That stops an old, captured delivery from being replayed.
Verification code
// npm install express
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.VOXIMATE_WEBHOOK_SECRET; // "whsec_…", shown once
// The signature covers the RAW body, so read it as bytes, not parsed JSON.
app.post('/voximate/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('Voximate-Signature') ?? '';
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const t = Number(parts.t);
const v1 = parts.v1 ?? '';
// Refuse old or future-dated deliveries (replay protection).
if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(400).send('stale signature');
}
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.`)
.update(req.body) // Buffer — the exact bytes received
.digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(v1, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(400).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Events can arrive more than once: skip ids you have already handled.
console.log(event.id, event.type);
res.sendStatus(200); // answer fast; do slow work after acknowledging
});
app.listen(3000);Older integrations
X-Voximate-Signature: sha256=… — an HMAC-SHA256 of the body alone, with no timestamp. It is kept for integrations built before v1. New code should verify Voximate-Signature, which also protects against replays.Secrets and rotation
A secret is whsec_ followed by 64 hex characters, and it is shown only when the endpoint is created and when you rotate it. To replace a secret — because it leaked, or as routine — call POST /webhooks/{webhook_id}/rotate-secret. Deliveries are signed with the new secret from the next event, so update your server straight away. Deleting an endpoint deletes its secret.
Testing
- In the dashboard, open Webhooks and click Send test event, or call POST /webhooks/{webhook_id}/test. You get a signed
webhook.testevent and see the status your server answered. - Every delivery attempt is logged, with your server’s status code: GET /webhooks/{webhook_id}/deliveries.
- Pause an endpoint while you work on it with PATCH /webhooks/{webhook_id} and
{"status": "paused"}. - While developing on your own machine, expose it with a tunnel such as
ngrokorcloudflared— the endpoint must be public https.
Checklist
- The endpoint is HTTPS and public.
- The signature is checked on the raw body, with a constant-time compare and the 300-second window.
- Event ids are stored, and duplicates skipped.
- The endpoint answers 2xx within 10 seconds and does slow work afterwards.
- The secret lives in a secret store, not in code.