Webhook Reference
We POST every completed call to a URL you own. This page is the complete description of that request.
The event
There is one event type: call.completed.
It fires when post-call processing finishes, which is a few seconds after the caller hangs up. Everything in the payload is final at that point — transcript, summary, evaluations, costs, and recording.
Two things it does not cover:
- Only voice calls. SMS and WhatsApp text conversations do not fire this event.
- Never test calls. Calls you make from the dashboard's test panel are excluded, so your production pipeline only ever sees real traffic.
Payload
{
"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"
}
}
}How to read it
event_idis the call id. A retry of the same call carries the sameevent_id, which makes it a usable idempotency key if you would rather not track delivery ids.- Null fields are omitted, not sent as
null. Treat a missing key as "no value", never as an error. - Whole blocks are absent when you switch them off, and also when a call genuinely has no data for them.
recording.download_urlis a Voicetta endpoint, not a storage URL. It does not expire. Requesting it redirects to a short-lived signed URL, so the link stays valid inside a payload you stored months ago. See the read API reference.
Choosing what you receive
Ten blocks, each switchable per endpoint in Settings → Developers. Only the blocks you tick are sent.
| Block | What is in it | Default |
|---|---|---|
call | Call id, workspace, assistant, direction, duration, timestamps, end reason | Always sent |
customer | Caller name, phone, email, language, country | On |
transcript | Turn-by-turn transcript | On |
analysis | Summary, intent, outcome, sentiment | On |
evaluations | Evaluation verdicts and reasoning | On |
recording | Recording status and download link | On |
costs | Cost breakdown and the amount charged | Off |
performance_metrics | Speech, language model, and voice latency | Off |
config_snapshot | Assistant configuration at the time of the call | Off |
follow_ups | Recovery and disconnect follow-up state | Off |
call cannot be switched off. Without identity and timing, the rest of the payload cannot be interpreted.
Switch off anything you do not store. It is less data crossing the network, less to secure on your side, and less to explain in your own privacy documentation. costs in particular is off by default because it exposes what the call cost to run.
The read API uses the same blocks on each API key — see Choosing what you can read. Webhook and key permissions are configured separately.
Headers
| Header | Value |
|---|---|
X-Voicetta-Event | call.completed |
X-Voicetta-Delivery-Id | Unique per delivery attempt. Use this to detect repeats. |
X-Voicetta-Payload-Version | 1 |
X-Voicetta-Signature | t=<unix seconds>,v1=<hex hmac-sha256> |
Plus any custom headers you configure on the endpoint.
Verifying that it is really us
Your endpoint is a public URL, so anything on the internet can post to it. There are two ways to establish that a request came from Voicetta. The signature is always sent, so you can start with headers and add signature verification later without any coordination from us.
Option A: a custom header
Add a header such as X-Api-Key: <your own secret> in Settings → Developers. We attach it to every delivery and you check it on arrival.
This is the right choice for no-code tools: in n8n it maps directly onto the Webhook node's Header Auth credential, with nothing to implement. Header values are encrypted at rest and never shown again after you save them.
Option B: HMAC signature verification
Compute HMAC-SHA256 over <timestamp>.<raw request body> using your signing secret, and compare the result to the v1 value in X-Voicetta-Signature.
Two details decide whether this works:
- Use the raw request body, byte for byte. If your framework parses the JSON and you re-serialise it to hash it, key order and whitespace change and the digest will not match. This is by far the most common cause of "the signature never matches".
- Check that the timestamp is recent. Five minutes is a reasonable window. Without this check, a request captured once can be replayed against you at any point in the future.
Compare with a constant-time function, not ==.
Node (Express):
const crypto = require('crypto');
// express.raw, not express.json — you need the exact bytes.
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);
}
// Acknowledge first, then process.
res.sendStatus(200);
enqueue(JSON.parse(req.body.toString('utf8')));
});Python (FastAPI):
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() # raw bytes, before any JSON parsing
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 at any time in the dashboard. The previous secret stops working immediately — there is no overlap window. So either update your side first if you can tolerate a brief period where old deliveries fail, or rotate during a quiet hour.
Retries and duplicates
Any response outside 2xx, and any timeout, counts as a failure. We retry up to six attempts, doubling the delay each time.
| Attempt | Delay after the previous one |
|---|---|
| 2 | 1 minute |
| 3 | 2 minutes |
| 4 | 4 minutes |
| 5 | 8 minutes |
| 6 | 16 minutes |
After the sixth attempt the delivery is marked failed and we stop trying. Nothing is lost: retry it by hand from the delivery log, or fetch the call from the read API.
Each attempt carries a new X-Voicetta-Delivery-Id and the same event_id. Dedupe on whichever matches your model — delivery id to process each attempt at most once, event_id to treat repeats as updates to the same call.
About the timeout
You can set the timeout between 1 and 10 seconds. The default is 10, and 10 is almost always the right answer.
It is tempting to lower it, on the theory that a tighter timeout means faster delivery. It does not work that way. A successful delivery finishes when your server answers, so shortening the timeout makes nothing faster — it only moves the point at which we give up and retry.
What a tight timeout does do is fire on ordinary conditions: DNS and TLS setup on a cold connection, a large transcript upload, a serverless cold start. Each of those produces a duplicate delivery and a false failure for a call you handled perfectly.
Leave it at 10 and make your endpoint reply immediately. Replying immediately is the thing that actually makes deliveries fast.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| The signature never matches | You are hashing re-serialised JSON instead of the raw body, or omitting the <timestamp>. prefix |
| The same call arrives repeatedly | Your 200 is arriving late or after the timeout. Acknowledge before processing |
| Deliveries time out | Processing inline. Move it after the response |
Everything is failed in the log | Endpoint unreachable from the internet, a non-2xx status, or a private or loopback host, which we reject |
| A block is missing from the payload | It is switched off, or that call has no data for it |
| A field you expected is missing | It was null. Null fields are omitted rather than sent as null |
Related pages
- Quickstart — see a real payload before you write code
- Read API reference — pull the same call objects on demand
- Overview — how push and pull fit together
- n8n and no-code — receiving deliveries without a server