Agent Webhook (call enrichment and call end)
Verified against backend/internal/webhooks/service.go, handlers.go, types.go, backend/pkg/enrichment/config.go, agent/webhook_client.py and agent/agent_worker.py, and exercised against production on 2026-10-07 (a signed test delivery, its signature checked with the recipe below).
Each agent can call your server during its calls:
call_start— when a call connects, before the agent speaks. Your answer can tell the agent who is calling (enrichment): name, plan, open tickets, anything your CRM knows.call_end— when the call is over: duration, status, transcript.
This is the agent's own webhook, set on the agent. It is not the same thing as the account-wide event webhooks (call.started, lead.created, …) in Webhooks: different payload, different signature, different settings. You can use either or both.
Turn it on
The webhook block of the agent's settings, through PATCH /agents/:id/config or PATCH /agents/:id:
{
"webhook": {
"enabled": true,
"url": "https://crm.example.co.il/voicematrix",
"secret": "a-long-random-string",
"events": ["call_start", "call_end"],
"timeout_ms": 5000
}
}| Key | Notes |
|---|---|
enabled | true to send |
url | a public https:// (or http://) URL. Private, internal and loopback addresses are refused when you save, and again on every delivery |
secret | signs every request (below). Required when enabled. Write-only: it reads back as null, and secrets_configured["webhook.secret"] says one is stored. Through /config, omit it to keep the stored one; PATCH /agents/:id replaces the whole block, so send it again there |
events | call_start, call_end, or both. Omitted with enabled: true: both. An empty list is refused |
timeout_ms | how long we wait for your answer, default 5000. Answer call_start within a few seconds: the agent stops waiting at 10 s and starts the call without enrichment |
An enabled block without url or secret, with a private URL, or with no events is refused with 400 INVALID_WEBHOOK_CONFIG (VALIDATION_ERROR through /config). Test it at any time with POST /agents/:id/webhook/test, which sends a signed request of the same shape ("method": "test") and reports your endpoint's answer — see Agents.
What we send
POST <url> with:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | VoiceMatrix-Webhooks/2.0 (tests: VoiceMatrix-Webhooks/1.0, plus X-Webhook-Event: test) |
X-Webhook-Signature | lowercase hex of HMAC-SHA256(key = your secret, message = the raw request body) |
The body is JSON-RPC 2.0. call_start:
{
"jsonrpc": "2.0",
"id": "8b0f6a52-1c1e-4d8e-9a43-0c3f5b9f2d71",
"method": "webhook/lifecycle",
"params": {
"event": "new_inbound_call",
"timestamp": "2026-10-07T09:14:03Z",
"agent": { "id": "8d01bbe0-79c5-4e7c-9b04-8448cdae5995", "name": "סוכנת שירות" },
"call": {
"id": "3f2a9c1e-…",
"caller": "+972541234567",
"callee": "+97233827850",
"direction": "inbound",
"channel": "phone",
"status": "initializing"
}
}
}eventisnew_inbound_callfor every call, outbound ones included; usecall.direction(inbound,outbound,outbound-campaign,outbound-callback, …).- For an outbound call,
calleris the number dialled.
call_end — same envelope, "event": "call_ended", and the call's outcome:
{
"jsonrpc": "2.0",
"id": "…",
"method": "webhook/lifecycle",
"params": {
"event": "call_ended",
"timestamp": "2026-10-07T09:16:09Z",
"agent": { "id": "…", "name": "סוכנת שירות" },
"call": {
"id": "3f2a9c1e-…", "caller": "+972541234567", "callee": "+97233827850",
"direction": "inbound", "channel": "phone",
"status": "completed", "duration": 125, "cost": 0.0417,
"started_at": "2026-10-07T09:14:03Z", "ended_at": "2026-10-07T09:16:08Z",
"transcript": [
{ "role": "assistant", "text": "שלום, הגעתם למוקד השירות, במה אפשר לעזור?", "t": 1791335643.2 },
{ "role": "user", "text": "רציתי לבדוק את החשבונית האחרונה", "t": 1791335649.9 }
],
"summary": "assistant: שלום, הגעתם למוקד השירות… user: רציתי לבדוק…"
},
"metadata": { "crm_id": "C-1042" }
}
}status:completed,failedorno-answer.durationis seconds.costis an internal estimate.summaryis the start of the transcript, not an AI summary.metadatais what you returned atcall_start(below), handed back so you can join the two.- Your answer to
call_endis not used; return200with{"jsonrpc": "2.0", "result": {}}.
Verify the signature
Compute the HMAC over the raw body bytes, before parsing them — re-serialising JSON changes the bytes (&, <, > are escaped by our encoder) and the signature with them.
import hmac, hashlib
def is_from_voicematrix(raw_body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)const crypto = require('crypto');
function isFromVoiceMatrix(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature || ''));
}This is not the signature of the account-wide event webhooks (v1= plus a timestamp, see Webhooks); a verifier written for those rejects these requests. There is no timestamp, so to refuse replays, remember the JSON-RPC ids you have processed.
Answer call_start to enrich the call
Return 200 with a JSON-RPC result. Everything is optional:
{
"jsonrpc": "2.0",
"id": "8b0f6a52-1c1e-4d8e-9a43-0c3f5b9f2d71",
"result": {
"dynamic_data": { "firstName": "דנה", "plan": "עסקים", "open_tickets": 2 },
"additional_context": "לקוחה ותיקה מאז 2021. חשבונית אחרונה שולמה.",
"metadata": { "crm_id": "C-1042" }
}
}| Field | Effect |
|---|---|
dynamic_data | facts about the caller; the agent receives them for this call as customer information |
additional_context | free text added to the agent's instructions for this call |
metadata | not shown to the agent; stored on the call and returned to you in call_end |
- Echo the request's
idas a string; a numericidmakes the whole answer unreadable. - Values are treated as data, never instructions: phrases that try to instruct the model are filtered, and each value is capped at 2,000 characters.
- If your endpoint is slow, down, answers non-2xx or with an empty body, the call goes ahead without enrichment. Nothing is retried.
- Everything you return can be spoken to the caller — return only what the agent may say.
Delivery log
GET /agents/:id/webhook-logs lists recent deliveries (event, webhookUrl, requestBody, responseStatus, responseTimeMs, success, errorMessage, createdAt). It needs webhooks:manage and calls:read, because a call_end body carries the transcript.
Good to know
- Simulations and the dashboard's test calls do not trigger the webhook.
- There are no retries, and
call_endcan, rarely, arrive twice: deduplicate onparams.call.id. - Authenticate every request with the signature.