Webhooks
Verified against backend/internal/publicapi/webhooks_handler.go, webhooks_dto.go, webhook_dispatcher.go, and pkg/models/webhook.go.
If you've read the previous version of this page: it listed 12 events —
call.started,call.ended,call.transferred,call.failed,lead.created,lead.updated,lead.qualified,appointment.created,appointment.updated,appointment.cancelled,campaign.started,campaign.completed. Only four of those twelve exist —call.started,call.ended,lead.created,lead.updated. The other eight are not in the catalog the server actually accepts:call.transferred,call.failed, all threeappointment.*events,campaign.started, pluslead.qualifiedandcampaign.completed(the last two were once defined as constants but are not offered or dispatched today). A customer who subscribed to any of them received exactly nothing, with no error. The old page also omitted a real event —agent.error, which does fire. The true catalog is the five events below, and it is the same list the create-webhook validator accepts: both read the singleWebhookEventsslice, so the published catalog and the accepted set cannot drift apart.
The real event catalog
curl https://api.voicematrix.ai/api/v1/ext/webhooks/events -H "X-API-Key: vm_live_..."The response is keyed events, not data — this is the one list on the API that isn't in the standard {"data": [...], "pagination": {...}} envelope, and it carries no pagination:
{ "events": [ { "name": "call.started", "description": "When a call begins, with the caller and direction", "category": "calls" } ] }| Event | Category | Fires when |
|---|---|---|
call.started | calls | A call begins — payload includes caller and direction |
call.ended | calls | A call ends — payload includes transcript and summary |
lead.created | leads | A new lead is captured |
lead.updated | leads | A lead's status changes |
agent.error | agents | An agent reports an unhandled error mid-call |
That's it. This isn't a curated subset for the docs — GET /webhooks/events returns exactly these five, and each has a real, tested dispatch call site (enforced by a test that parses the backend source with go/ast, specifically so "five that fire" can't silently drift back into "twelve that lie"). If you need an event that isn't here — call.transferred, appointment/booking events, campaign progress — it does not exist yet; don't build a subscription around it.
call.ended was previously being rejected by the create-webhook validator with 400 Invalid event, despite firing in production — while three events that fire nothing were accepted. Fixed 2026-08-04.
Endpoints
All eight live under the base URL and require the webhooks:manage scope (publicapi/routes.go:91-98). The writes accept an Idempotency-Key header, like every other write on the API.
| Method | Path | Does |
|---|---|---|
| GET | /webhooks/events | The event catalog above — the same slice the validator reads |
| GET | /webhooks | List your subscriptions |
| POST | /webhooks | Register one — the only response that ever contains secret |
| GET | /webhooks/:id | One subscription, without its secret |
| PATCH | /webhooks/:id | Update — see the note below |
| DELETE | /webhooks/:id | Remove it |
| POST | /webhooks/:id/test | Send a sample payload to the destination (see the caveat further down) |
| GET | /webhooks/:id/deliveries | The delivery log for this subscription |
PATCH takes any of url, events, is_active, is_paused, http_method, content_type, custom_headers, payload_template, condition (webhooks_dto.go, UpdateWebhookRequest). Two things to know. custom_headers replaces the whole set when present — there is no merge, because the existing values are credentials and are never returned for you to diff against — so send the complete set every time. And because an omitted field and an empty {} are indistinguishable, reverting to the standard envelope or to unconditional delivery is done with the explicit flags clear_payload_template: true / clear_condition: true, not by sending an empty object.
Register a webhook
curl -X POST https://api.voicematrix.ai/api/v1/ext/webhooks \
-H "X-API-Key: vm_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.example.com/webhooks/voicematrix",
"events": ["call.ended", "lead.created"],
"agent_id": null
}'{
"id": "...",
"url": "https://yourapp.example.com/webhooks/voicematrix",
"events": ["call.ended", "lead.created"],
"secret": "whsec_...",
"is_active": true,
"is_paused": false,
"http_method": "POST",
"content_type": "application/json"
}secret is returned only on create — save it immediately; it's used to verify signatures and is never round-tripped again (GET/PATCH never return it, by design — the same reasoning as API keys).
Optional fields worth knowing about:
agent_id— scope the subscription to one agent instead of the whole organization.http_method/content_type— override the delivery verb/content-type if your receiver expects something other thanPOST+application/json.custom_headers— e.g.{"X-Api-Password": "..."}, added to every delivery to this webhook.payload_template— remap event fields onto your destination's own schema instead of the standard envelope, using{{double-curly-brace}}field placeholders. This exists specifically because several destinations (CRMs, in particular) expect a shape nothing like VoiceMatrix's native payload.condition—{"require": ["field_a", "field_b"]}: gate delivery until those fields are present on the event, instead of sending a partially-populated payload.
There is currently no way to preview what a template will actually render before a real event fires — the rendering-preview tooling that traces each field back to what produced it exists in the codebase but isn't wired to the public API yet. If you set a payload_template, its first real test is a real event.
Delivery payload
Unless you set a payload_template, every delivery body is this envelope (webhook_dispatcher.go:314-322):
{
"id": "<event id — one per event, shared by every subscriber that receives it>",
"event": "call.ended",
"timestamp": "2026-08-25T09:14:03Z",
"api_version": "2026-01-01",
"data": { "...": "the event's fields — caller and direction for call.started, transcript and summary for call.ended, the lead for lead.*" }
}api_version is the envelope's own contract version (EnvelopeAPIVersion, a constant), not the VoiceMatrix-Version you pin on requests.
Signature verification
Every delivery — real events and the Test button alike — carries two signature sets over the same raw body, so you can verify with either (webhook_dispatcher.go:57-62, :109-114, :1195-1207; Test: internal/webhooks/handlers.go:1343-1347):
| Header | Value |
|---|---|
X-Webhook-Signature | v1= + hex(HMAC-SHA256(secret, "<X-Webhook-Timestamp>.<raw body>")) — Stripe's scheme. The key is the whole secret string, whsec_ prefix included |
X-Webhook-Timestamp | Unix seconds at send time. It is inside the MAC — that is what lets you reject a replayed body instead of accepting it forever |
X-Webhook-ID | The delivery id — retries carry the same one, so it is your natural idempotency key (msg_test_… on a Test press) |
webhook-id / webhook-timestamp / webhook-signature | The same message in Standard Webhooks form: v1, + base64(HMAC-SHA256(key, "<id>.<timestamp>.<raw body>")) |
If you copied the previous version of this section, your verifier rejects every delivery. It hashed the body alone, with no timestamp and no
v1=prefix. The three lines that matter: prefix the timestamp and a dot to the body before hashing, expect thev1=prefix on the header, and key the HMAC with the full secret string.
const crypto = require('crypto');
// rawBody: the exact bytes received (a Buffer) — never a parsed-and-re-serialized object
function verifySignature(rawBody, headers, secret, toleranceSec = 300) {
const sig = headers['x-webhook-signature'] || '';
const ts = headers['x-webhook-timestamp'] || '';
if (!sig.startsWith('v1=') || !/^\d+$/.test(ts)) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // replay window
const expected = 'v1=' + crypto.createHmac('sha256', secret)
.update(ts + '.')
.update(rawBody)
.digest('hex');
return expected.length === sig.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}Sign over the raw bytes you received, not a value you've parsed and re-stringified — JSON key ordering isn't guaranteed to round-trip identically, and a re-serialized comparison can fail signature checks that should pass. The replay window is yours to choose — the server does not enforce one — but a timestamp-bound signature is only worth having if you check the timestamp.
Using a Standard Webhooks library instead. The headers are spec-compliant, but the secret is not: it is minted as whsec_<uuid>, and a UUID is not base64, so a library's default constructor (which strips whsec_ and base64-decodes the rest) cannot load it. Use the library's raw-key constructor with the whole secret string, and webhook-signature verifies unchanged — the server derives its key the same way (standardSigningKey in webhook_dispatcher.go: decode the tail when it is base64, otherwise use the whole secret as raw bytes).
Delivery, retries, and pausing
| Behavior | Value |
|---|---|
| Delivery timeout | 10 seconds |
| Retry backoffs | 5 attempts, spread over ~2h45m |
| Auto-pause | After 10 consecutive failures |
| Replay limit | 20 per webhook per day, 60 per organization per day |
A paused webhook stops receiving new deliveries until you either fix the destination and it resumes, or you manually unpause it. See Errors → Webhook delivery error classes for why a delivery failed, and note the specific caution there: failures classified as "ours" (e.g. ssrf_blocked) count differently than failures that are genuinely your endpoint's fault — a webhook auto-paused because of a VoiceMatrix-side reachability decision is not the same situation as one paused because your server is actually down, even though both look identical from the delivery-count alone.
Outbound requests are SSRF-guarded
Both the url on a webhook and the url on a knowledge-base source are dialed server-side, and both are validated against private/internal address ranges before the request goes out, with redirect hops re-validated the same way. This is enforced, not merely documented — attempting to register a webhook pointed at an internal address fails at creation.
Test a webhook
curl -X POST https://api.voicematrix.ai/api/v1/ext/webhooks/<id>/test \
-H "X-API-Key: vm_live_..." \
-H "Content-Type: application/json" \
-d '{"event_type": "call.ended"}'It sends a fixed sample payload to your real destination (the response says "sentToLive": true) and runs synchronously, so the reply carries the outcome:
{
"success": true,
"delivery": { "id": "...", "event_type": "lead.created", "status": "success",
"is_test": true, "response_status": 200, "response_time_ms": 121,
"attempt_count": 1 },
"sentToLive": true,
"destination": "https://yourapp.example.com/webhooks/voicematrix"
}A test does write a delivery-log row, flagged is_test: true, and it appears in GET /webhooks/:id/deliveries like any other. What the flag buys is health isolation: an is_test attempt is kept out of the webhook's failure_count / last_delivery_* columns and out of the retry queue, so a diagnostic can't pause the webhook it is diagnosing (webhooks_handler.go, IsTest: true; migration 232). It is signed exactly like a real delivery — both the legacy and Standard Webhooks header sets — so a verifier you test against will also accept production traffic.
Corrected 2026-08-25. The previous version of this page said the Test endpoint writes no delivery-log row and that "press Test, then check the delivery log" shows nothing. That is backwards. Measured on DEV: a fresh webhook went from 0 delivery rows to 1 (
is_test=true,status=success) on a single Test call.
Delivery log & replay
curl https://api.voicematrix.ai/api/v1/ext/webhooks/<id>/deliveries -H "X-API-Key: vm_live_..."{
"data": [
{
"id": "...",
"event_type": "lead.created",
"status": "failed",
"is_test": false,
"response_status": 409,
"response_time_ms": 340,
"attempt_count": 1,
"error_message": "..."
}
]
}A real production sample of 119 non-success deliveries broke down as: 46 conflict (409 — the destination correctly refusing a duplicate, not a failure), 43 with no response at all (turned out to be VoiceMatrix's own reachability guard, not the destination being down), 14 rate-limited, 16 validation errors. That's the entire reason the error-class field in the delivery log exists — see Errors for the full class list and what each one actually implies you should do.
Next
- Errors — the delivery error-class taxonomy in full
- MCP Server —
list_webhooks/webhook_deliveriesas read-only MCP tools - Changelog