Technical reference for all webhook events sent by the voice agent platform.
Two different mechanisms — make sure you're reading the right one.
This page Call-ended callback Configured Per workspace / agent subscription callbackUrlon a single POST/api/v1/callsSigned Yes, HMAC-SHA256 No Retried Yes No — single delivery Events call.ended,call.analyzed,call.failed,call.extracted,call.recording_readycall.completed,call.failed,call.voicemailonlyIf you passed
callbackUrlwhen placing a call, the payload you receive is the one documented on the Calls page, not the events below.
Webhook Delivery#
Request Format#
POST {your_webhook_url}
Content-Type: application/json
User-Agent: VoiceAgent-Webhook/1.0
X-Webhook-Signature: t=1704067200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Signature Verification#
The signature is an HMAC-SHA256, keyed with your subscription's whsec_... secret, over the string "{t}.{raw request body}". Reject requests whose t is more than 5 minutes old.
// Header: X-Webhook-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
// Signed string: "<t>.<raw request body>"
func verifyWebhook(body []byte, header, secret string) bool {
var ts, sig string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
sig = kv[1]
}
}
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || time.Now().Unix()-t > 300 { // 5 minute replay window
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "." + string(body)))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(sig))
}
import hashlib
import hmac
import time
def verify_webhook(body: bytes, header: str, secret: str) -> bool:
"""header is the X-Webhook-Signature value: t=<unix seconds>,v1=<hex>"""
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
ts, sig = parts.get("t", ""), parts.get("v1", "")
if not ts.isdigit() or time.time() - int(ts) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, sig)
Event Types#
call.ended#
Triggered when a call completes.
{
"event": "call.ended",
"timestamp": "2024-01-01T12:05:00Z",
"data": {
"call_id": "call_abc123",
"agent_id": "agent_xyz",
"duration_seconds": 300,
"end_reason": "user_hangup",
"disposition": "success",
"transcript": {
"summary": "Customer inquired about order status...",
"turns": [
{
"role": "assistant",
"content": "Hello! How can I help you today?",
"timestamp": "2024-01-01T12:00:01Z"
},
{
"role": "user",
"content": "I want to check my order status",
"timestamp": "2024-01-01T12:00:05Z"
}
]
},
"metadata": {
"functions_called": ["get_order_status"],
"transfer_occurred": false,
"recording_url": "https://..."
}
}
}
End Reasons:
| Reason | Description |
|---|---|
user_hangup |
User ended the call |
agent_hangup |
Agent completed and ended |
transfer |
Call was transferred |
error |
Call ended due to error |
timeout |
No activity timeout |
max_duration |
Maximum call duration reached |
call.failed#
Triggered when an outbound call fails to connect (busy, no-answer, rejected).
{
"event": "call.failed",
"timestamp": "2024-01-01T12:00:30Z",
"data": {
"call_id": "call_abc123",
"agent_id": "agent_xyz",
"to": "+1234567890",
"from": "+0987654321",
"provider": "twilio",
"failure_reason": "busy",
"failure_code": "3010",
"failure_description": "Busy Line",
"hangup_source": "Carrier",
"workspace_id": "ws_123"
}
}
Failure Reasons:
| Reason | Description |
|---|---|
busy |
Recipient's line is busy |
no-answer |
Call was not answered |
rejected |
Call was declined by recipient |
canceled |
Call was canceled before connecting |
failed |
General failure (network issue) |
unallocated |
Phone number does not exist |
call.analyzed, call.extracted, call.recording_ready#
Sent when post-call analysis finishes, when extraction fields are ready, and when the recording is available. See Webhook Subscriptions for their payloads.
These five events (call.ended, call.analyzed, call.failed, call.extracted, call.recording_ready) are the only ones available for subscription.
Retry Policy#
Failed webhook deliveries are retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 second |
| 3 | 5 seconds |
| 4 | 30 seconds |
| 5 | 2 minutes |
Webhooks are considered failed if:
- Response status is not 2xx
- Connection timeout (10 seconds)
- Response timeout (30 seconds)
Best Practices#
1. Respond Quickly#
func handleWebhook(w http.ResponseWriter, r *http.Request) {
// Acknowledge immediately
w.WriteHeader(http.StatusOK)
// Process asynchronously
go processWebhookAsync(r.Body)
}
2. Idempotency#
func processWebhook(payload WebhookPayload) error {
// Check if already processed
if db.WebhookProcessed(payload.CallID, payload.Event) {
return nil // Already handled
}
// Process and mark as done
err := handleEvent(payload)
if err == nil {
db.MarkWebhookProcessed(payload.CallID, payload.Event)
}
return err
}
3. Verify Signatures#
Always verify webhook signatures in production to prevent spoofing.
Testing#
Local Development#
Use ngrok or similar to expose local endpoints:
ngrok http 3000
# Then configure webhook URL: https://abc123.ngrok.io/webhooks
Test Payload#
curl -X POST https://your-server.com/webhooks \
-H "Content-Type: application/json" \
-H "X-Webhook-Event: call.ended" \
-d '{
"event": "call.ended",
"timestamp": "2024-01-01T12:00:00Z",
"data": {
"call_id": "test_123",
"duration_seconds": 60,
"end_reason": "user_hangup"
}
}'
Related Resources#
- Finding Your Workspace ID - Locate your workspace ID for integrations
- Webhooks Guide - Configuration and use cases
- REST API - API endpoints
- SDKs - Client libraries