Skip to content

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.

Terminal window
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.
  • events lists 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 get 403 permission_denied, naming the permission that’s missing.
  • campaign_ids limits 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.

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

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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is when we signed it, in unix seconds.
  • v1 is an HMAC-SHA256, in hex, of the text t, a dot, then the raw body, using your endpoint’s whsec_ secret as the key.

To check it:

  1. 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.
  2. Work out HMAC-SHA256 of t + . + raw body with your secret, and compare it to each v1 (a constant-time compare).
  3. Refuse it if t is 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.

verify-signature.js
// 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);
}
receive-express.js
// A webhook receiver in Express that checks the signature, skips repeats and
// answers fast. npm install express
import 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'));

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.

  • 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 answer 200.
  • Events can arrive out of order. Each lead and call carries an updated time: keep the newest. A call.completed sent again after someone corrects the outcome has a higher revision and a newer updated.

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 /events keeps 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}/resend sends one again, with the same event id and a fresh signature.
  • GET /webhook_endpoints/{id}/deliveries shows 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.

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".