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#

EventFires whendata contains
call.startedAn inbound call is answered or an outbound call connects.The call object.
call.endedA call terminates for any reason.The call object, plus duration_seconds and hangup_reason.
session.endedAny session (web, phone, or test) reaches ended.The session object, plus duration_seconds and end_reason.
workflow.completedAn execution reaches an end or hangup node.Execution summary and the final variables (secrets redacted).
workflow.failedAn 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:

HTTPRequest headers
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
JSONRequest body
{
  "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:

TextSignature scheme
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.

JavaScriptExpress — verify X-Vollo-Signature
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);
  });
PHPPHP — verify X-Vollo-Signature
<?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);
PythonPython — verify X-Vollo-Signature
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 "", 200
Use the raw body

Frameworks 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:

Attempt12345678
Delay after failure30 s2 min10 min30 min2 h6 h12 h24 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:

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.