Webhooks
Receive completed analyses, failures, threshold breaches and alerts as signed HTTP deliveries: event types, payloads, headers, HMAC verification in Node, .NET and Python, retries and troubleshooting.
Webhooks push events from VoiceLens to a URL you control the moment they happen. Use them to route low-scoring calls into a QA queue, to store every analysis in your own warehouse, or to page someone when an alert fires. Every delivery is signed with HMAC-SHA256 so your endpoint can prove it came from VoiceLens.
Create an endpoint
Under Webhooks in the dashboard (Admin role):
Add the URL and a name
The URL must be
https. Name the endpoint after the system that receives it.Choose events
Subscribe to any combination of the five event types below.
Optionally set a health-score threshold
A value from 1 to 100. Completed calls scoring strictly below it, or containing at least one danger fault, additionally produce a
threshold.breachedevent for this endpoint.Store the secret
The signing secret (prefix
whsec_) is shown once at creation and whenever you rotate it. Listings show only a hint.Send a test
Send test event delivers a
webhook.testpayload so you can verify the signature before real traffic arrives.
The dashboard also lists recent deliveries with status codes and lets you redeliver any of them.
Event types
| Type | When |
|---|---|
conversation.completed | A call finished transcription and analysis. |
conversation.failed | Processing failed; data.conversation.processingError says why. |
threshold.breached | A completed call scored below the endpoint's threshold or has a danger fault. Requires a threshold on the endpoint. |
alert.triggered | An alert rule crossed its threshold. |
alert.resolved | The rule's condition cleared (when recovery notifications are enabled). |
webhook.test | Sent only by the test button. |
Payload
Every delivery is a JSON envelope. Fields that do not apply are omitted rather than null.
{
"id": "evt_01J8…",
"type": "conversation.completed",
"createdAt": "2026-09-16T09:41:12Z",
"workspaceId": "b3c1…",
"data": {
"conversation": { "…": "see below" },
"threshold": { "…": "threshold.breached only" },
"alert": { "…": "alert.* only" },
"message": "webhook.test only"
}
}data.conversation
Present on conversation.completed, conversation.failed and threshold.breached.
| Field | Meaning |
|---|---|
id, title, status, source, url | Identity and a link to the call in the dashboard. |
channel { id, name } | The channel. |
externalCallId, custom | Your identifiers and custom metadata from ingest, returned untouched. |
agent { id, name, type } | Agent identity from ingest metadata. |
healthScore | 0 to 100; absent on failed calls. |
durationSec | Call length. |
faults { total, danger, warning, info } | Open fault counts. |
completionRate, coverageRate | Slot and question rates. |
outcome, resolvedCaseLabel | Call outcome and the rubric case branch. |
evaluationTemplateVersionId | The rubric version used. |
startedAt, completedAt, createdAt | Timestamps. |
processingError | On failures. |
isTestCall | true for test-call recordings. |
data.threshold
{ "healthScoreBelow": 70, "healthScore": 58, "dangerFaults": 1 }: the endpoint's threshold, the call's score and its danger-fault count.
data.alert
ruleId, eventId, name, metric, comparison, threshold, observedValue, sampleSize, window { kind, size }, optional channel { id, name }, triggeredAt, resolvedAt (on alert.resolved), sampleCalls[] (a few conversation summaries from the window) and url.
Headers
| Header | Value |
|---|---|
X-VoiceLens-Signature | t=<unix seconds>,v1=<hex> |
X-VoiceLens-Event | The event type. |
X-VoiceLens-Event-Id | The envelope id. Stable across retries: use it for idempotency. |
X-VoiceLens-Delivery | Unique per delivery attempt. |
X-VoiceLens-Timestamp | The same t as in the signature. |
Content-Type | application/json |
User-Agent | VoiceLens-Webhooks/1.0 |
Verify the signature
The signature is computed over the timestamp and the raw request body:
v1 = hex( HMAC-SHA256( secret, "<t>" + "." + raw_body ) )Compare with a constant-time comparison, and reject deliveries whose t is older than your tolerance (5 minutes is a sensible default) to defeat replays. Read the body as bytes before any JSON parsing; re-serialised JSON will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyVoiceLens(rawBody, signatureHeader, secret, toleranceSec = 300) {
const parts = Object.fromEntries(signatureHeader.split(",").map((kv) => kv.split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
// Express: keep the raw body
app.post("/voicelens", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyVoiceLens(req.body.toString("utf8"), req.header("X-VoiceLens-Signature"), process.env.WHSEC)) {
return res.status(400).send("bad signature");
}
const event = JSON.parse(req.body);
// handle event.type …
res.sendStatus(204);
});Delivery, retries and disabling
- A delivery succeeds on any
2xxresponse within 10 seconds. Respond quickly and process asynchronously. - Failures are retried up to 8 attempts with growing delays: 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours.
- After 30 consecutive failures the endpoint is disabled and shown as such in the dashboard. Fix the receiver, then re-enable it.
- Retries reuse the event
idand a newX-VoiceLens-Delivery. Deduplicate on the event id. - Events are delivered at least once and not necessarily in order; use
createdAtif ordering matters.
Rotate the secret
Rotate secret on an endpoint generates a new whsec_ value, shown once. Deliveries are signed with the new secret immediately, so update the receiver first if you cannot tolerate a few failures, or accept both secrets during the switch.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body was parsed and re-serialised before hashing, or the wrong secret is in use after a rotation. Hash the raw bytes. |
| Deliveries time out | The receiver does work before responding. Return 204 first, process later. |
| Duplicate events | Normal after a retry. Store processed event ids. |
No threshold.breached events | The endpoint has no health-score threshold set, or calls score above it and have no danger faults. |
| Endpoint disabled | 30 consecutive failures. Check the delivery log for the response codes. |