Skip to main content

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 existcall.started, call.ended, lead.created, lead.updated. The other eight are not in the catalog the server actually accepts: call.transferred, call.failed, all three appointment.* events, campaign.started, plus lead.qualified and campaign.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 eventagent.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 single WebhookEvents slice, so the published catalog and the accepted set cannot drift apart.

The real event catalog

bash
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:

json
{ "events": [ { "name": "call.started", "description": "When a call begins, with the caller and direction", "category": "calls" } ] }
EventCategoryFires when
call.startedcallsA call begins — payload includes caller and direction
call.endedcallsA call ends — payload includes transcript and summary
lead.createdleadsA new lead is captured
lead.updatedleadsA lead's status changes
agent.erroragentsAn 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.

MethodPathDoes
GET/webhooks/eventsThe event catalog above — the same slice the validator reads
GET/webhooksList your subscriptions
POST/webhooksRegister one — the only response that ever contains secret
GET/webhooks/:idOne subscription, without its secret
PATCH/webhooks/:idUpdate — see the note below
DELETE/webhooks/:idRemove it
POST/webhooks/:id/testSend a sample payload to the destination (see the caveat further down)
GET/webhooks/:id/deliveriesThe 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

bash
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
  }'
json
{
  "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 than POST + 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):

json
{
  "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):

HeaderValue
X-Webhook-Signaturev1= + hex(HMAC-SHA256(secret, "<X-Webhook-Timestamp>.<raw body>")) — Stripe's scheme. The key is the whole secret string, whsec_ prefix included
X-Webhook-TimestampUnix 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-IDThe 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-signatureThe 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 the v1= prefix on the header, and key the HMAC with the full secret string.

javascript
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

BehaviorValue
Delivery timeout10 seconds
Retry backoffs5 attempts, spread over ~2h45m
Auto-pauseAfter 10 consecutive failures
Replay limit20 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

bash
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:

json
{
  "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

bash
curl https://api.voicematrix.ai/api/v1/ext/webhooks/<id>/deliveries -H "X-API-Key: vm_live_..."
json
{
  "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 Serverlist_webhooks / webhook_deliveries as read-only MCP tools
  • Changelog
All pages