Skip to main content

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:

json
{
  "webhook": {
    "enabled": true,
    "url": "https://crm.example.co.il/voicematrix",
    "secret": "a-long-random-string",
    "events": ["call_start", "call_end"],
    "timeout_ms": 5000
  }
}
KeyNotes
enabledtrue to send
urla public https:// (or http://) URL. Private, internal and loopback addresses are refused when you save, and again on every delivery
secretsigns 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
eventscall_start, call_end, or both. Omitted with enabled: true: both. An empty list is refused
timeout_mshow 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:

HeaderValue
Content-Typeapplication/json
User-AgentVoiceMatrix-Webhooks/2.0 (tests: VoiceMatrix-Webhooks/1.0, plus X-Webhook-Event: test)
X-Webhook-Signaturelowercase hex of HMAC-SHA256(key = your secret, message = the raw request body)

The body is JSON-RPC 2.0. call_start:

json
{
  "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"
    }
  }
}
  • event is new_inbound_call for every call, outbound ones included; use call.direction (inbound, outbound, outbound-campaign, outbound-callback, …).
  • For an outbound call, caller is the number dialled.

call_end — same envelope, "event": "call_ended", and the call's outcome:

json
{
  "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, failed or no-answer. duration is seconds. cost is an internal estimate.
  • summary is the start of the transcript, not an AI summary.
  • metadata is what you returned at call_start (below), handed back so you can join the two.
  • Your answer to call_end is not used; return 200 with {"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.

python
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)
javascript
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:

json
{
  "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" }
  }
}
FieldEffect
dynamic_datafacts about the caller; the agent receives them for this call as customer information
additional_contextfree text added to the agent's instructions for this call
metadatanot shown to the agent; stored on the call and returned to you in call_end
  • Echo the request's id as a string; a numeric id makes 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_end can, rarely, arrive twice: deduplicate on params.call.id.
  • Authenticate every request with the signature.
All pages