Skip to content

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.

json
{
  "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
  }
}
CheieTipNote
idULIDId-ul evenimentului. Fă dedupe pe el. Identic între retry-uri, între endpoint-uri și la un replay.
typestringUnul dintre tipurile de evenimente.
api_versionstringVersiunea fixată a endpoint-ului, în prezent 2026-01-01. Se setează pe rândul endpoint-ului; nu poate fi setată prin API.
created_atISO 8601Momentul î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_idULIDOrganizația ta.
dataobjectDiferă 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 ​

HeaderValoare
Content-Typeapplication/json
X-CallAgent-Signaturev1=<64 hex chars>, separate prin virgulă în timpul rotației
X-CallAgent-TimestampSecunde Unix, ca string zecimal
X-CallAgent-Event-IdAcelași ULID ca id din body — cheia de dedupe
X-CallAgent-Event-TypeAcelași ca type
X-CallAgent-Delivery-IdRândul acestei încercări de livrare; diferă per endpoint și per replay
X-CallAgent-AttemptNumărul încercării, numerotat de la 1
X-CallAgent-Api-VersionAcelași ca api_version
User-AgentCallAgent/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 hex

Patru reguli, toate contează:

  1. 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.
  2. 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.
  3. Compară în timp constant. Un timing oracle pe o semnătură de webhook este o cale de falsificare reală, chiar dacă lentă.
  4. 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.

js
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
<?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
bash
# 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:

http
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.

GrupEveniment
Assistantassistant.request
Callcall.started · call.ringing · call.answered · call.status_update · call.ended · call.failed
Speechspeech.started · speech.stopped · transcript.partial · transcript.final
Toolstool.called · tool.completed · tool.failed
Transfertransfer.requested · transfer.completed · transfer.failed
Recordingrecording.ready · recording.failed
Analysisanalysis.completed · structured_output.produced
Campaigncampaign.started · campaign.contact_attempted · campaign.completed
Operatoroperator.state_changed · operator.assigned
Billingbilling.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 endpointCe 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

json
{
  "campaignId": "01JQ…",
  "campaignName": "Q1 renewals",
  "contactsTotal": 400,
  "dryRun": false,
  "startedAt": "2026-01-08T14:00:00+00:00"
}

campaign.contact_attempted

json
{
  "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

json
{
  "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:

json
{
  "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
SuccesHTTP 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ărimax_attempts pe endpoint, implicit 6, setabil 1–12
Backoff10s, 30s, 120s, 600s, 1800s, 3600s — determinist, fără jitter, ultima valoare se repetă
Timeouttimeout_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șecuristatus
0healthy
1–4degraded
5–19failing
20disabled — 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.

IdentificatorStabil între retry-uriAcelași pentru două endpoint-uriAcelași la replay
X-CallAgent-Event-Iddadada
X-CallAgent-Delivery-Iddanunu
X-CallAgent-Attemptcreș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-deliveriesFiltrează 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}/redeliver202. 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ă ​

  1. Citește body-ul brut. Nu lăsa un framework să îl parseze înainte să verifici.
  2. Verifică dacă timestamp-ul se încadrează în 300 de secunde, în ambele direcții.
  3. Verifică semnătura, în timp constant, acceptând orice v1= din header.
  4. Returnează 2xx imediat — î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.
  5. Fă dedupe pe X-CallAgent-Event-Id într-un storage durabil, nu în memorie.
  6. Fă treaba asincron, presupunând că evenimentele pot ajunge în altă ordine.
  7. La o eroare nerecuperabilă, returnează tot 2xx și înregistreaz-o — returnarea unui 4xx marchează 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 CallEventType de la GET /calls/{call}/events. Un vocabular diferit de cele 28 de mai sus, cu nume similare în mod deliberat: call.created și tool.started sunt evenimente de apel; call.started și tool.called sunt evenimente de webhook.
  • Webhook endpoints — crearea și gestionarea endpoint-urilor.
  • Webhook deliveries — jurnalul de livrări.

Scrisă după cod. Ce nu este încă implementat este marcat ca atare, niciodată descris ca funcțional.