Receive & verify webhooks

Guide: Receive & verify webhooks

This guide shows how to build a secure webhook receiver — verifying signatures, de‑duplicating retries, and responding correctly. For the catalog of events and payloads, see the events reference.

1. Create an endpoint

Register your HTTPS URL and the events you want via the dashboard Webhooks page or the management API. Save the signing_secret from the create response — it's shown only once.

curl -X POST https://api.telenow.ai/api/orgs/{orgId}/webhooks \
  -H "x-api-key: vai_live_…" -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/telenow/webhooks", "events": ["call.ended","recording.ready"], "includeTranscript": true }'

If an automation platform (Zapier, n8n, Make) is managing the subscription, it uses the REST-hooks API (/api/v1/hooks) instead — those deliveries are still signed, but the platform handles verification.

2. Verify the signature

Every delivery includes X-VoiceAI-Signature: sha256=<hex>, the HMAC‑SHA256 of the raw request body keyed with your signing secret. Always verify against the raw bytes — not a re‑serialized object.

Node (Express)

import express from 'express';
import crypto from 'crypto';

const app = express();
const SECRET = process.env.TELENOW_WEBHOOK_SECRET;

// Capture the raw body for signature verification.
app.use('/telenow/webhooks', express.raw({ type: 'application/json' }));

app.post('/telenow/webhooks', (req, res) => {
  const sig = req.get('X-VoiceAI-Signature') || '';
  const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');

  const ok =
    sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  const event = JSON.parse(req.body.toString());
  const deliveryId = req.get('X-VoiceAI-Delivery');

  if (alreadyProcessed(deliveryId)) return res.status(200).end(); // de-dupe retries
  handle(event);                                                  // your logic
  markProcessed(deliveryId);

  res.status(200).end(); // ack quickly
});

Python (Flask)

import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["TELENOW_WEBHOOK_SECRET"].encode()

@app.post("/telenow/webhooks")
def webhooks():
    body = request.get_data()  # raw bytes
    expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
    sig = request.headers.get("X-VoiceAI-Signature", "")
    if not hmac.compare_digest(sig, expected):
        abort(401)

    delivery_id = request.headers.get("X-VoiceAI-Delivery")
    if already_processed(delivery_id):
        return "", 200
    handle(request.get_json())
    mark_processed(delivery_id)
    return "", 200

3. De-duplicate with the delivery id

Retries reuse the same X-VoiceAI-Delivery id. Record processed ids (in Redis/DB with a TTL) and skip duplicates so a retried call.ended doesn't double‑post to your CRM.

4. Respond fast, work async

Acknowledge with a 2xx immediately, then do heavy work (CRM writes, downloading recordings) in a background job. Slow responses look like failures and get retried.

5. Understand retries

Failed deliveries (non‑2xx, timeout, connection error) retry with exponential backoff (5s × 2^attempt, capped at 1 hour) for up to 8 attempts; after repeated permanent failures the endpoint is disabled. Inspect attempts at GET /api/orgs/{orgId}/webhooks/{id}/deliveries.

6. Handle the events you care about

function handle(event) {
  switch (event.event) {
    case 'call.started':
      // event.from, event.variables, event.startTime
      break;
    case 'call.ended':
      // event.durationSecs, event.transcript (if enabled), event.recording (if enabled)
      break;
    case 'recording.ready':
      // download event.recording.url before event.recording.expiresAt
      break;
    case 'tool.invoked':
      // event.name, event.arguments, event.result, event.status
      break;
    case 'call.analyzed':
      // event.analysis.summary, .sentiment, .disposition, .actionItems
      break;
  }
}

Recording URLs are signed and short‑lived (~1 hour) — download promptly inside your handler rather than storing the URL for later.

See the events reference for every payload.