Webhooks
A webhook is an HTTPS request we make to a URL you choose, the moment something happens on a call. It is how you get calls into a CRM, a spreadsheet, or anything that can receive a POST.
This is not the same thing as an alert. An alert tells a person — by email, or in a Slack channel — and is described under Alerts and notifications. A webhook tells a system. They are next to each other in the menu and configured entirely separately.
Setting one up
Webhooks — directly below Notifications in the menu — lists the endpoints you have. Create new webhook opens the form: the URL, which events you want, and whether the caller's phone number is included. Save it and the signing secret is shown.
The secret is shown once. We cannot show it to you again — that is deliberate, and it means nobody who gets into your account later can read it either. It is shown in a panel you have to acknowledge rather than a notification that can be dismissed, and you are not moved off the page until you say you have stored it. If you lose it, rotate it.
Changing one
Edit on any endpoint opens the same form, filled in: the URL, the description, which events it wants, whether the caller's number is included, and whether it is delivering at all. Unlike an alert rule, an endpoint can be read back in full — everything except the secret, which is why the form can pre-fill honestly and the only thing you cannot see is the one thing we cannot show you.
Rotate secret and Delete are on that page. Rotating breaks verification immediately: there is no overlap window, because we are the sender. The next delivery is signed with the new secret and a receiver still holding the old one will reject it, so update your receiver straight away.
Verifying the signature
Every request carries a proofbell-signature header:
proofbell-signature: t=1772280000,v1=8f2c… t is a Unix timestamp and v1 is an HMAC-SHA256 of
t + "." + raw_body, keyed with your signing secret. To verify:
- Take the raw request body, before any JSON parsing.
- Join the timestamp and the body with a full stop.
- HMAC-SHA256 it with your secret and compare, using a constant-time comparison.
-
Reject anything where
tis more than five minutes from your own clock.
Use the raw body, not a re-serialised object. This is the mistake that costs
people an afternoon. Most frameworks parse JSON before your code runs;
JSON.stringify on the parsed object may produce different bytes, and the signature
will not match. Every framework has a way to reach the raw body — in Express it is
express.raw(), in Laravel $request->getContent().
Why the timestamp matters
It is inside the signature, not just beside it. Without it, anyone who ever captured one of our requests could resend it forever and it would verify — which for a lead notification means duplicate enquiries in your CRM whenever they felt like it. Rejecting old timestamps is what closes that, so please do the fourth step.
Node example
const crypto = require("crypto");
function verify(rawBody, header, secret) {
const parts = new Map(header.split(",").map((p) => {
const i = p.indexOf("=");
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
}));
const timestamp = parts.get("t");
const provided = parts.get("v1");
if (!timestamp || !provided) return false;
// Reject anything older than five minutes, in either direction.
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (Math.abs(age) > 300) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
if (expected.length !== provided.length) return false;
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided));
} Retries, and why you need to handle duplicates
If your endpoint does not answer with a 2xx, we try again — up to eight times over
roughly two days, with increasing gaps. That means you will occasionally receive the same
event twice: your server may process a request and then fail to answer, and we cannot
tell the difference.
Every request carries a proofbell-delivery-id header, repeated as id in
the body. Store it and ignore anything you have already seen. It stays the same across retries —
that is what it is for.
| You answer | What we do |
|---|---|
2xx | Done. |
408, 429, any 5xx | Retry with backoff. |
400, 401, 403, 404, 422 | Stop immediately. These say the request will never be accepted, and retrying for two days would not help. You will see it in the failed list. |
3xx | Treated as a failure. We do not follow redirects — a redirect could send your call data somewhere neither of us checked. Update the URL instead. |
Answer quickly and do the work afterwards. We give up on a request after ten seconds, so an endpoint that writes to a slow CRM before responding will time out and be retried.
After 20 failures in a row we switch the endpoint off and tell you why. It stays off until you clear it — a URL that has been dead for a fortnight should not keep generating attempts. Successes reset the count, so a bad afternoon costs you nothing.
What is in the payload
{
"id": "whd_01K…",
"type": "call.completed",
"occurredAt": "2026-03-01T09:17:00.000Z",
"project": { "id": "proj_…", "name": "Main Site" },
"call": {
"id": "call_…",
"startedAt": "2026-03-01T09:15:00.000Z",
"answered": true,
"talkTimeSeconds": 95,
"billable": true,
"firstTimeCaller": true,
"trackedNumber": "+441242987654"
},
"attribution": {
"channel": "Paid Search",
"source": "google",
"medium": "cpc",
"campaign": "Brand",
"keyword": "emergency plumber",
"degraded": false
},
"value": { "amountMinor": "390300", "currency": "GBP" }
} Three things to note
- Money is a string of minor units.
"390300"with"GBP"is £3,903.00. A string because a large amount loses precision as a JSON number in most languages, silently. -
degraded: truemeans the attribution is a best guess. Every tracking number was in use when that visitor arrived, so they saw a shared one. The call is real; the campaign beside it is less certain. Worth storing, so a report built on this is not confidently wrong. - The caller's number is off by default. Switch it on per endpoint if you need it. Most integrations do not, and we would rather it not travel to a destination that does not need it.
Recordings and transcripts are never sent
Not as a URL, not as text, not on request. A recording is a member of the public's voice and a transcript is what they said; neither belongs in a payload going to an automation tool nobody has assessed. If you need them, fetch them from the API, where the access is permissioned and recorded against a named person.
URLs we will not accept
HTTPS only, and nothing that points inside a private network — no localhost, no
10.x, no .internal hostnames. Also no username and password in the URL:
we would end up storing your password. Use a header or a query token instead.
That check runs again at the moment each delivery is sent, and on the address the name actually resolves to rather than on the name itself. So an endpoint whose hostname resolves to a private address — a tunnel, or an internal DNS name that also exists publicly — is refused at send time even though the URL looked fine when you saved it. The failed delivery names the address it resolved to, so it is clear what happened.
When something has not arrived
The failed list on Webhooks shows every delivery that ran out of attempts or was rejected, with the status code and the error your server returned. You can replay any of them — the original payload is resent unchanged, not rebuilt, so what arrives is what was originally promised.