Webhooks
Signed, retried, replayable event delivery. Use webhooks instead of polling GET /calls — a call produces its events as it happens, and polling either misses them or costs you a request per second per call.
Create an endpoint
curl -X POST "$CALLAGENT_API_URL/webhooks" \
-H "Authorization: Bearer $CALLAGENT_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order system",
"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."
}The signing secret appears only in the response to create and to rotate. The endpoint resource has no field for it at all — structurally absent, not filtered out.
| Field | Notes |
|---|---|
name, url | Required. url ≤2048 and must be a valid URL. |
events[] | Empty means "everything except the high-volume ones". |
headers{} | Extra headers on every delivery. |
assistant_ids[] | Restrict to specific assistants. |
max_attempts | 1–12, default 6. |
timeout_seconds | 1–60. |
active, description, metadata |
Events
GET /api/v1/webhooks/events returns the live catalogue with a group and a high_volume flag. Subscribe to that rather than to a list in a document.
At the time of writing there are 28:
| Group | Types |
|---|---|
| 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 |
* High volume — opt-in only. An endpoint with an empty events list does not receive these four. Name them explicitly if you want them, and size your receiver accordingly: transcript.partial fires several times a second per call.
Subscriptions match * and prefix wildcards: call.* gets all six call events.
analysis.completed and structured_output.produced do not fire
The pipeline that would produce them is not dispatched. See What is not built.
This is not the same vocabulary as the ~60 internal call event types, which are the timeline you read with GET /calls/{id}/events. Webhooks are the outbound customer-facing set above.
The envelope
Every delivery has the same shape:
{
"id": "01JQ…",
"type": "call.ended",
"api_version": "2026-01-01",
"created_at": "2026-01-01T12:00:00+00:00",
"organization_id": "01JQ…",
"data": { }
}data passes through the redactor before it is stored or sent, so credential-shaped values are masked even inside tool results and variables you supplied.
Headers
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-01Verifying a signature
The signed string is {timestamp}.{raw body} — not the body alone — so a captured request cannot be replayed a week later. The tolerance is 300 seconds in either direction. Check the timestamp before comparing digests, and compare in constant time.
More than one v1= digest can appear. During a secret rotation both secrets sign every request, so a receiver redeploying mid-rotation loses nothing. Try each of your secrets against each presented digest.
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(), // the raw body, not a re-encoded array
timestamp: (int) $request->header('X-CallAgent-Timestamp'),
secrets: [$currentSecret, $previousSecret], // whatever you have on file
);Sign the raw body. Parsing the JSON and re-encoding it changes key order and whitespace, and the digest will not match. Read the body as a string first.
The CLI ships the same algorithm (apps/cli/src/core/webhook.ts), pinned by a test against a vector generated with PHP's hash_hmac, so the implementations cannot drift apart quietly.
Rotating a 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 is 0–168, default 24. Both secrets sign every delivery during the overlap. 0 revokes the old one immediately, which is what you want after a leak.
The secret format is whsec_ followed by 48 hex characters.
Idempotency — what to dedupe on
Dedupe on X-CallAgent-Event-Id, which is the envelope's id.
This is the one thing on this page that will cost you money if you get it wrong, so be precise about which id is which:
| Header | Stable across retries? | Same for two endpoints? | Same on manual redelivery? |
|---|---|---|---|
X-CallAgent-Event-Id | Yes | Yes | Yes |
X-CallAgent-Delivery-Id | No | No | No |
Delivery is at-least-once. You will see a repeat — after a timeout where your handler in fact succeeded, after a 5xx from a proxy in front of you, or when somebody clicks Redeliver. A call.ended handler that charges a card must record the event id inside the same transaction as the charge, and skip when it is already there.
Concretely, and in the order that matters:
app.post('/hooks/callagent', async (req, res) => {
// 1. Read the raw body before any JSON middleware touches it.
const raw = req.rawBody.toString('utf8');
const timestamp = Number(req.get('X-CallAgent-Timestamp'));
// 2. Verify before you parse. An unverified body is attacker input.
if (!verify(req.get('X-CallAgent-Signature')!, raw, timestamp, secrets)) {
return res.status(400).end();
}
const eventId = req.get('X-CallAgent-Event-Id')!;
// 3. Claim the event id and do the work in ONE transaction. A separate
// "have I seen it?" read before the work is a race, not a guard: two
// concurrent retries both read "no" and both charge the card.
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; // already handled, and that is fine
await handle(JSON.parse(raw), tx);
});
// 4. Answer 2xx quickly. Slow handlers become timeouts become retries.
res.status(204).end();
});Three failure modes this shape avoids, all of which happen in practice:
- The check-then-act race. Two retries arriving 40 ms apart both pass a
SELECT ... WHERE event_id = ?that returns nothing. Claim with a unique index andON CONFLICT DO NOTHING, in the same transaction as the work. - Deduping on the wrong id.
X-CallAgent-Delivery-Idis different for every attempt. Deduping on it deduplicates nothing. - Deduping across endpoints. Two endpoints subscribed to the same event get the same event id, because it is one logical event. If both point at the same handler, the second is correctly skipped.
Note that CallAgent's own enqueue-side idempotency is separate from yours: it prevents the same event being queued twice for the same endpoint, keyed on (endpoint_id, event_id) behind a unique index. That does not help your handler; you still have to dedupe.
Retries
| Backoff by attempt | 10 s, 30 s, 2 min, 10 min, 30 min, then 1 h repeating |
| Attempts | max_attempts on the endpoint, default 6, range 1–12 |
| Success | Any 2xx |
| Retried | 5xx and network failures |
| Not retried | Any 4xx. A 410 and a 404 are treated the same as a 400: your endpoint said no. |
| Not retried | An SSRF-blocked URL. Retrying a blocked destination just blocks again. |
Exhausting the attempts marks the delivery dead.
Consecutive failures disable an endpoint. At 5 the endpoint's status becomes failing; at 20 it is force-disabled — active: false, disabled_at set, disabled_reason: "20 consecutive delivery failures." A single success anywhere resets the counter to zero.
A scheduled command re-queues deliveries whose retry is due, and reclaims deliveries stuck in delivering for over an hour — a crashed worker — back to pending.
The delivery log
GET /api/v1/webhook-deliveries
GET /api/v1/webhook-deliveries/{id}
POST /api/v1/webhook-deliveries/{id}/redeliverFilter on webhook_endpoint_id, event_type, status, call_id.
Status is pending, delivering, succeeded, dead or skipped. Each delivery carries attempts, max_attempts, response_status, response_body (redacted and truncated), response_headers (redacted), duration_ms, error, next_attempt_at, delivered_at, failed_at, redelivery_of_id.
Per-attempt detail is a separate insert-only table: one row per HTTP try, with attempt, outcome (succeeded · http_error · network_error · blocked), request and response headers, body, duration, error and the resolved IP.
Deliveries older than 30 days are pruned.
Redelivery creates a new delivery row with the same event_id and the same payload, a fresh idempotency key, and attempts reset. Your receiver sees a repeat of an event it may already have handled — which is exactly why the dedupe above matters.
Testing
curl -X POST "$CALLAGENT_API_URL/webhooks/$WEBHOOK_ID/test" \
-H "Authorization: Bearer $CALLAGENT_API_KEY"Sends a signed test event, so you can verify your signature check end to end before a real call depends on it.
For local development:
callagent listen --forward-to http://localhost:3000/hooksStarts a tunnel, registers the URL, verifies every signature and prints the events. See the CLI reference.
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}/redeliverFull parameters and responses: webhook endpoints and deliveries.