Webhooks
A webhook is an address in your systems where Caller sends the result of each call as soon as it is analyzed. It is how you get scores, criteria and summaries into your CRM, your data warehouse or a dashboard of your own without anyone exporting anything.
Set it up
Section titled “Set it up”-
In the app, go to Integrations and, under Notices, click Set up on the Webhooks card.
-
Enter the Address that will receive the deliveries and leave Send the results checked.
-
Save. Caller shows the signing secret, which starts with
whsec_. Copy it: it is not shown again. -
Click Send a test to check that your address responds.
There is one webhook per account. The address has to meet three conditions:
- It starts with
https://. - It is public.
localhost, private IP addresses and internal names such asserver.localare not accepted. - It has no user name or password in it (
https://user:password@…).
To pause deliveries without losing the configuration, uncheck Send the results.
What you receive
Section titled “What you receive”Each delivery is a POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
User-Agent |
Caller-Webhooks/1 |
X-Caller-Event |
The event type: call.analyzed or ping. |
X-Caller-Signature |
The signature of the body. See verify the signature. |
call.analyzed
Section titled “call.analyzed”Sent every time the analysis of a call finishes, whether the call came in through the API or was uploaded by hand. If a call is analyzed again, the event is sent again with the new result.
{ "event": "call.analyzed", "sent_at": "2026-10-09T08:51:37.204Z", "call": { "id": "k57e2xq9m4hc8w1t6b0z5y2e97c4n1ad", "external_id": "pbx-000123", "url": "https://app.caller.ee/calls/k57e2xq9m4hc8w1t6b0z5y2e97c4n1ad", "title": "Asks to cancel over the price and accepts a discount", "score": 76, "band": "mid", "topic": "Service cancellation", "outcome": "resolved", "customer_sentiment": { "start": "negative", "end": "neutral" }, "agent": { "id": "jd7b4m1n8p2q5r9s3t6v0w4x7y1z8c2e", "name": "Lucía Romero", "code": "AG1143" }, "campaign": "Retention", "started_at": "2026-10-09T08:42:11.000Z", "duration_seconds": 312, "needs_review": true, "review_reasons": ["required_failed"], "criteria": [ { "key": "greeting", "name": "Corporate greeting", "score": 10, "max": 10, "required": false }, { "key": "verification", "name": "Identity verification", "score": 0, "max": 10, "required": true }, { "key": "alternative", "name": "Offers an alternative to canceling", "score": 20, "max": 20, "required": false }, { "key": "closing", "name": "Call closing", "score": 8, "max": 10, "required": false } ], "summary": "The customer calls to cancel because of the latest price increase. The agent offers her a discount for six months and she accepts it. Her identity is not verified before her contract is looked up.", "next_step": "Apply the discount to the next invoice." }}| Field | Type | Description |
|---|---|---|
event |
string | call.analyzed. |
sent_at |
date | When this delivery was made, in ISO 8601 and UTC. It changes on every retry. |
call.id |
string | The identifier of the call in Caller. |
call.external_id |
string or null |
The identifier you sent through the API. null for calls uploaded by hand. |
call.url |
string | The address of the call in the app. |
call.title |
string | The title the analysis gave the call. |
call.score |
number or null |
The score, from 0 to 100. null if the template has no scored criteria. |
call.band |
string or null |
The score band: good (80 or more), mid (60 to 79) or bad (below 60). |
call.topic |
string or null |
The main topic. |
call.outcome |
string or null |
How the call ended: resolved, follow_up, escalated or unresolved. |
call.customer_sentiment |
object or null |
The customer’s sentiment at the start (start) and at the end (end): positive, neutral or negative. |
call.agent |
object or null |
The agent: id, name and code. Any of the three can be null. |
call.campaign |
string or null |
The name of the campaign. |
call.started_at |
date or null |
When the call started, in UTC. |
call.duration_seconds |
number or null |
The length of the recording. |
call.needs_review |
boolean | Whether the call is in the To review queue at the time of the delivery. |
call.review_reasons |
array | The reasons the call should be listened to: required_failed, critical_score, customer_upset, unresolved, long_silence. Empty if there are none. It can contain reasons while needs_review is false if someone has already marked the call as reviewed. |
call.criteria |
array | One item per criterion in the template: its key (key), its name, the points earned (score), the points possible (max) and whether it is mandatory (required). |
call.summary |
string or null |
The summary of the call. |
call.next_step |
string or null |
The agreed next step, if there is one. |
The key of a criterion does not change even if you rename the criterion in the template. Use it to match one week’s results with the next week’s.
The body does not include the transcript or the audio. To see them, open call.url.
This is what the Send a test button sends. Use it to check the connection and the signature without waiting for a real call.
{ "event": "ping", "sent_at": "2026-10-09T08:40:02.118Z" }Respond
Section titled “Respond”Respond with a 2xx code in less than 10 seconds. The content of your response is not read.
Do the minimum before you respond: verify the signature, store the body and reply. Do the slow work, such as writing to a CRM or recalculating a report, afterward. A receiver that takes more than 10 seconds counts as a failed delivery, even if it processes the event in the end.
Verify the signature
Section titled “Verify the signature”Your address is public, so anyone can send it a POST. The signature proves that a delivery comes from Caller and that nobody has altered its content.
The X-Caller-Signature header looks like this:
X-Caller-Signature: sha256=5a1f0c…What follows sha256= is the HMAC-SHA256 of the request body, computed with your signing secret and written in hexadecimal. To verify it, compute the same HMAC over the body you received and compare.
import hashlibimport hmacimport os
from flask import Flask, abort, request
app = Flask(__name__)SECRET = os.environ["CALLER_WEBHOOK_SECRET"].encode()
def valid_signature(body: bytes, header: str) -> bool: expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, header)
@app.post("/hooks/caller")def receive(): body = request.get_data() # the bytes exactly as they arrived if not valid_signature(body, request.headers.get("X-Caller-Signature", "")): abort(401)
payload = request.get_json() if payload["event"] == "call.analyzed": save(payload["call"]) # your code return "", 204import { createHmac, timingSafeEqual } from 'node:crypto';import express from 'express';
const app = express();const secret = process.env.CALLER_WEBHOOK_SECRET;
function validSignature(body, header = '') { const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex'); const a = Buffer.from(expected); const b = Buffer.from(header); return a.length === b.length && timingSafeEqual(a, b);}
// express.raw gives you the body as a Buffer, untouched.app.post('/hooks/caller', express.raw({ type: 'application/json' }), (req, res) => { if (!validSignature(req.body, req.get('X-Caller-Signature'))) return res.sendStatus(401);
const payload = JSON.parse(req.body); if (payload.event === 'call.analyzed') save(payload.call); // your code res.sendStatus(204);});
app.listen(3000);<?php$secret = getenv('CALLER_WEBHOOK_SECRET');$body = file_get_contents('php://input'); // the body exactly as it arrived$header = $_SERVER['HTTP_X_CALLER_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $body, $secret);if (!hash_equals($expected, $header)) { http_response_code(401); exit;}
$payload = json_decode($body, true);if ($payload['event'] === 'call.analyzed') { save($payload['call']); // your code}http_response_code(204);Compare with a constant-time function (hmac.compare_digest, timingSafeEqual, hash_equals), not with ==.
Change the secret
Section titled “Change the secret”In the webhook configuration, click Make a new secret. Caller shows the new one once, and the previous secret stops working at that moment, so deliveries that arrive before you update your system will fail verification. Do it at a time of low traffic.
Retries
Section titled “Retries”A delivery counts as failed when your address:
- does not respond within 10 seconds or cannot be reached,
- responds with a
5xxcode, - responds with
429.
In those cases Caller tries two more times: after 1 minute and, if that fails too, 10 minutes later. After the third attempt, that delivery is dropped.
Any other response is treated as final and is not retried. That includes 4xx codes and also redirects: Caller does not follow a 301 or a 302, so configure the final address.
The Webhooks card in Integrations shows the result of the last delivery: accepted, no answer, or the code your address returned.
Write a reliable receiver
Section titled “Write a reliable receiver”- Treat each delivery as “this is the current state of the call.” Store the result with
call.idas the key and overwrite it if it already exists. That way, a retry that arrives twice or a new analysis of the same call leaves your data correct with no special logic. - Do not assume the order. A retry can arrive after the delivery for a later call.
- Ignore events you do not know. If
eventis not one you handle, respond with2xxand do nothing. - Have a plan for what gets lost. If your system is down for more than a few minutes, the deliveries from that time are dropped and are not sent again. The results are still in the app: you can recover them with a CSV export or by analyzing those calls again, which uses analyses.
When an event does not arrive
Section titled “When an event does not arrive”The webhook is sent when a call is analyzed. Nothing is sent if:
- The call could not be transcribed.
- The account has no template, or there are no analyses left in the period. The event will arrive when the call is analyzed.
- Someone corrects a score by hand in the app. The correction does not produce a new delivery.
Test on your computer
Section titled “Test on your computer”Because the address has to be public and https, a server on localhost does not work directly. While you develop, use a tunnel that exposes your local port at a temporary https address, set that address in Caller and click Send a test.