Webhook Reference
Voicetta sends conversation events to your backend as signed webhooks.
Use webhooks to keep your product in sync with what is happening across voice, SMS, and WhatsApp.
Each delivery contains one event and the data behind it.
The events
| Event | Subject | Fires |
|---|---|---|
call.completed | One voice call | When post-call processing finishes |
message.received | One inbound SMS or WhatsApp message | 15–30 seconds after arrival |
message.sent | One outbound SMS or WhatsApp message | 15–30 seconds after sending |
thread.completed | One finished SMS or WhatsApp conversation | When the conversation is graded |
Choose the events you want for each webhook endpoint in Settings → Developers.
The envelope
Every event has the same structure:
{
"event": "call.completed",
"event_id": "9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11",
"version": 1,
"occurred_at": "2026-08-20T10:03:00+00:00",
"call": {}
}The event tells you what happened.
The object named after the event contains the data:
callmessagethread
event_id is stable across retries.
call.completed
This event fires after post-call processing is complete.
The transcript, analysis, evaluations, costs, and recording are ready at this point.
{
"event": "call.completed",
"event_id": "9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11",
"version": 1,
"occurred_at": "2026-08-20T10:03:00+00:00",
"call": {
"call_id": "9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11",
"workspace_id": "2a1f8e7d-4c3b-4a29-9f18-6d5e4c3b2a19",
"assistant_id": "7c6b5a49-3e2d-4c1b-8a97-5f4e3d2c1b09",
"assistant_name": "Front Desk",
"call_type": "inbound",
"direction": "inbound",
"status": "ended",
"duration_seconds": 180,
"started_at": "2026-08-20T10:00:00+00:00",
"ended_at": "2026-08-20T10:03:00+00:00",
"end_reason": "caller_hung_up",
"is_test": false,
"customer": {
"customer_id": "5d4c3b2a-1f09-4e8d-7c6b-5a493e2d1c0b",
"name": "Jane Caller",
"phone": "+15551234567",
"email": "[email protected]",
"language": "en",
"country": "US"
},
"transcript": [
{
"role": "assistant",
"content": "Good morning, how can I help?"
},
{
"role": "user",
"content": "I'd like to book a room for Friday."
}
],
"analysis": {
"summary": "Caller asked about availability for Friday and booked a double room.",
"intent": "booking_enquiry",
"outcome": "booked",
"sentiment": "positive"
},
"evaluations": [
{
"evaluation_id": "3e2d1c0b-5a49-4f8e-9d7c-6b5a493e2d1c",
"title": "Confirmed the booking reference",
"result": "pass",
"initial_result": "pass",
"rationale": "The agent read the reference back to the caller.",
"recovered": false
}
],
"recording": {
"status": "complete",
"available": true,
"download_url": "https://api.voicetta.com/v1/calls/9f8b1c4e-6d2a-4b7f-8e21-3c5a7d9e0f11/recording"
}
}
}Reading the payload
event_idis the call id.- Retries keep the same
event_id. - Missing fields mean there was no value.
- Optional blocks can be disabled in your webhook settings.
recording.download_urlpoints to a Voicetta recording endpoint. The endpoint creates a short-lived signed URL when accessed.
Choosing what you receive
You can choose which data blocks each webhook sends.
| Block | Contains | Default | Events |
|---|---|---|---|
call | Record id, workspace, assistant, timestamps | Always | All |
customer | Name, phone, email, language, country | On | All |
costs | Cost breakdown | Off | All |
transcript | Turn-by-turn transcript | On | Calls, threads |
analysis | Summary, intent, outcome, sentiment | On | Calls, threads |
evaluations | Evaluation verdicts and reasoning | On | Calls, threads |
follow_ups | Recovery and follow-up state | Off | Calls, threads |
recording | Recording status and download link | On | Calls |
performance_metrics | Speech, model, and voice latency | Off | Calls |
config_snapshot | Assistant configuration at call time | Off | Calls |
A block that does not apply to an event is omitted.
For example, messages do not contain recordings or call latency.
Only request or store the data your product actually needs.
Headers
Every webhook includes:
| Header | Value |
|---|---|
X-Voicetta-Event | Event name |
X-Voicetta-Delivery-Id | Unique id for this delivery attempt |
X-Voicetta-Payload-Version | 1 |
X-Voicetta-Signature | HMAC signature |
You can also configure custom headers.
Verifying Voicetta requests
Your endpoint is public, so you should verify incoming requests.
You have two options.
Option A: Custom header
Add a secret header in Settings → Developers.
For example:
X-Api-Key: your-secretVoicetta sends the header with every delivery.
This works well with no-code tools such as n8n.
Option B: HMAC signature
Voicetta sends:
X-Voicetta-Signature: t=<unix seconds>,v1=<hex hmac-sha256>Calculate HMAC-SHA256 over:
<timestamp>.<raw request body>using your webhook signing secret.
Always use the raw request body.
Do not parse and re-serialize the JSON before calculating the signature.
Also reject signatures older than a reasonable window, such as five minutes.
Node
const crypto = require('crypto');
app.post(
'/voicetta/calls',
express.raw({ type: 'application/json' }),
(req, res) => {
const header = req.get('X-Voicetta-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map((p) => p.split('='))
);
const timestamp = Number(parts.t);
if (
!Number.isFinite(timestamp) ||
Math.abs(Date.now() / 1000 - timestamp) > 300
) {
return res.sendStatus(400);
}
const expected = crypto
.createHmac(
'sha256',
process.env.VOICETTA_SIGNING_SECRET
)
.update(`${timestamp}.`)
.update(req.body)
.digest('hex');
const provided = Buffer.from(parts.v1 || '', 'utf8');
const computed = Buffer.from(expected, 'utf8');
if (
provided.length !== computed.length ||
!crypto.timingSafeEqual(provided, computed)
) {
return res.sendStatus(401);
}
res.sendStatus(200);
enqueue(JSON.parse(req.body.toString('utf8')));
}
);Python
import hashlib
import hmac
import os
import time
from fastapi import BackgroundTasks, HTTPException, Request
SECRET = os.environ["VOICETTA_SIGNING_SECRET"]
TOLERANCE_SECONDS = 300
@app.post("/voicetta/calls")
async def receive_call(
request: Request,
background: BackgroundTasks
):
body = await request.body()
parts = dict(
part.split("=", 1)
for part in request.headers
.get("X-Voicetta-Signature", "")
.split(",")
)
try:
timestamp = int(parts["t"])
provided = parts["v1"]
except (KeyError, ValueError):
raise HTTPException(
status_code=400,
detail="Malformed signature"
)
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
raise HTTPException(
status_code=400,
detail="Stale signature"
)
expected = hmac.new(
SECRET.encode(),
f"{timestamp}.".encode() + body,
hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected, provided):
raise HTTPException(
status_code=401,
detail="Bad signature"
)
background.add_task(
process_call,
await request.json()
)
return {"received": True}Rotating the signing secret
You can rotate the secret from the dashboard.
The old secret stops working immediately.
Update your backend first if possible, then rotate the secret.
Retries
If your endpoint returns anything outside 2xx, or does not respond in time, Voicetta retries the delivery.
There are up to six attempts.
| Attempt | Delay |
|---|---|
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
After the final attempt, the delivery is marked as failed.
You can retry it manually from the delivery log or fetch the same record through the REST API.
Each retry has a new delivery id but the same event_id.
What your integration should assume
At-least-once delivery
The same event can arrive more than once.
Deduplicate it.
Not real-time
Voice events arrive a few seconds after the call.
Text events usually arrive within 15–30 seconds.
This is suitable for activity feeds and workflows, not for building a live chat transport.
Not ordered
Events can arrive out of order.
Use occurred_at when you need chronological ordering.
Do not rely on arrival order.
Timeout
Webhook timeout can be set between 1 and 10 seconds.
The default is 10 seconds.
Keep it at 10 unless you have a specific reason to change it.
More importantly: respond quickly.
Do your processing after the 200.
Troubleshooting
| Problem | Likely cause |
|---|---|
| Signature does not match | You are hashing parsed JSON instead of the raw body |
| Same event arrives repeatedly | Your response is too slow |
| Deliveries time out | Processing is happening before the response |
| Everything is failed | Endpoint is unreachable, private, or returning non-2xx |
| A block is missing | It is disabled or does not apply to that event |
| A field is missing | The value is not present |
| Event never arrives | It is not enabled for that webhook |
| Old events are missing | Webhook events are not retroactive |