Webhooks
Outgoing HTTP callbacks when a call starts, ends, or fails to connect. This is the intended way to get transcripts into your own systems — polling the call list is slower and misses the calls that never connected.
Register a webhook
POST /api/voice/webhooks
{
"name": "CRM sync",
"url": "https://your-server.example/hooks/nextneural",
"events": ["call.started", "call.ended", "call.no_answer"],
"headers": { "X-Tenant": "acme" }
}
| Field | Required | Notes |
|---|---|---|
name | ✓ | |
url | ✓ | Where events are POSTed |
events | ✓ | At least one — see below |
headers | — | Extra headers sent with every delivery |
201 Created:
{
"uuid": "wh1a2b3c-4d5e-6f70-8192-a3b4c5d6e7f8",
"secret": "9f2c4e8a1b3d5f7091a2b3c4d5e6f708"
}
It is generated rather than accepted — a caller-chosen secret is routinely a guessable string. Store it now; there is no way to read it back, and no rotate endpoint. To change it, delete the webhook and register a new one.
List webhooks
GET /api/voice/webhooks
{
"webhooks": [
{
"uuid": "wh1a2b3c-…",
"name": "CRM sync",
"url": "https://your-server.example/hooks/nextneural",
"events": ["call.started", "call.ended"],
"is_active": true,
"last_triggered_at": "2026-09-20T14:33:54Z",
"created_at": "2026-09-02T10:11:12Z"
}
]
}
The secret is not included.
Delete a webhook
DELETE /api/voice/webhooks/{uuid} → 204
Delivery logs
GET /api/voice/webhooks/{uuid}/logs
Every attempt is logged whether it succeeded or not — an integration that stopped working is otherwise indistinguishable from one nobody configured.
{
"logs": [
{
"event_type": "call.ended",
"response_status": 200,
"success": true,
"error_message": "",
"created_at": "2026-09-20T14:33:55Z"
},
{
"event_type": "call.ended",
"response_status": 0,
"success": false,
"error_message": "Post \"https://…\": dial tcp: i/o timeout",
"created_at": "2026-09-20T12:04:11Z"
}
]
}
Events
| Event | Fires when |
|---|---|
call.started | A call connects and the pipeline begins |
call.ended | The call finishes — carries the transcript |
call.no_answer | The provider reported no answer, busy, or failed |
call.failed | The call could not be placed |
call.no_answer matters for campaigns: those calls never reach the media
pipeline at all, so this is the only signal they happened.
Payload
{
"event": "call.ended",
"timestamp": "2026-09-20T14:33:54Z",
"data": {
"call_uuid": "cal-c1d2e3f4-…",
"from_number": "+915550010000",
"to_number": "+918888888888",
"duration_seconds": 113,
"transcript": [
{ "speaker": "agent", "text": "Hi, is that Priya?", "ts": "2026-09-20T14:32:04Z" },
{ "speaker": "customer", "text": "Yes, speaking.", "ts": "2026-09-20T14:32:07Z" }
]
}
}
call.no_answer carries the shorter form — call_uuid, from_number,
to_number, outcome — since there is no conversation to report.
Events fire after the call row is written, so a receiver that immediately calls back sees the finished call rather than one still in progress.
Verifying a delivery
Every request carries an HMAC-SHA256 of the raw body, keyed with your secret:
X-Webhook-Signature: sha256=<hex digest>
User-Agent: voice-bot-ai-webhooks/1
Content-Type: application/json
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
Compute it over the raw bytes, before any JSON parsing — re-serializing changes the bytes and the digest with them.
Delivery behaviour
- One attempt per event. There is no retry and no backoff. A receiver that is down misses that event permanently; reconcile from the call list.
- Any status ≥ 300 counts as failure, recorded in the logs with the status.
- Deliveries are sequential across your endpoints, not parallel.
- Only the first 4 KB of your response is read, then discarded.
Return 2xx quickly and do the work asynchronously. A slow receiver delays
delivery to your other endpoints.