Guides
Webhooks
Vollo pushes lifecycle events to your HTTPS endpoints as signed JSON POSTs — the reliable way to react to calls ending, sessions closing, and workflows completing.
Configuring endpoints#
Add endpoints per project in the dashboard under Project → Webhooks: a URL, the event types to receive, and an automatically generated signing secret (whsec_…). The secret is shown once — store it next to your API key. You can pause an endpoint without deleting it, and each endpoint has a delivery log with request/response details for debugging.
Event types#
| Event | Fires when | data contains |
|---|---|---|
call.started | An inbound call is answered or an outbound call connects. | The call object. |
call.ended | A call terminates for any reason. | The call object, plus duration_seconds and hangup_reason. |
session.ended | Any session (web, phone, or test) reaches ended. | The session object, plus duration_seconds and end_reason. |
workflow.completed | An execution reaches an end or hangup node. | Execution summary and the final variables (secrets redacted). |
workflow.failed | An execution aborts — unhandled node error or max duration exceeded. | Execution summary, error, and the last node reached. |
Delivery format#
Every delivery is a POST with a common envelope:
POST /vollo/webhooks HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: Vollo-Webhooks/1.0
X-Vollo-Event: call.ended
X-Vollo-Delivery: 8d2f5a1c-9b7e-4c3a-8f6d-2e5b9a1c7d4f
X-Vollo-Timestamp: 1786957964
X-Vollo-Signature: 42d1e59c40b9a06cf59a2a4f8b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c{
"id": "8d2f5a1c-9b7e-4c3a-8f6d-2e5b9a1c7d4f",
"type": "call.ended",
"created_at": "2026-08-18T09:15:44Z",
"project_id": "9b2e7c1a-4d3f-4e8a-9c5b-2f7d1e6a8b3c",
"data": {
"id": "c4d9e2f7-1b8a-4e3c-9f6d-7a2b5c8e1d4a",
"direction": "outbound",
"status": "completed",
"from": "+12125550142",
"to": "+962790123456",
"duration_seconds": 63,
"hangup_reason": "callee_hangup",
"metadata": { "crm_id": "A-1042" }
}
}X-Vollo-Delivery is unique per delivery attempt; id is stable per event — use it for idempotency, since retries redeliver the same event.
Verifying signatures#
X-Vollo-Signature is a lowercase hex HMAC-SHA256 over the string {timestamp}.{raw_body}, keyed with the endpoint's whsec_… secret:
signature = hex( HMAC_SHA256( secret, X-Vollo-Timestamp + "." + raw_request_body ) )Verify every delivery: compute the same HMAC from the raw body bytes (before any JSON parsing), compare in constant time, and reject timestamps older than a few minutes to block replays.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRET = process.env.VOLLO_WEBHOOK_SECRET; // whsec_…
app.post('/vollo/webhooks',
express.raw({ type: 'application/json' }), // keep the raw body!
(req, res) => {
const ts = req.header('X-Vollo-Timestamp');
const sig = req.header('X-Vollo-Signature') ?? '';
const expected = crypto.createHmac('sha256', SECRET)
.update(`${ts}.${req.body}`)
.digest('hex');
const fresh = Math.abs(Date.now() / 1000 - Number(ts)) < 300;
const valid = sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!fresh || !valid) return res.status(400).send('invalid signature');
const event = JSON.parse(req.body);
// …handle event.type, then respond fast:
res.sendStatus(200);
});<?php
$secret = getenv('VOLLO_WEBHOOK_SECRET'); // whsec_…
$raw = file_get_contents('php://input');
$ts = $_SERVER['HTTP_X_VOLLO_TIMESTAMP'] ?? '';
$sig = $_SERVER['HTTP_X_VOLLO_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $ts . '.' . $raw, $secret);
$fresh = abs(time() - (int) $ts) < 300;
if (!$fresh || !hash_equals($expected, $sig)) {
http_response_code(400);
exit('invalid signature');
}
$event = json_decode($raw, true);
// …handle $event['type'], then respond fast:
http_response_code(200);import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["VOLLO_WEBHOOK_SECRET"].encode() # whsec_…
@app.post("/vollo/webhooks")
def vollo_webhook():
ts = request.headers.get("X-Vollo-Timestamp", "")
sig = request.headers.get("X-Vollo-Signature", "")
expected = hmac.new(SECRET, f"{ts}.".encode() + request.get_data(),
hashlib.sha256).hexdigest()
fresh = abs(time.time() - float(ts or 0)) < 300
if not fresh or not hmac.compare_digest(expected, sig):
abort(400)
event = request.get_json()
# …handle event["type"], then respond fast
return "", 200Frameworks that parse and re-serialize JSON will change whitespace and key order, breaking the HMAC. Always compute the signature over the exact bytes received.
Delivery, retries, and backoff#
A delivery counts as successful when your endpoint returns any 2xx within 10 seconds. Anything else — 3xx/4xx/5xx, timeout, connection failure — is retried with exponential backoff and jitter:
| Attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Delay after failure | 30 s | 2 min | 10 min | 30 min | 2 h | 6 h | 12 h | 24 h |
After the eighth failed attempt the delivery is marked dead (inspect and redeliver manually from the endpoint's delivery log). An endpoint that fails every delivery for 7 consecutive days is automatically paused and the workspace owner is emailed.
Practical guidance:
- Acknowledge fast, process async. Enqueue the event and return 200; the 10-second budget includes your processing time.
- Be idempotent. Retries and rare duplicate deliveries mean you may see the same event
idtwice. - Don't rely on ordering. Events are usually delivered in order but only
created_atis authoritative —call.endedcan arrive before a retriedcall.started.
Mid-call webhooks from workflows#
These platform events are separate from the Webhook node, which lets a running workflow POST to your systems during a call (e.g. “create a ticket now”). The node supports the same HMAC scheme when you set its auth to hmac with a secret variable.