Call event webhooks
For CRM integrators and business owners: a webhook tells your system about a call while it is happening. You can open the customer card for the employee, create a task after a missed call or collect your own statistics. You need a public HTTPS receiver; without one, poll the event feed. Keys and permissions are covered in the overview Your PBX API and MCP.
In this article: creating, events, signature, delivery, polling, debugging.
Create a webhook#
Open Telephony → API and MCP → Webhooks, add a name and a public HTTPS address, choose locations and events. Save the whsec_… secret: it is shown once. The "Test" button sends webhook.ping. Up to 10 webhooks per account; name up to 60 characters, URL up to 500.
Via the API you need a key with the separately granted webhooks:write. Empty events means all five call events; empty locations — all locations. A key limited to locations must list the allowed locations explicitly and cannot manage an account-wide webhook.
curl -sS 'https://api.cenaly.ru/pbx-api/v1/webhooks' \
-H 'Authorization: Bearer cpx_…' -H 'Content-Type: application/json' \
-d '{"name":"CRM","url":"https://crm.example.com/cenaly/events","events":["call.started","call.ended"],"locations":["loc1"],"enabled":true}'
Example response (time, id and secret are made up):
{
"ok": true,
"created": true,
"webhook": {
"id": "wh_0123456789ab",
"name": "CRM",
"url": "https://crm.example.com/cenaly/events",
"events": [
"call.started",
"call.ended"
],
"locations": [
"loc1"
],
"enabled": true,
"createdAt": "2026-10-24T08:00:00Z",
"secretHint": "whsec_exampl"
},
"secret": "whsec_…"
}
PATCH /v1/webhooks/{id} changes the settings you pass. POST …/{id}/rotate-secret issues a new secret and revokes the old one immediately; DELETE …/{id} deletes the subscription. When rotating, coordinate the secret change with the receiver.
Events are sent by the Cenaly cloud. The sender's address may change — including when the PBX server itself starts doing the delivery — so verify authenticity by the body signature, not by IP.
Event types and bodies#
| Type | When it happens | Special data fields |
|---|---|---|
call.started |
The call starts | direction, parties and the line, if known |
call.ringing |
A phone rings for the first time | party: whose phone; there may be several events per call |
call.answered |
The first person answers | party: who answered |
call.transferred |
The conversation is transferred | party: to whom, by: who transferred |
call.ended |
The call is over | status, durations, answeredBy, hangupBy when present |
webhook.ping |
"Test" was pressed | a service message, not a call |
The envelope always contains id, type, createdAt, locationId, webhookId, attempt, data. In call events data is the original event with id, type, at, callId, seq. createdAt is taken from the event time; it is not the time of a retry. Optional fields may be absent; zero durations are usually not sent.
The full bodies below describe a made-up call. Line, trunk and party fields depend on the route, so do not require them to be present every time.
call.started#
{
"id": "1792836000.1:1",
"type": "call.started",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:1",
"type": "call.started",
"at": "2026-10-24T10:00:00Z",
"callId": "1792836000.1",
"seq": 1,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
}
}
}
call.ringing#
{
"id": "1792836000.1:2",
"type": "call.ringing",
"createdAt": "2026-10-24T10:00:01Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:2",
"type": "call.ringing",
"at": "2026-10-24T10:00:01Z",
"callId": "1792836000.1",
"seq": 2,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Anna"
}
}
}
call.answered#
{
"id": "1792836000.1:3",
"type": "call.answered",
"createdAt": "2026-10-24T10:00:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:3",
"type": "call.answered",
"at": "2026-10-24T10:00:05Z",
"callId": "1792836000.1",
"seq": 3,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "101",
"name": "Anna"
}
}
}
call.transferred#
{
"id": "1792836000.1:4",
"type": "call.transferred",
"createdAt": "2026-10-24T10:00:30Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:4",
"type": "call.transferred",
"at": "2026-10-24T10:00:30Z",
"callId": "1792836000.1",
"seq": 4,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"party": {
"kind": "operator",
"extension": "102",
"name": "Ivan"
},
"by": {
"kind": "operator",
"extension": "101",
"name": "Anna"
}
}
}
call.ended#
{
"id": "1792836000.1:5",
"type": "call.ended",
"createdAt": "2026-10-24T10:01:05Z",
"locationId": "loc1",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"id": "1792836000.1:5",
"type": "call.ended",
"at": "2026-10-24T10:01:05Z",
"callId": "1792836000.1",
"seq": 5,
"direction": "inbound",
"from": {
"kind": "caller",
"number": "+995555123456"
},
"to": {
"kind": "operator",
"extension": "101"
},
"status": "answered",
"answeredBy": {
"kind": "operator",
"extension": "101",
"name": "Anna"
},
"durationSec": 65,
"talkSec": 60,
"ringSec": 5
}
}
There may also be lineId, lineLabel, line (id, number, caption, resolution, source), trunk (id, name), redirectedFrom, transferOf, hangupBy. Parties may carry operatorId and connectionId. Keep unknown fields or ignore them; do not reject an event when the schema grows.
webhook.ping#
{
"id": "ping:example",
"type": "webhook.ping",
"createdAt": "2026-10-24T10:00:00Z",
"locationId": "",
"webhookId": "wh_0123456789ab",
"attempt": 1,
"data": {
"message": "Cenaly: проверка вебхука / webhook test"
}
}
A test's locationId may be empty. Check the signature, answer 2xx, do not create a call or a task in the CRM.
Headers#
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Cenaly-Signature |
t=<unix>,v1=<hex HMAC-SHA256> |
X-Cenaly-Event |
Event type |
X-Cenaly-Delivery |
Event id, the same on a retry |
X-Cenaly-Webhook |
Subscription id |
User-Agent |
Cenaly-Webhooks/1.0 (+https://cenaly.com/docs/pbx-api) |
Checking the signature#
The raw body bytes are signed: HMAC-SHA256(secret, t + "." + rawBody). The secret is used whole, including whsec_, without base64 decoding. Do not parse and re-serialize the JSON before checking. The examples reject a timestamp more than 300 seconds away from the receiver's clock in either direction and compare the signature in constant time. Keep your clock in sync.
Node.js#
The function receives a Buffer before any JSON middleware, for example collected from the HTTP request stream. Take the secret from protected settings, never from a request header.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(raw, header, secret, now = Date.now() / 1000) {
const m = /^t=(\d{1,12}),v1=([0-9a-f]{64})$/.exec(header || '');
if (!m || Math.abs(now - Number(m[1])) > 300) return false;
const expected = createHmac('sha256', secret)
.update(m[1] + '.').update(raw).digest();
return timingSafeEqual(expected, Buffer.from(m[2], 'hex'));
}
Python#
raw is bytes, for example request.get_data() before JSON parsing.
import hashlib, hmac, re, time
def verify(raw, header, secret, now=None):
m = re.fullmatch(r"t=(\d{1,12}),v1=([0-9a-f]{64})", header or "")
if not m or abs((time.time() if now is None else now) - int(m[1])) > 300:
return False
expected = hmac.new(secret.encode(), m[1].encode() + b"." + raw,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, m[2])
PHP#
<?php
function verifySignature(string $raw, string $header, string $secret): bool {
if (!preg_match('/\At=(\d{1,12}),v1=([0-9a-f]{64})\z/', $header, $m)) return false;
if (abs(time() - (int)$m[1]) > 300) return false;
$expected = hash_hmac('sha256', $m[1] . '.' . $raw, $secret);
return hash_equals($expected, $m[2]);
}
$raw = file_get_contents('php://input');
if (!verifySignature($raw, $_SERVER['HTTP_X_CENALY_SIGNATURE'] ?? '', getenv('PBX_WEBHOOK_SECRET') ?: '')) {
http_response_code(401);
exit;
}
// Before answering, store the event in your queue reliably; if storing fails, return 503.
Go#
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var signatureRE = regexp.MustCompile(`^t=([0-9]{1,12}),v1=([0-9a-f]{64})$`)
func Verify(raw []byte, header, secret string, now time.Time) bool {
m := signatureRE.FindStringSubmatch(header)
if m == nil { return false }
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil || ts < now.Unix()-300 || ts > now.Unix()+300 { return false }
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(raw)
got, err := hex.DecodeString(m[2])
return err == nil && hmac.Equal(mac.Sum(nil), got)
}
After a successful check, parse the JSON, validate the fields you need and store the event. Limit the incoming HTTP body size on your server. A wrong signature — 401, unable to store — 5xx, after a reliable intake — 200 or 204.
Delivery, retries and order#
Answer 2xx within 10 seconds. Do the heavy work from your own queue after answering, but store the event before acknowledging. 3xx is not followed; 4xx, 5xx, a timeout and a network error count as failures.
| Next attempt | Pause after the previous one |
|---|---|
| 2 | 10 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 5 minutes |
| 6 | 10 minutes |
| 7 | 15 minutes |
| 8 | 15 minutes |
8 attempts in total, pauses adding up to 47 minutes 40 seconds plus request and queue time. This is not an exact clock schedule. webhook.ping is sent once, with no retries.
A call event's id is <callId>:<seq>. On a retry the id stays the same, attempt grows, and the signature and its timestamp are computed again. In the receiver's storage make (webhookId, id) unique if you accept several subscriptions, or id within one subscription. Acknowledge a retry of an already stored event with 2xx as well. Enqueue the job and record the id in one transaction — otherwise you may lose events or create duplicate tasks.
The order of HTTP deliveries is not guaranteed: a retried call.started may arrive after call.ended. Build state from locationId, data.callId and data.seq, and never roll a finished call back to "ringing". A gap in seq does not necessarily mean a loss: the subscription may exclude intermediate types. The event feed and the call log help reconcile the outcome; webhooks are not a permanent archive.
After a series of failed real deliveries lasting at least 72 hours, the webhook is turned off automatically and the owner is notified. A successful real delivery resets the series; a test ping does not affect it. No calls at all does not turn a webhook off.
The window then shows "Turned off automatically" and "Turn on". After fixing the receiver, turn it on there or send PATCH /v1/webhooks/{id} with {"enabled":true}. Reconcile historical gaps separately.
The log is in the subscription window and at GET /v1/webhooks/{id}/deliveries: up to the last 100 attempts with time, event, attempt number, HTTP code, error and result. Code 0 means no HTTP response was received. POST /v1/webhooks/{id}/test queues a ping; accepting the command does not mean a successful delivery — read the log.
Without your own server#
The calls:read permission gives GET /v1/events and the MCP tool get_call_events. These are the original events, without the webhook envelope, attempt or signature. The feed keeps up to 2,000 events per location over 7 days. Parameters: since — an id or ISO time, types — comma-separated types, limit — up to 500.
curl -sS -G 'https://api.cenaly.ru/pbx-api/v1/events' \
-H 'Authorization: Bearer cpx_…' \
--data-urlencode 'location=loc1' --data-urlencode 'since=1792836000.1:5' \
--data-urlencode 'limit=100'
{
"location": "loc1",
"events": [],
"more": false
}
If there are events, the response contains next — the id of the last one returned. Store it after processing and pass it as since of the next request. With more:true continue immediately, with false pause within the API limit. Do not reuse the previous since for the next page.
The feed is limited by its retention window: if the receiver was silent for more than 7 days or missed more than 2,000 events, the old ones cannot be recovered — reconcile the outcome with the call log.
MCP call: get_call_events with {"location":"loc1","since":"1792836000.1:5","types":["call.ended"],"limit":100}.
Recipes#
Customer card in the CRM. On call.started look up the contact by the normalized data.from.number. On call.ringing show the card to the employee from data.party.extension; on call.answered mark who picked up, on a transfer change the recipient. On the end, close the call indicator. Keep the mapping between extensions and CRM employees on your side.
Missed call → task. On call.ended with direction:"inbound" and a status of missed, busy, cancelled or abandoned, create one task per callId. Filter out unwanted short calls with your own policy. Do not create a task from a single call.ringing: another phone may have answered.
Statistics. Count finished calls once, directions and outcomes separately; take durations from durationSec, talkSec, ringSec, keeping in mind that zero values are omitted. To compare with Cenaly analytics use /v1/metrics (REST API): SLA rules and metric calculation are more involved than simply counting events.
Debugging#
- Run a local HTTP receiver, for example on port 8080, and give it a public HTTPS tunnel:
ngrok http 8080. Put the HTTPS URL you get, with the receiver's path, into the webhook.localhost, private addresses, HTTP and redirects do not work for delivery. - Save the secret; press "Test". Check the HTTP code and the error in the log. Make sure the route accepts POST without a browser sign-in or a form CSRF check.
- If the signature does not match, compare the raw bytes, the secret, the dot separator and the clock. Do not log the secret or full call bodies into a shared log.
- Check that a retry of the same id does not create a second record; then send the events of a test scenario in reverse order.
- After the ping, make an allowed test call: the ping does not check the event filter, the locations or whether events are enabled on the PBX.
- Check public DNS/TLS availability and a response faster than 10 seconds. When the tunnel address changes, update the subscription.
Audio processing and transcript-ready events belong to the "Webhook" source of the Calls section. These five PBX event types have no "transcript ready" notification.