Webhooks
Livrare de evenimente semnată, cu reîncercări și retrimitere. Folosește webhooks în loc de polling pe GET /calls — un apel își produce evenimentele pe măsură ce se întâmplă, iar polling-ul ori le ratează, ori te costă un request pe secundă pe apel.
Creează un endpoint
curl -X POST "$CALLAGENT_API_URL/webhooks" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Sistem de comenzi",
"url": "https://example.com/hooks/callagent",
"events": ["call.ended", "tool.failed", "recording.ready"],
"max_attempts": 6,
"timeout_seconds": 15
}'{
"data": { "id": "01JQ…", "url": "…", "events": [ … ] },
"secret": "whsec_…",
"secret_notice": "Store this signing secret now. It cannot be retrieved again — only rotated."
}Secretul de semnare apare doar în răspunsul la creare și la rotire. Resursa de endpoint nu are niciun câmp pentru el — absent structural, nu filtrat la ieșire.
| Câmp | Observații |
|---|---|
name, url | Obligatorii. url ≤2048 și trebuie să fie un URL valid. |
events[] | Gol înseamnă „tot, în afară de cele de volum mare". |
headers{} | Headere suplimentare la fiecare livrare. |
assistant_ids[] | Restricționează la anumiți asistenți. |
max_attempts | 1–12, implicit 6. |
timeout_seconds | 1–60. |
active, description, metadata |
Evenimente
GET /api/v1/webhooks/events întoarce catalogul viu, cu un grup și un flag high_volume. Abonează-te la ăla, nu la o listă dintr-un document.
La momentul scrierii sunt 28:
| Grup | Tipuri |
|---|---|
| Assistant | assistant.request |
| Apel | call.started, call.ringing, call.answered, call.status_update*, call.ended, call.failed |
| Vorbire | speech.started*, speech.stopped*, transcript.partial*, transcript.final |
| Tools | tool.called, tool.completed, tool.failed |
| Transfer | transfer.requested, transfer.completed, transfer.failed |
| Înregistrare | recording.ready, recording.failed |
| Analiză | analysis.completed, structured_output.produced |
| Campanie | campaign.started, campaign.contact_attempted, campaign.completed |
| Operator | operator.state_changed, operator.assigned |
| Facturare | billing.threshold_reached, billing.balance_low |
* Volum mare — doar cu abonare explicită. Un endpoint cu lista events goală nu le primește pe cele patru. Numește-le explicit dacă le vrei, și dimensionează-ți receptorul: transcript.partial se declanșează de câteva ori pe secundă, pe apel.
Abonamentele acceptă * și wildcard pe prefix: call.* prinde toate cele șase evenimente de apel.
analysis.completed și structured_output.produced nu se declanșează
Pipeline-ul care le-ar produce nu e dispecerizat. Vezi Ce nu este construit.
Ăsta nu e același vocabular cu cele ~70 de tipuri de eveniment de apel interne, care sunt timeline-ul pe care îl citești cu GET /calls/{id}/events. Webhook-urile sunt setul de ieșire, orientat spre client, de mai sus.
Formatul comun
Fiecare livrare are aceeași formă:
{
"id": "01JQ…",
"type": "call.ended",
"api_version": "2026-01-01",
"created_at": "2026-01-01T12:00:00+00:00",
"organization_id": "01JQ…",
"data": { }
}data trece prin redactor înainte să fie stocat sau trimis, deci valorile care arată a credențiale sunt mascate chiar și în rezultatele de tools și în variabilele date de tine.
Headere
Content-Type: application/json
Accept: application/json
X-CallAgent-Signature: v1=<hex>[,v1=<hex>]
X-CallAgent-Timestamp: 1735689600
X-CallAgent-Event-Id: 01JQ…
X-CallAgent-Event-Type: call.ended
X-CallAgent-Delivery-Id: 01JQ…
X-CallAgent-Attempt: 1
X-CallAgent-Api-Version: 2026-01-01Cum verifici o semnătură
Șirul semnat e {timestamp}.{body brut} — nu doar body-ul — deci un request capturat nu poate fi reluat peste o săptămână. Toleranța e de 300 de secunde, în ambele sensuri. Verifică timestamp-ul înainte să compari digest-urile, și compară în timp constant.
Pot apărea mai multe digest-uri v1=. În timpul unei rotiri de secret, ambele secrete semnează fiecare request, deci un receptor care face deploy la mijlocul rotirii nu pierde nimic. Încearcă fiecare secret al tău față de fiecare digest prezentat.
import { createHmac, timingSafeEqual } from 'node:crypto';
function sign(payload: string, timestamp: number, secret: string): string {
return createHmac('sha256', secret).update(`${timestamp}.${payload}`).digest('hex');
}
export function verify(
header: string,
payload: string,
timestamp: number,
secrets: string[],
toleranceSeconds = 300,
): boolean {
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > toleranceSeconds) return false;
const presented = header
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('v1='))
.map((part) => part.slice(3));
return secrets.some((secret) => {
const expected = Buffer.from(sign(payload, timestamp, secret), 'utf8');
return presented.some((candidate) => {
const actual = Buffer.from(candidate, 'utf8');
return actual.length === expected.length && timingSafeEqual(actual, expected);
});
});
}use App\Services\Webhooks\WebhookSigner;
$ok = WebhookSigner::verify(
header: $request->header('X-CallAgent-Signature'),
payload: $request->getContent(), // body-ul brut, nu un array re-encodat
timestamp: (int) $request->header('X-CallAgent-Timestamp'),
secrets: [$currentSecret, $previousSecret], // ce ai la tine
);Semnează body-ul brut. Dacă parsezi JSON-ul și îl re-encodezi, se schimbă ordinea cheilor și spațiile, iar digest-ul nu se mai potrivește. Citește body-ul ca șir întâi.
CLI-ul are același algoritm (apps/cli/src/core/webhook.ts), fixat printr-un test față de un vector generat cu hash_hmac din PHP, deci implementările nu pot devia în tăcere.
Rotirea unui secret
curl -X POST "$CALLAGENT_API_URL/webhooks/$WEBHOOK_ID/rotate-secret" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "overlap_hours": 24 }'overlap_hours e între 0 și 168, implicit 24. Ambele secrete semnează fiecare livrare pe durata suprapunerii. 0 îl revocă imediat pe cel vechi, ceea ce vrei după o scurgere.
Formatul secretului e whsec_ urmat de 48 de caractere hex.
Idempotență — pe ce deduplici
Deduplică pe X-CallAgent-Event-Id, care e id-ul din formatul comun.
E singurul lucru de pe pagina asta care te costă bani dacă îl greșești, deci fii precis cu privire la care id e care:
| Header | Stabil la reîncercări? | Același pentru două endpoint-uri? | Același la retrimitere manuală? |
|---|---|---|---|
X-CallAgent-Event-Id | Da | Da | Da |
X-CallAgent-Delivery-Id | Nu | Nu | Nu |
Livrarea e cel puțin o dată. O să vezi o repetare — după un timeout la care handler-ul tău a reușit de fapt, după un 5xx de la un proxy din fața ta, sau când apasă cineva Retrimite. Un handler de call.ended care taxează un card trebuie să înregistreze id-ul evenimentului în aceeași tranzacție cu taxarea și să sară dacă e deja acolo.
Concret, și în ordinea care contează:
app.post('/hooks/callagent', async (req, res) => {
// 1. Citește body-ul brut înainte să îl atingă vreun middleware de JSON.
const raw = req.rawBody.toString('utf8');
const timestamp = Number(req.get('X-CallAgent-Timestamp'));
// 2. Verifică înainte să parsezi. Un body neverificat e input de la un atacator.
if (!verify(req.get('X-CallAgent-Signature')!, raw, timestamp, secrets)) {
return res.status(400).end();
}
const eventId = req.get('X-CallAgent-Event-Id')!;
// 3. Revendică id-ul evenimentului și fă treaba într-O SINGURĂ tranzacție. Un
// "l-am mai văzut?" separat, înainte de treabă, e o cursă, nu o protecție:
// două reîncercări concurente citesc amândouă "nu" și taxează amândouă cardul.
await db.transaction(async (tx) => {
const claimed = await tx.query(
'INSERT INTO webhook_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING 1',
[eventId],
);
if (claimed.rowCount === 0) return; // deja tratat, și e în regulă
await handle(JSON.parse(raw), tx);
});
// 4. Răspunde 2xx repede. Handlerele lente devin timeout-uri, care devin reîncercări.
res.status(204).end();
});Trei moduri de eșec pe care forma asta le evită, și toate se întâmplă în practică:
- Cursa verifică-apoi-acționează. Două reîncercări sosite la 40 ms distanță trec amândouă de un
SELECT ... WHERE event_id = ?care nu întoarce nimic. Revendică printr-un index unic șiON CONFLICT DO NOTHING, în aceeași tranzacție cu treaba. - Deduplicare pe id-ul greșit.
X-CallAgent-Delivery-Ide diferit la fiecare încercare. Deduplicarea pe el nu deduplică nimic. - Deduplicare între endpoint-uri. Două endpoint-uri abonate la același eveniment primesc același id de eveniment, pentru că e un singur eveniment logic. Dacă amândouă arată spre același handler, al doilea e sărit corect.
Observă că idempotența proprie a CallAgent, la punerea în coadă, e separată de a ta: împiedică punerea de două ori a aceluiași eveniment în coadă pentru același endpoint, cheiată pe (endpoint_id, event_id), în spatele unui index unic. Asta nu îți ajută handler-ul; tot tu trebuie să deduplici.
Reîncercări
| Backoff, pe încercare | 10 s, 30 s, 2 min, 10 min, 30 min, apoi din oră în oră |
| Încercări | max_attempts pe endpoint, implicit 6, interval 1–12 |
| Succes | Orice 2xx |
| Se reîncearcă | 5xx și eșecuri de rețea |
| Nu se reîncearcă | Orice 4xx. Un 410 și un 404 sunt tratate la fel ca un 400: endpoint-ul tău a spus nu. |
| Nu se reîncearcă | Un URL blocat de garda SSRF. O reîncercare către o destinație blocată e blocată din nou. |
Epuizarea încercărilor marchează livrarea ca dead.
Eșecurile consecutive dezactivează un endpoint. La 5, statusul endpoint-ului devine failing; la 20 e dezactivat forțat — active: false, disabled_at setat, disabled_reason: "20 consecutive delivery failures." Un singur succes oriunde resetează contorul la zero.
O comandă programată repune în coadă livrările a căror reîncercare a devenit scadentă și recuperează livrările blocate în delivering de peste o oră — un worker care a crăpat — înapoi în pending.
Jurnalul de livrare
GET /api/v1/webhook-deliveries
GET /api/v1/webhook-deliveries/{id}
POST /api/v1/webhook-deliveries/{id}/redeliverFiltrezi pe webhook_endpoint_id, event_type, status, call_id.
Statusul e pending, delivering, succeeded, dead sau skipped. Fiecare livrare poartă attempts, max_attempts, response_status, response_body (redactat și trunchiat), response_headers (redactate), duration_ms, error, next_attempt_at, delivered_at, failed_at, redelivery_of_id.
Detaliul per încercare e o tabelă separată, doar cu inserări: un rând per încercare HTTP, cu attempt, outcome (succeeded · http_error · network_error · blocked), headerele de request și de răspuns, body-ul, durata, eroarea și IP-ul rezolvat.
Livrările mai vechi de 30 de zile sunt curățate.
Retrimiterea creează un rând nou de livrare cu același event_id și același payload, o cheie de idempotență nouă și attempts resetat. Receptorul tău vede o repetare a unui eveniment pe care poate l-a tratat deja — exact de asta contează deduplicarea de mai sus.
Cum testezi
curl -X POST "$CALLAGENT_API_URL/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"Trimite un eveniment de test semnat, ca să îți verifici verificarea de semnătură cap-coadă înainte ca un apel real să depindă de ea.
Pentru dezvoltare locală:
callagent listen --forward-to http://localhost:3000/hooksPornește un tunel, înregistrează URL-ul, verifică fiecare semnătură și afișează evenimentele. Vezi referința CLI.
API
GET/POST /api/v1/webhooks
GET/PATCH/DELETE /api/v1/webhooks/{id}
GET /api/v1/webhooks/events
POST /api/v1/webhooks/{id}/rotate-secret
POST /api/v1/webhooks/{id}/test
GET /api/v1/webhook-deliveries
GET /api/v1/webhook-deliveries/{id}
POST /api/v1/webhook-deliveries/{id}/redeliverParametri și răspunsuri complete: endpoint-uri de webhook și livrări.