--- title: "Webhook Reference" description: "The call.completed event: full payload, the ten field groups, every header we send, HMAC signature verification in Node and Python, and the retry ladder." publishedAt: "2026-08-22" modifiedAt: "2026-08-22" category: "Reference" tags: "Voicetta, webhook, HMAC, signature, retries, payload, call.completed" navOrder: "30" canonical: "https://voicetta.com/developers/webhooks" --- # 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 ```json { "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": "jane@example.com", "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_id` is the call id.** A retry of the same call carries the same `event_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_url` is 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](/developers/api-reference#get-v1-calls-call-id-recording). ## 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](/developers/api-reference#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=,v1=` | 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: ` 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 `.` using your signing secret, and compare the result to the `v1` value in `X-Voicetta-Signature`. Two details decide whether this works: 1. **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". 2. **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):** ```js 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):** ```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() # 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](/developers/api-reference). 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 `.` 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](/developers/quickstart) — see a real payload before you write code - [Read API reference](/developers/api-reference) — pull the same call objects on demand - [Overview](/developers/overview) — how push and pull fit together - [n8n and no-code](/developers/n8n) — receiving deliveries without a server