Webhooks
Webhooks work in both directions. Inbound, you push calls to us from any platform. Outbound, we push signed lifecycle events — including the finished report — to your endpoints.
Inbound: push a call to us
A single, flexible endpoint that turns almost any platform's webhook into a call. Authenticate
with your API key in the X-Api-Key header (or Authorization: Bearer) — the
key both identifies your company and acts as the shared secret.
Flexible field mapping
The endpoint accepts several common field names for each value, so you can often point a platform's native webhook straight at it. For each call attribute, the first present key wins:
| Call attribute | Accepted JSON keys (in priority order) |
|---|---|
| External id | external_call_id, call_id, id |
| Rep name | rep_name, agent_name, user_name |
| Rep email | rep_email, agent_email |
| Customer name | customer_name, contact_name |
| Customer phone | customer_phone, phone, from |
| Customer email | customer_email, email |
| Recording URL | recording_url, recording, audio_url |
| Duration (sec) | duration_seconds, duration |
| Started at | call_started_at, started_at |
| CRM contact id | crm_contact_id, contact_id |
| CRM deal id | crm_deal_id, deal_id |
The entire raw payload is stored alongside the call, so anything not mapped is still retained.
Request
POST https://api.callanalyticsapi.com/webhooks/inbound/generic
X-Api-Key: cc_3a91f8e2_your_api_key
Content-Type: application/json
{
"id": "ghl-evt-7781",
"contact_name": "Acme Corp",
"phone": "+15125550199",
"recording": "https://storage.example.com/rec/7781.mp3",
"duration": 1560,
"agent_name": "Jordan Lee"
}
Responses
201 {"call_id":4131,"status":"received"}— a new call was created.200 {"call_id":4131,"status":"duplicate"}— the external id already exists; the existing call is returned. Dedupe is keyed on the external id, so the endpoint is safe to retry.401 {"error":"unauthorized",...}— missing or invalid key/secret.422 {"error":"empty_payload"}— the JSON body was empty.
Outbound: receive events from us
Add an endpoint under Company → Webhooks in the app and subscribe it to the events you care about. When an event fires, we POST a JSON body to your URL and sign it.
Event types
| Event | Fires when |
|---|---|
call.received | A call was accepted and queued. |
call.processing_started | The pipeline began working the call. |
call.transcription_completed | Transcription finished. |
call.analysis_completed | AI analysis finished. |
call.report_completed | The structured report is ready (payload includes the report). |
call.failed | Processing failed for the call. |
call.low_score_detected | The call scored below your threshold. |
call.buying_signal_detected | A buying signal was detected. |
call.compliance_alert_detected | A compliance issue was flagged. |
Signature
Every delivery carries an HMAC-SHA256 signature in the X-Cadence-Signature header,
computed over timestamp + "." + raw_body using your endpoint's signing secret (shown
once when you create the endpoint):
X-Cadence-Signature: t=1718291040,v1=4f8c…e21a
X-Cadence-Event: call.report_completed
Content-Type: application/json
To verify: split the header on ,, read t and v1, recompute
HMAC_SHA256(secret, t + "." + body), and compare in constant time. Reject deliveries
whose timestamp is outside a tolerance window (we use 300 seconds) to prevent replay.
PHP
<?php
function verify(string $body, string $header, string $secret, int $tolerance = 300): bool {
$parts = [];
foreach (explode(',', $header) as $kv) {
[$k, $v] = array_pad(explode('=', $kv, 2), 2, '');
$parts[$k] = $v;
}
$ts = (int) ($parts['t'] ?? 0);
if ($ts === 0 || abs(time() - $ts) > $tolerance) return false;
$expected = hash_hmac('sha256', $ts . '.' . $body, $secret);
return isset($parts['v1']) && hash_equals($expected, $parts['v1']);
}
$body = file_get_contents('php://input');
if (!verify($body, $_SERVER['HTTP_X_CADENCE_SIGNATURE'] ?? '', getenv('CADENCE_WEBHOOK_SECRET'))) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
Node.js
const crypto = require('crypto');
function verify(rawBody, header, secret, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map(kv => kv.split('=')));
const ts = parseInt(parts.t || '0', 10);
if (!ts || Math.abs(Date.now() / 1000 - ts) > tolerance) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${ts}.${rawBody}`).digest('hex');
return parts.v1 &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
// express, with express.raw({ type: 'application/json' })
app.post('/cadence', (req, res) => {
const ok = verify(req.body.toString('utf8'),
req.get('X-Cadence-Signature') || '', process.env.CADENCE_WEBHOOK_SECRET);
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200);
});
Respond 2xx to acknowledge. Non-2xx responses are recorded as failed deliveries and
retried. You can inspect recent deliveries via
GET /v1/webhooks/events.
Recent deliveries
{
"data": [
{ "id": 9912, "event_type": "call.report_completed", "status": "delivered",
"response_status": 200, "attempts": 1, "created_at": "2026-06-22T15:22:10Z" }
]
}