Webhooks
Un webhook este un POST HTTP de la CallAgent către un URL pe care îl deții, semnat cu HMAC-SHA256, reîncercat la eșec și livrat cel puțin o dată. Această pagină este întregul contract: un receiver scris pornind de la ea va fi corect.
Plicul
Șase chei de nivel superior, aceleași pentru orice tip de eveniment, ca să poți face routing pe type înainte să știi ce este data.
{
"id": "01JQ8Z9K3T4V5W6X7Y8Z9A0B1C",
"type": "campaign.completed",
"api_version": "2026-01-01",
"created_at": "2026-01-08T14:23:11+00:00",
"organization_id": "01JQ8ZA1B2C3D4E5F6G7H8J9K0",
"data": {
"campaignId": "01JQ8ZB0000000000000000000",
"campaignName": "Q1 renewals",
"contactsTotal": 400,
"contactsCompleted": 388,
"contactsFailed": 9,
"contactsSkipped": 3,
"attemptsTotal": 512,
"budgetSpent": 41.72
}
}| Cheie | Tip | Note |
|---|---|---|
id | ULID | Id-ul evenimentului. Fă dedupe pe el. Identic între retry-uri, între endpoint-uri și la un replay. |
type | string | Unul dintre tipurile de evenimente. |
api_version | string | Versiunea fixată a endpoint-ului, în prezent 2026-01-01. Se setează pe rândul endpoint-ului; nu poate fi setată prin API. |
created_at | ISO 8601 | Momentul în care livrarea a fost pusă în coadă, nu momentul în care a fost trimisă această încercare. Înghețat — retry-urile poartă exact același string. |
organization_id | ULID | Organizația ta. |
data | object | Diferă per tip de eveniment. |
Plicul este snake_case; data nu este
Cheile plicului sunt snake_case (api_version, created_at, organization_id). Payload-urile data livrate azi folosesc camelCase (campaignId, contactsTotal, nextAttemptAt). Nimic din pipeline nu transformă notația — primești exact ce a scris producătorul — așa că scrie-ți receiver-ul după formele documentate mai jos, nu după o convenție.
Headers
| Header | Valoare |
|---|---|
Content-Type | application/json |
X-CallAgent-Signature | v1=<64 hex chars>, separate prin virgulă în timpul rotației |
X-CallAgent-Timestamp | Secunde Unix, ca string zecimal |
X-CallAgent-Event-Id | Același ULID ca id din body — cheia de dedupe |
X-CallAgent-Event-Type | Același ca type |
X-CallAgent-Delivery-Id | Rândul acestei încercări de livrare; diferă per endpoint și per replay |
X-CallAgent-Attempt | Numărul încercării, numerotat de la 1 |
X-CallAgent-Api-Version | Același ca api_version |
User-Agent | CallAgent/1.0 (+https://callagent.dev) |
Orice header custom configurat pe endpoint este aplicat după acestea, așa că un header custom numit X-CallAgent-Signature va suprascrie semnătura reală. Nu denumi un header custom X-CallAgent-*.
Verificarea semnăturii
String-ul semnat este timestamp-ul, un . literal, apoi byte-ii bruți din body-ul request-ului:
HMAC-SHA256( "{timestamp}.{raw body}", secret ) → lowercase hexPatru reguli, toate contează:
- Verifică întâi timestamp-ul. Respinge orice se abate cu mai mult de 300 de secunde față de momentul curent, în oricare direcție. Asta oprește un request capturat să fie reluat săptămâna viitoare.
- Fă hash pe byte-ii bruți. Nu pe un obiect re-serializat.
JSON.stringify(req.body)nu va reproduce byte-ii pe care i-am semnat, iar verificarea ta va eșua intermitent și inexplicabil. - Compară în timp constant. Un timing oracle pe o semnătură de webhook este o cale de falsificare reală, chiar dacă lentă.
- Acceptă request-ul dacă oricare semnătură din header se potrivește. În timpul unei rotații de secret, header-ul poartă două, separate prin virgulă, cea mai nouă prima.
Secretul este whsec_ + 48 de caractere hex. Este afișat exact de două ori: în 201 de la POST /webhooks și în response-ul la POST /webhooks/{id}/rotate-secret. Nu apare în GET /webhooks/{id}, pentru că este criptat la rest și nu ți-l mai putem arăta.
import { createHmac, timingSafeEqual } from 'node:crypto';
const TOLERANCE_SECONDS = 300;
/** `raw` TREBUIE să fie Buffer-ul/string-ul brut al corpului, nu un obiect parsat. */
export function verify(raw, headers, secret) {
const timestamp = Number(headers['x-callagent-timestamp']);
if (!Number.isFinite(timestamp)) return false;
// Ambele direcții: un timestamp din viitor e la fel de suspect ca unul vechi.
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${raw}`)
.digest('hex');
const expectedBytes = Buffer.from(expected, 'utf8');
// Rotația trimite câte un `v1=` per secret activ; orice potrivire trece.
return String(headers['x-callagent-signature'] ?? '')
.split(',')
.map((part) => part.trim())
.map((part) => (part.includes('=') ? part.split('=', 2) : ['v1', part]))
.filter(([version]) => version.trim() === 'v1')
.some(([, digest]) => {
const candidate = Buffer.from(digest.trim(), 'utf8');
return candidate.length === expectedBytes.length && timingSafeEqual(candidate, expectedBytes);
});
}
// Express: păstrează body-ul brut.
// app.post('/webhooks/callagent', express.raw({ type: 'application/json' }), (req, res) => {
// if (!verify(req.body, req.headers, process.env.CALLAGENT_WEBHOOK_SECRET)) return res.sendStatus(400);
// const event = JSON.parse(req.body.toString('utf8'));
// res.sendStatus(200); // întâi ack, apoi lucrul
// });<?php
const CALLAGENT_TOLERANCE_SECONDS = 300;
/** $raw TREBUIE să fie file_get_contents('php://input'), nu un array re-encodat. */
function callagent_verify(string $raw, array $headers, string $secret): bool
{
$timestamp = (int) ($headers['x-callagent-timestamp'] ?? 0);
// Ambele direcții: un timestamp din viitor e la fel de suspect ca unul vechi.
if ($timestamp === 0 || abs(time() - $timestamp) > CALLAGENT_TOLERANCE_SECONDS) {
return false;
}
$expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);
// Rotația trimite câte un `v1=` per secret activ; orice potrivire trece.
foreach (explode(',', $headers['x-callagent-signature'] ?? '') as $part) {
$part = trim($part);
if ($part === '') {
continue;
}
if (str_contains($part, '=')) {
[$version, $digest] = explode('=', $part, 2);
if (trim($version) !== 'v1') {
continue;
}
} else {
$digest = $part;
}
if (hash_equals($expected, trim($digest))) {
return true;
}
}
return false;
}
// $raw = file_get_contents('php://input');
// $headers = array_change_key_case(getallheaders());
// if (! callagent_verify($raw, $headers, getenv('CALLAGENT_WEBHOOK_SECRET'))) { http_response_code(400); exit; }
// $event = json_decode($raw, true);
// http_response_code(200); // întâi ack, apoi lucrul# Verifică manual o livrare capturată. Salvează întâi byte-ii exacți:
# body-ul în body.json, iar cele două header-e în variabile de shell.
TS="$(cat timestamp.txt)"
SECRET="whsec_your_secret_here"
printf '%s.%s' "$TS" "$(cat body.json)" \
| openssl dgst -sha256 -hmac "$SECRET" -hex \
| sed 's/^.* //'
# Compară cu valoarea v1= din X-CallAgent-Signature.
# `printf`, nu `echo` — echo adaugă un newline și digest-ul nu se va potrivi.O singură schemă de semnătură, două formate
Webhook-urile outbound trimit întotdeauna v1=<hex>. Hook-urile de număr de telefon — callback-ul de routing inbound per număr — semnează același string cu același algoritm, dar trimit un digest hex simplu, fără prefix v1=, sub același nume de header. Verificatoarele de mai sus le tratează pe amândouă, de aceea cad înapoi pe ['v1', part] pentru o parte fără =. Un receiver care face split strict pe v1= va respinge orice hook de număr de telefon.
Rotația secretului
POST /webhooks/{id}/rotate-secret returnează un secret nou și îl păstrează pe cel vechi valid pentru o fereastră de suprapunere — overlap_hours, 0–168, implicit 24. În timpul suprapunerii, header-ul de semnătură le poartă pe amândouă, cea mai nouă prima:
X-CallAgent-Signature: v1=9f2c…,v1=41ab…Fă deploy la secretul nou, confirmă că livrările se verifică în continuare și lasă-l pe cel vechi să expire. Un overlap_hours de 0 revocă imediat secretul vechi, ceea ce va strica retry-urile în curs semnate cu el.
Tipuri de evenimente
28 de evenimente. La fiecare te poți abona; vezi avertismentul de mai jos despre care sunt produse efectiv azi.
| Grup | Eveniment |
|---|---|
| Assistant | assistant.request |
| Call | call.started · call.ringing · call.answered · call.status_update · call.ended · call.failed |
| Speech | speech.started · speech.stopped · transcript.partial · transcript.final |
| Tools | tool.called · tool.completed · tool.failed |
| Transfer | transfer.requested · transfer.completed · transfer.failed |
| Recording | recording.ready · recording.failed |
| Analysis | analysis.completed · structured_output.produced |
| Campaign | campaign.started · campaign.contact_attempted · campaign.completed |
| Operator | operator.state_changed · operator.assigned |
| Billing | billing.threshold_reached · billing.balance_low |
GET /webhooks/events returnează aceeași listă, cu un label, un grup și un flag high_volume pentru fiecare eveniment, și este sursa din care construiești un UI de abonare.
Doar patru evenimente sunt trimise azi
campaign.started, campaign.contact_attempted și campaign.completed sunt produse de motorul de campanii. assistant.request este produs doar de POST /webhooks/{id}/test.
Celelalte 24 sunt declarate, poți să te abonezi la ele și trec validarea — și nimic din control plane nu le emite încă. Abonarea la call.ended azi nu este o eroare și nu va livra nimic. Construiește pe cele patru și tratează restul ca pe o formă publicată care nu este încă un feed live.
Evenimente de volum mare
Patru evenimente se declanșează per enunț, nu per apel: transcript.partial, speech.started, speech.stopped, call.status_update.
Sunt doar opt-in, iar regula nu este cea la care te-ai aștepta:
events pe endpoint | Ce primești |
|---|---|
[] (gol, valoarea implicită) | Tot, mai puțin cele patru evenimente de volum mare |
["*"] | Tot, inclusiv cele de volum mare |
["call.*"] | Fiecare eveniment call., inclusiv call.status_update |
["call.ended"] | Exact acela |
Deci o listă goală nu înseamnă „tot” — înseamnă „tot ce poate suporta un cont aglomerat”. Doar * sau un nume explicit te înscrie la mii de livrări pe minut.
Wildcard-urile se potrivesc pe un singur nivel: segmentul dinaintea primului punct, plus .*. Deci structured_output.* se potrivește cu structured_output.produced. Orice nume care se termină în .* sau * este acceptat fără să fie verificat față de catalog; un nume exact necunoscut este respins cu 422.
Un endpoint care specifică assistant_ids primește doar evenimentele care poartă unul dintre acei asistenți — iar un eveniment fără context de asistent este filtrat afară, nu înăuntru.
Payload-uri livrate azi
campaign.started
{
"campaignId": "01JQ…",
"campaignName": "Q1 renewals",
"contactsTotal": 400,
"dryRun": false,
"startedAt": "2026-01-08T14:00:00+00:00"
}campaign.contact_attempted
{
"campaignId": "01JQ…",
"campaignName": "Q1 renewals",
"contactId": "01JQ…",
"externalId": "crm-8841",
"phoneNumber": "+40712345678",
"attemptNumber": 2,
"outcome": "answered",
"endedReason": "assistant_ended",
"durationSeconds": 94,
"abandoned": false,
"callId": "01JQ…",
"state": "completed",
"nextAttemptAt": null
}externalId, phoneNumber, endedReason, durationSeconds, state și nextAttemptAt pot fi null.
campaign.completed
{
"campaignId": "01JQ…",
"campaignName": "Q1 renewals",
"contactsTotal": 400,
"contactsCompleted": 388,
"contactsFailed": 9,
"contactsSkipped": 3,
"attemptsTotal": 512,
"budgetSpent": 41.72
}assistant.request — trimis doar de POST /webhooks/{id}/test:
{
"test": true,
"message": "This is a CallAgent test event. If your signature verification accepted it, you are correctly configured.",
"endpoint_id": "01JQ…"
}Acel eveniment de test parcurge tot traseul de semnare și livrare, așa că este modalitatea corectă de a demonstra că un verificator funcționează înainte să depinzi de el.
Valorile sunt redactate înainte să fie trimise
Orice cheie din data al cărei nume, scris cu litere mici, conține password, secret, token, api_key, authorization, credential, card_number, iban, cvv, pan, ssn și încă vreo douăzeci are valoarea înlocuită cu ••••••••. Structurile imbricate sunt parcurse; null rămâne null; cheile nu sunt niciodată redenumite.
Potrivirea se face pe substring, deci se declanșează în exces: o cheie numită company conține pan și va ajunge mascată. Dacă tu controlezi cheile unui payload și o valoare ajunge sub formă de buline, acesta este motivul.
Cheile de numărare a token-urilor (total_tokens, input_tokens, max_tokens, …) sunt exceptate explicit.
Livrare, retry-uri și eșec
| Metodă | POST |
| Succes | HTTP 200–299. Nimic altceva. Body-ul response-ului este ignorat. |
| Se reîncearcă | 408, 429, orice 5xx și orice eșec de transport (eroare de conectare, timeout, DNS) |
| Nu se reîncearcă | Orice alt 4xx — va eșua identic la nesfârșit |
| Încercări | max_attempts pe endpoint, implicit 6, setabil 1–12 |
| Backoff | 10s, 30s, 120s, 600s, 1800s, 3600s — determinist, fără jitter, ultima valoare se repetă |
| Timeout | timeout_seconds pe endpoint, implicit 15, setabil 1–60; timeout-ul de conectare este min(5, timeout) |
Cu cele șase încercări implicite, programul este 0s, +10s, +40s, +160s, +760s, +2560s — aproximativ 43 de minute de la prima încercare până la epuizare.
Nu face redirect. Un 3xx este urmat manual, cel mult 3 hop-uri, iar un 301/302/303 degradează la GET și pierde body-ul — deci receiver-ul tău primește un GET fără body, iar livrarea eșuează. Publică URL-ul final.
URL-ul tău este rezolvat din nou la fiecare încercare. Dacă începe să se rezolve către o adresă privată, livrarea este marcată blocked și nu se mai reîncearcă niciodată, pentru că o destinație blocată nu este un eșec tranzitoriu.
Sănătatea endpoint-ului
consecutive_failures numără încercările eșuate, nu livrările eșuate, și se resetează la zero la orice 2xx.
| Eșecuri | status |
|---|---|
| 0 | healthy |
| 1–4 | degraded |
| 5–19 | failing |
| 20 | disabled — active este setat pe false și livrarea se oprește |
Un endpoint dezactivat este exclus complet din fan-out. Reactivează-l din dashboard; setarea active: true cu PATCH /webhooks/{id} nu este, în prezent, suficientă singură ca să readucă un endpoint dezactivat.
Ștergerea unui endpoint nu oprește livrările din coadă
DELETE /webhooks/{id} face soft-delete pe endpoint, dar livrările deja puse în coadă pentru el sunt totuși încercate. Dacă ai nevoie ca livrarea să se oprească imediat, rotește secretul cu overlap_hours: 0 — fiecare încercare în curs ajunge atunci cu o semnătură pe care receiver-ul tău o va respinge — sau lasă endpoint-ul pe loc și dezabonează-l de la toate evenimentele.
Idempotență și ordonare
Fă dedupe pe X-CallAgent-Event-Id (identic cu id din body). Este stabil la fiecare retry al unei livrări, la fan-out către mai multe endpoint-uri și la un replay făcut de un operator.
| Identificator | Stabil între retry-uri | Același pentru două endpoint-uri | Același la replay |
|---|---|---|---|
X-CallAgent-Event-Id | da | da | da |
X-CallAgent-Delivery-Id | da | nu | nu |
X-CallAgent-Attempt | crește | — | se resetează la 1 |
Pentru că id-ul evenimentului este comun între endpoint-uri, un receiver care deservește două endpoint-uri pe același URL și face dedupe doar pe id-ul evenimentului va arunca în tăcere a doua copie. Dacă ai nevoie de contabilizare per endpoint, fă dedupe pe perechea (event id, delivery id).
Nu există nicio garanție de ordonare, iar acesta nu este un detaliu pe care îl poți ignora. Încercările sunt job-uri de coadă independente, fără serializare per endpoint, iar un eveniment reîncercat este întârziat de backoff-ul lui — așa că un call.started eșuat va ajunge după un call.ended reușit. Ordonează-ți singur procesarea după ceva din payload și fă fiecare handler idempotent. created_at (momentul punerii în coadă) este singurul semnal de ordonare disponibil și nu există niciun număr de secvență.
Inspectare și replay
GET /webhook-deliveries | Filtrează după webhook_endpoint_id, event_type, status, call_id. Cele mai noi primele. |
GET /webhook-deliveries/{delivery} | Adaugă attempt_log[] — per încercare: attempt, outcome, response_status, duration_ms, error, resolved_ip, created_at. |
POST /webhook-deliveries/{delivery}/redeliver | 202. Rând de livrare nou, același id de eveniment și payload identic la nivel de byte, cu redelivery_of_id care indică originalul. |
O înregistrare de livrare poartă exact payload-ul pe care l-am trimis, request_headers (cu semnătura mascată), response_headers de la tine și primii 8 KB din response_body — de obicei suficient ca să vezi de ce a respins ceva un receiver.
status-ul livrării este unul dintre pending, delivering, succeeded, dead, skipped. outcome-ul încercării este succeeded, http_error, network_error sau blocked.
Doar o livrare succeeded, failed sau dead poate fi reluată; orice altceva înseamnă un 422. Înregistrările de livrare sunt șterse după 30 de zile.
Un receiver corect, cap-coadă
- Citește body-ul brut. Nu lăsa un framework să îl parseze înainte să verifici.
- Verifică dacă timestamp-ul se încadrează în 300 de secunde, în ambele direcții.
- Verifică semnătura, în timp constant, acceptând orice
v1=din header. - Returnează
2xximediat — înainte să faci treaba. Ai 15 secunde, iar un receiver lent este reîncercat, și așa ajungi să procesezi de șase ori același eveniment. - Fă dedupe pe
X-CallAgent-Event-Idîntr-un storage durabil, nu în memorie. - Fă treaba asincron, presupunând că evenimentele pot ajunge în altă ordine.
- La o eroare nerecuperabilă, returnează tot
2xxși înregistreaz-o — returnarea unui4xxmarchează livrarea ca dead de la prima încercare și nu vei mai vedea niciodată acel eveniment.
Vezi și
- Erori — formele de eroare returnate de API-ul REST, care nu sunt plicul de webhook.
- Tipuri de evenimente — cele ~60 de valori interne
CallEventTypede laGET /calls/{call}/events. Un vocabular diferit de cele 28 de mai sus, cu nume similare în mod deliberat:call.createdșitool.startedsunt evenimente de apel;call.startedșitool.calledsunt evenimente de webhook. - Webhook endpoints — crearea și gestionarea endpoint-urilor.
- Webhook deliveries — jurnalul de livrări.