Webhooks and signatures
A webhook is an address on your server where we send events as they happen: a lead was queued, a call was answered, the lead is interested, the call is over. You don’t have to keep asking.
Set one up
Section titled “Set one up”curl -X POST 'https://v2-api.callview.ai/api/v1/external/webhook_endpoints' \ -u "$CALLVIEW_KEY_ID:$CALLVIEW_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "url": "https://crm.example.com/callview", "events": ["call.completed", "lead.interested", "lead.opted_out"] }'- The address must be
https://and on the public internet. We don’t follow redirects, so give the final address. eventslists what you want. Events has them all.- The key needs Webhooks: write, and it can only ask for events whose data it may read anyway. Lead events (
lead.created,lead.queued,lead.opted_out,lead.late,callback.booked) need Leads: read. Call events (call.started,call.answered,call.completed,lead.interested,handover.accepted,voicemail.left) need Calls: read. Without it you get403 permission_denied, naming the permission that’s missing. campaign_idslimits it to some campaigns. Leave it out for all of them.- The endpoint takes the key’s mode. One made with a test key only ever gets test events, and one made with a live key only gets live ones.
- The reply has a
secret(whsec_...). It’s shown this once. You need it to check our signature. - You can have up to 10 endpoints.
What we send
Section titled “What we send”A POST with a JSON body, and these headers:
| Header | What it is |
|---|---|
CallView-Signature |
The signature. See below. |
CallView-Event-Id |
The event’s id (evt_...). |
CallView-Event-Type |
For example call.completed. |
CallView-Delivery-Id |
This delivery. A re-send gets a new one. |
CallView-Delivery-Attempt |
1 the first time, then 2, 3 and so on. |
User-Agent |
CallView-Webhooks/1.0 |
The body is the event:
{ "id": "evt_callcompletedxxxxxxxxxxx", "type": "call.completed", "created": "2026-10-06T17:05:20Z", "livemode": true, "api_version": "2026-10-01", "data": { "object": { "id": "e4d3c2b1-a09f-4e8d-9c7b-6a5f4e3d2c1b", "outcome": "interested", "note": "Wants a quote for a 6 kW system...", "...": "..." } }}Answer fast
Section titled “Answer fast”Answer with any 2xx status within 10 seconds. Anything else, or no answer in time, counts as a failure and we try again later. If your work takes longer (writing to your CRM, say), save the event, answer 200, then do the work.
Check the signature
Section titled “Check the signature”Anyone can send a POST to your address. The signature proves an event came from us and wasn’t changed on the way.
The CallView-Signature header looks like this:
t=1791306131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis when we signed it, in unix seconds.v1is an HMAC-SHA256, in hex, of the textt, a dot, then the raw body, using your endpoint’swhsec_secret as the key.
To check it:
- Take the raw body, byte for byte as it arrived. If your framework parsed the JSON for you and you turn it back into text, the bytes can change and the check fails.
- Work out HMAC-SHA256 of
t+.+ raw body with your secret, and compare it to eachv1(a constant-time compare). - Refuse it if
tis more than 5 minutes away from your clock. That stops anyone replaying an old event at you.
The code for Node, Python and PHP is below. Our test suite runs these exact files against our real signer on every build, so you can copy them as they are.
// Check that a webhook really came from CallView (Node 18 or later, no packages).//// Every delivery has a CallView-Signature header like:// t=1791306131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd// t is when we sent it (unix seconds). v1 is HMAC-SHA256 of "<t>.<raw body>",// keyed with your endpoint's secret (whsec_...), in hex. While you roll the// secret there are two v1 values: one matching is enough.//// Use the RAW body, byte for byte as it arrived. JSON you parsed and turned// back into text won't match.//// This is an ES module. In a CommonJS project, change the first line to:// const crypto = require('node:crypto');import crypto from 'node:crypto';
export function verifyCallViewSignature(rawBody, header, secret, toleranceSeconds = 300) { let timestamp = null; const signatures = []; for (const part of String(header || '').split(',')) { const i = part.indexOf('='); if (i === -1) continue; const key = part.slice(0, i).trim(); const value = part.slice(i + 1).trim(); if (key === 't' && /^\d+$/.test(value)) timestamp = Number(value); if (key === 'v1' && /^[0-9a-f]{64}$/i.test(value)) signatures.push(value.toLowerCase()); } if (timestamp === null || signatures.length === 0 || !secret) return false;
// Older than 5 minutes (or from the future): refuse it, so nobody can replay an old one. if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody), 'utf8'); const expected = crypto .createHmac('sha256', secret) .update(Buffer.concat([Buffer.from(`${timestamp}.`, 'utf8'), body])) .digest(); return signatures.some((sig) => { const got = Buffer.from(sig, 'hex'); return got.length === expected.length && crypto.timingSafeEqual(got, expected); });}
// Try it from the command line with a delivery you saved:// node verify-signature.js whsec_... 't=...,v1=...' body.json// It prints "valid" or "invalid".import { readFileSync } from 'node:fs';import { pathToFileURL } from 'node:url';
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const [secret, header, bodyFile] = process.argv.slice(2); const ok = verifyCallViewSignature(readFileSync(bodyFile), header, secret); console.log(ok ? 'valid' : 'invalid'); process.exit(ok ? 0 : 1);}# Check that a webhook really came from CallView (Python 3.8 or later, no packages).## Every delivery has a CallView-Signature header like:# t=1791306131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd# t is when we sent it (unix seconds). v1 is HMAC-SHA256 of "<t>.<raw body>",# keyed with your endpoint's secret (whsec_...), in hex. While you roll the# secret there are two v1 values: one matching is enough.## Use the RAW body, byte for byte as it arrived (request.get_data() in Flask,# request.body in Django). JSON you parsed and turned back into text won't match.import hashlibimport hmacimport time
HEX = set("0123456789abcdefABCDEF")
def verify_callview_signature(raw_body, header, secret, tolerance_seconds=300): timestamp = None signatures = [] for part in (header or "").split(","): key, _, value = part.strip().partition("=") value = value.strip() if key == "t" and value.isdigit(): timestamp = int(value) elif key == "v1" and len(value) == 64 and set(value) <= HEX: signatures.append(value.lower()) if timestamp is None or not signatures or not secret: return False
# Older than 5 minutes (or from the future): refuse it, so nobody can replay an old one. if abs(int(time.time()) - timestamp) > tolerance_seconds: return False
if isinstance(raw_body, str): raw_body = raw_body.encode("utf-8") expected = hmac.new( secret.encode("utf-8"), str(timestamp).encode("utf-8") + b"." + raw_body, hashlib.sha256, ).hexdigest() return any(hmac.compare_digest(expected, sig) for sig in signatures)
# Try it from the command line with a delivery you saved:# python verify_signature.py whsec_... 't=...,v1=...' body.json# It prints "valid" or "invalid".if __name__ == "__main__": import sys
secret, header, body_file = sys.argv[1:4] with open(body_file, "rb") as f: ok = verify_callview_signature(f.read(), header, secret) print("valid" if ok else "invalid") sys.exit(0 if ok else 1)<?php// Check that a webhook really came from CallView (PHP 7.4 or later, no packages).//// Every delivery has a CallView-Signature header like:// t=1791306131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd// t is when we sent it (unix seconds). v1 is HMAC-SHA256 of "<t>.<raw body>",// keyed with your endpoint's secret (whsec_...), in hex. While you roll the// secret there are two v1 values: one matching is enough.//// Use the RAW body, byte for byte as it arrived: file_get_contents('php://input').// JSON you decoded and encoded again won't match.
function verify_callview_signature(string $rawBody, string $header, string $secret, int $toleranceSeconds = 300): bool{ $timestamp = null; $signatures = []; foreach (explode(',', $header) as $part) { $pair = explode('=', trim($part), 2); if (count($pair) !== 2) { continue; } [$key, $value] = [trim($pair[0]), trim($pair[1])]; if ($key === 't' && ctype_digit($value)) { $timestamp = (int) $value; } elseif ($key === 'v1' && preg_match('/^[0-9a-f]{64}$/i', $value)) { $signatures[] = strtolower($value); } } if ($timestamp === null || count($signatures) === 0 || $secret === '') { return false; }
// Older than 5 minutes (or from the future): refuse it, so nobody can replay an old one. if (abs(time() - $timestamp) > $toleranceSeconds) { return false; }
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret); foreach ($signatures as $signature) { if (hash_equals($expected, $signature)) { return true; } } return false;}
// In your webhook handler:// $raw = file_get_contents('php://input');// $header = $_SERVER['HTTP_CALLVIEW_SIGNATURE'] ?? '';// if (!verify_callview_signature($raw, $header, getenv('CALLVIEW_WEBHOOK_SECRET'))) {// http_response_code(400);// exit;// }
// Try it from the command line with a delivery you saved:// php verify-signature.php whsec_... 't=...,v1=...' body.json// It prints "valid" or "invalid".if (PHP_SAPI === 'cli' && isset($argv[0]) && realpath($argv[0]) === __FILE__) { [, $secret, $header, $bodyFile] = $argv; $ok = verify_callview_signature(file_get_contents($bodyFile), $header, $secret); echo $ok ? "valid\n" : "invalid\n"; exit($ok ? 0 : 1);}A whole receiver
Section titled “A whole receiver”// A webhook receiver in Express that checks the signature, skips repeats and// answers fast. npm install expressimport express from 'express';import { verifyCallViewSignature } from './verify-signature.js';
const app = express();const seen = new Set(); // use your database in real life: event ids you have handled
// express.raw keeps the body as bytes. Don't put express.json() in front of// this route: the signature is over the raw bytes.app.post('/callview', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyCallViewSignature(req.body, req.get('CallView-Signature'), process.env.CALLVIEW_WEBHOOK_SECRET); if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8')); if (seen.has(event.id)) return res.sendStatus(200); // already handled: still say OK seen.add(event.id);
// Answer within 10 seconds. Do slow work (CRM updates) after answering. res.sendStatus(200);
if (event.type === 'call.completed') { const call = event.data.object; console.log(`${call.lead?.external_id}: ${call.outcome} - ${call.note ?? ''}`); }});
app.listen(3000, () => console.log('Listening on http://localhost:3000/callview'));# A webhook receiver in Flask that checks the signature, skips repeats and# answers fast. pip install flaskimport jsonimport os
from flask import Flask, request
from verify_signature import verify_callview_signature
app = Flask(__name__)seen = set() # use your database in real life: event ids you have handled
@app.post("/callview")def callview(): raw = request.get_data() # the raw bytes: the signature is over these header = request.headers.get("CallView-Signature", "") if not verify_callview_signature(raw, header, os.environ["CALLVIEW_WEBHOOK_SECRET"]): return "bad signature", 400
event = json.loads(raw) if event["id"] in seen: return "", 200 # already handled: still say OK seen.add(event["id"])
# Answer within 10 seconds. Hand slow work (CRM updates) to a queue. if event["type"] == "call.completed": call = event["data"]["object"] lead = call.get("lead") or {} print(f'{lead.get("external_id")}: {call["outcome"]} - {call.get("note") or ""}') return "", 200
if __name__ == "__main__": app.run(port=3000)<?php// A webhook receiver in plain PHP that checks the signature and answers fast.require __DIR__ . '/verify-signature.php';
$raw = file_get_contents('php://input'); // the raw bytes: the signature is over these$header = $_SERVER['HTTP_CALLVIEW_SIGNATURE'] ?? '';if (!verify_callview_signature($raw, $header, getenv('CALLVIEW_WEBHOOK_SECRET') ?: '')) { http_response_code(400); echo 'bad signature'; exit;}
$event = json_decode($raw, true);// Skip an event id you have already handled (keep the ids in your database),// but still answer 200 so we stop sending it.
if ($event['type'] === 'call.completed') { $call = $event['data']['object']; error_log(($call['lead']['external_id'] ?? '?') . ': ' . $call['outcome'] . ' - ' . ($call['note'] ?? ''));}
// Answer within 10 seconds.http_response_code(200);Changing the secret
Section titled “Changing the secret”POST /webhook_endpoints/{id}/roll_secret gives you a new secret. For a while (grace_hours, 24 unless you say otherwise, at most 168) we sign with both, so the header has two v1 values. Your check passes if either one matches, so switch over at your own pace.
Repeats and order
Section titled “Repeats and order”- The same event can come twice. A timeout on your side, a network blip or a re-send can all do it. Keep the event
ids you’ve handled and skip one you’ve seen, but still answer200. - Events can arrive out of order. Each lead and call carries an
updatedtime: keep the newest. Acall.completedsent again after someone corrects the outcome has a higherrevisionand a newerupdated.
When your server is down
Section titled “When your server is down”If a delivery fails, we try the same event again, waiting longer each time. Every time below is counted from the first try:
| Try | When |
|---|---|
| 1 | At once |
| 2 | 1 minute later |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
That’s 7 tries. Nothing is tried 24 hours or more after the first, and then the delivery is marked gave_up. The event isn’t lost:
GET /eventskeeps every event for 30 days. After an outage, list what you missed:GET /events?created[gte]=2026-10-06T00:00:00Z&type=call.completed.POST /webhook_endpoints/{id}/deliveries/{delivery_id}/resendsends one again, with the same eventidand a fresh signature.GET /webhook_endpoints/{id}/deliveriesshows each delivery: its status, its tries, and the first 4 KB of your answer.
We send at most 5 deliveries at a time to one endpoint, and at most 10 a second. Anything more waits its turn. Nothing is dropped.
Paused endpoints
Section titled “Paused endpoints”If an endpoint fails for 24 hours with no success in between, we pause it and email your organization’s admins. While it’s paused, events are still saved but not sent. Fix your server, then call POST /webhook_endpoints/{id}/resume with "resend_skipped": true to get the skipped ones (up to 1,000). You can also pause an endpoint yourself with PATCH and "status": "paused".