Webhook Events
Agency plan requiredWebhooks let your server receive real-time notifications when call events occur. Configure a webhook URL and BuildVoiceAI will send POST requests to your endpoint whenever a call starts or ends.
Configuration
Setting Up a Webhook URL
- Navigate to Settings → API & Webhooks (agency-level) or Client Detail → API & Webhooks (client-level)
- Enter your webhook endpoint URL in the Webhook URL field
- Click Save
A webhook signing secret is automatically generated when you save. Use this secret to verify incoming webhook signatures.
Your endpoint must:
- Be publicly accessible (not behind a firewall or VPN)
- Accept POST requests with a JSON body
- Return a
2xxstatus code within 10 seconds - Use HTTPS (HTTP endpoints are rejected)
Client-Level Overrides
Client-level webhook URLs override the agency default. When a client has its own webhook URL configured, call events for that client’s agents are sent to the client’s URL instead of the agency’s.
Testing Your Webhook
Use the Send Test Webhook button in Settings to verify your integration before any real calls:
- Configure your webhook URL and save
- Click Send Test Webhook
- A sample
call_endedpayload is sent with"test": true - Check the result displayed in the dashboard and verify your endpoint received the payload
The test payload mirrors a real call_ended event with all the same fields. Use the "test": true flag to filter test events in your automation.
Event Types
BuildVoiceAI sends the following event types:
| Event | Fired When | Providers |
|---|---|---|
call_started | A call begins and the connection is established | Retell, Vapi |
call_ended | A call finishes (completed, failed, or no-answer) | Retell, Vapi, Bland, ElevenLabs |
test | Manual test from the Send Test Webhook button | All |
Bland AI and ElevenLabs only fire webhooks on call completion. There is no call_started event for Bland or ElevenLabs calls.
Payload Format
All webhook payloads are sent as JSON via POST request. The User-Agent header is set to Prosody-Webhook/1.0.
your-webhook-urlBuildVoiceAI sends this payload to your configured endpoint.
call_ended
{
"event": "call_ended",
"call_id": "f9e8d7c6-b5a4-3210-fedc-ba9876543210",
"agent_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_name": "Sales Qualifier",
"status": "completed",
"direction": "outbound",
"duration_seconds": 185,
"cost_cents": 28,
"from_number": "+15551234567",
"to_number": "+15559876543",
"transcript": "Agent: Hello, how can I help you today?\nUser: I was wondering about your pricing.",
"recording_url": "https://storage.example.com/recordings/abc123.mp3",
"summary": "Caller expressed interest in the enterprise plan and requested a follow-up demo.",
"sentiment": "positive",
"started_at": "2026-03-16T14:30:00.000Z",
"ended_at": "2026-03-16T14:33:05.000Z",
"metadata": {
"source": "hubspot",
"deal_id": "deal_12345"
},
"provider": "retell"
}call_started
{
"event": "call_started",
"call_id": "f9e8d7c6-b5a4-3210-fedc-ba9876543210",
"agent_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"agent_name": "Sales Qualifier",
"status": "in-progress",
"direction": "inbound",
"from_number": "+15551234567",
"to_number": "+15559876543",
"started_at": "2026-03-16T14:30:00.000Z",
"metadata": {},
"provider": "vapi"
}Field Reference
| Field | Type | Present In | Description |
|---|---|---|---|
event | string | Both | call_started, call_ended, or test |
call_id | string | Both | Unique call identifier |
agent_id | string | Both | BuildVoiceAI agent UUID |
agent_name | string | Both | Human-readable agent name |
status | string | Both | in-progress, completed, failed, no-answer |
direction | string | Both | inbound or outbound |
from_number | string | Both | Caller phone number (E.164 format) |
to_number | string | Both | Recipient phone number (E.164 format) |
started_at | string | Both | ISO 8601 timestamp |
duration_seconds | number | call_ended | Call length in seconds |
cost_cents | number | call_ended | Call cost in cents |
transcript | string | call_ended | Full conversation transcript |
recording_url | string|null | call_ended | Link to call recording |
summary | string | call_ended | AI-generated call summary |
sentiment | string | call_ended | positive, neutral, or negative |
ended_at | string | call_ended | ISO 8601 timestamp |
metadata | object | Both | Custom metadata attached to the call |
provider | string | Both | retell, vapi, bland, elevenlabs, or test |
test | boolean | Test only | true on test webhooks |
Signature Verification
Every webhook request includes HMAC-SHA256 signing headers when a signing secret is configured:
| Header | Description |
|---|---|
X-Prosody-Signature | HMAC-SHA256 hex digest |
X-Prosody-Timestamp | Unix timestamp in seconds when the request was signed |
The signature is computed over the concatenation of the timestamp and the raw request body:
HMAC-SHA256(signing_secret, "${timestamp}.${body}")Verification Example (Node.js)
const crypto = require('crypto');
function verifyWebhookSignature(rawBody, signature, timestamp, signingSecret) {
// Reject old payloads to prevent replay attacks
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) throw new Error('Webhook payload too old (>5 minutes)');
const expected = crypto
.createHmac('sha256', signingSecret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// Usage in an Express handler:
app.post('/webhook', (req, res) => {
const rawBody = req.body; // must be raw string, not parsed JSON
const signature = req.headers['x-prosody-signature'];
const timestamp = req.headers['x-prosody-timestamp'];
if (!verifyWebhookSignature(rawBody, signature, timestamp, SIGNING_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Process the webhook...
res.status(200).send('OK');
});Verification Example (Python)
import hmac
import hashlib
import time
def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
# Reject old payloads
age = int(time.time()) - int(timestamp)
if age > 300:
raise ValueError("Webhook payload too old")
expected = hmac.new(
secret.encode(),
f"{timestamp}.{raw_body.decode()}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)Always use a timing-safe comparison function (timingSafeEqual in Node.js, hmac.compare_digest in Python) to prevent timing attacks. Do not use === or == for signature comparison.
The signing secret is separate from your API key. Find it in Settings → API & Webhooks — click the eye icon to reveal it.
Retry Policy
If your webhook endpoint returns a non-2xx status code or does not respond within 10 seconds, BuildVoiceAI retries with exponential backoff:
| Attempt | Delay | Condition |
|---|---|---|
| 1st attempt | Immediate | Always |
| 2nd attempt | 1 second after failure | 5xx, 429, or network error |
| 3rd attempt | 2 seconds after 2nd failure | 5xx, 429, or network error |
Not retried: 4xx errors (except 429) indicate a permanent problem (bad URL, authentication failure) and fail immediately.
After 3 failed attempts, the delivery is marked as failed. All attempts are logged in the Delivery Log.
Delivery Log
Every webhook delivery attempt — successful or failed — is logged and visible in Settings → API & Webhooks → Recent Deliveries.
Each log entry includes:
| Field | Description |
|---|---|
| Event | The event type (call_started, call_ended, test) |
| Timestamp | When the delivery was attempted |
| Status Code | HTTP status code returned by your endpoint |
| Success | Whether the delivery succeeded |
| Error | Error message if the delivery failed |
| Attempt | Which attempt number (1, 2, or 3) |
Use the delivery log to:
- Debug failed webhook integrations
- Verify test webhooks were received
- Monitor overall delivery health
- Identify intermittent endpoint issues
Return a 200 response immediately and process the webhook payload asynchronously. This prevents timeouts if your processing logic takes time.
Security
BuildVoiceAI enforces several security measures on outbound webhooks:
- HTTPS only — HTTP URLs are rejected
- SSRF protection — Requests to localhost, private IPs (10.x, 192.168.x, 172.16-31.x), and link-local addresses are blocked
- HMAC-SHA256 signing — Every webhook is signed when a signing secret is configured
- Timing-safe verification — Signature comparison uses constant-time algorithms
- User-Agent identification — All requests include
Prosody-Webhook/1.0
Best Practices
- Respond quickly — Return
200within 10 seconds to avoid retries - Process asynchronously — Queue webhook payloads for background processing
- Verify signatures — Always validate the
X-Prosody-Signatureheader in production - Handle duplicates — Use
call_id+eventas a dedup key in case of retries - Log payloads — Store raw webhook payloads for debugging
- Use the test button — Verify your integration with a test webhook before relying on real calls