Skip to content

Webhooks ​

A webhook is an HTTP POST from CallAgent to a URL you own, signed with HMAC-SHA256, retried on failure, and delivered at least once. This page is the whole contract: a receiver written from it will be correct.

The envelope ​

Six top-level keys, the same for every event type, so you can route on type before you know what data is.

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
  }
}
KeyTypeNotes
idULIDThe event id. Dedupe on this. Identical across retries, across endpoints, and across a replay.
typestringOne of the event types.
api_versionstringThe endpoint's pinned version, currently 2026-01-01. Set on the endpoint row; not settable through the API.
created_atISO 8601When the delivery was enqueued, not when this attempt was sent. Frozen — retries carry the identical string.
organization_idULIDYour organization.
dataobjectPer event type.

The envelope is snake_case; data is not

Envelope keys are snake_case (api_version, created_at, organization_id). The data payloads shipped today use camelCase (campaignId, contactsTotal, nextAttemptAt). Nothing in the pipeline transforms case — whatever the producer wrote is what you receive — so write your receiver against the shapes documented below rather than against a convention.

Headers ​

HeaderValue
Content-Typeapplication/json
X-CallAgent-Signaturev1=<64 hex chars>, comma-separated during rotation
X-CallAgent-TimestampUnix seconds, as a decimal string
X-CallAgent-Event-IdSame ULID as id in the body — the dedupe key
X-CallAgent-Event-TypeSame as type
X-CallAgent-Delivery-IdThis delivery attempt's row; differs per endpoint and per replay
X-CallAgent-Attempt1-based attempt number
X-CallAgent-Api-VersionSame as api_version
User-AgentCallAgent/1.0 (+https://callagent.dev)

Any custom headers configured on the endpoint are applied after these, so a custom header named X-CallAgent-Signature will overwrite the real signature. Do not name a custom header X-CallAgent-*.

Verifying the signature ​

The signed string is the timestamp, a literal ., then the raw request body bytes:

HMAC-SHA256( "{timestamp}.{raw body}", secret )  →  lowercase hex

Four rules, all of which matter:

  1. Verify the timestamp first. Reject anything more than 300 seconds away from now, in either direction. This is what stops a captured request being replayed next week.
  2. Hash the raw bytes. Not a re-serialised object. JSON.stringify(req.body) will not reproduce the bytes we signed and your verification will fail intermittently and inexplicably.
  3. Compare in constant time. A timing oracle on a webhook signature is a real, if slow, forgery path.
  4. Accept the request if any signature in the header matches. During a secret rotation the header carries two, comma-separated, newest first.

The secret is whsec_ + 48 hex characters. It is shown exactly twice: in the 201 from POST /webhooks, and in the response to POST /webhooks/{id}/rotate-secret. It is not in GET /webhooks/{id}, because it is encrypted at rest and we cannot show it to you again.

js
import { createHmac, timingSafeEqual } from 'node:crypto';

const TOLERANCE_SECONDS = 300;

/** `raw` MUST be the raw body Buffer/string, not a parsed object. */
export function verify(raw, headers, secret) {
  const timestamp = Number(headers['x-callagent-timestamp']);
  if (!Number.isFinite(timestamp)) return false;

  // Both directions: a timestamp from the future is as suspect as a stale one.
  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');

  // Rotation sends one `v1=` per active secret; any match is a pass.
  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: keep the raw body.
// 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);           // ack first, work afterwards
// });
php
<?php

const CALLAGENT_TOLERANCE_SECONDS = 300;

/** $raw MUST be file_get_contents('php://input'), not a re-encoded array. */
function callagent_verify(string $raw, array $headers, string $secret): bool
{
    $timestamp = (int) ($headers['x-callagent-timestamp'] ?? 0);

    // Both directions: a timestamp from the future is as suspect as a stale one.
    if ($timestamp === 0 || abs(time() - $timestamp) > CALLAGENT_TOLERANCE_SECONDS) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $raw, $secret);

    // Rotation sends one `v1=` per active secret; any match is a pass.
    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);          // ack first, work afterwards
bash
# Verify a captured delivery by hand. Save the exact bytes first:
#   the body to body.json, and the two headers to shell variables.
TS="$(cat timestamp.txt)"
SECRET="whsec_your_secret_here"

printf '%s.%s' "$TS" "$(cat body.json)" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex \
  | sed 's/^.* //'

# Compare with the v1= value in X-CallAgent-Signature.
# `printf` rather than `echo` — echo appends a newline and the digest will not match.

One signature scheme, two formats

Outbound webhooks always send v1=<hex>. Phone-number hooks — the per-number inbound-routing callback — sign the same string with the same algorithm but send a bare hex digest with no v1= prefix, under the same header name. The verifiers above handle both, which is why they fall back to ['v1', part] for a part with no =. A receiver that splits strictly on v1= will reject every phone-number hook.

Rotating the secret ​

POST /webhooks/{id}/rotate-secret returns a new secret and keeps the old one valid for an overlap window — overlap_hours, 0–168, default 24. During the overlap the signature header carries both, newest first:

http
X-CallAgent-Signature: v1=9f2c…,v1=41ab…

Deploy the new secret, confirm deliveries still verify, and let the old one lapse. An overlap_hours of 0 revokes the old secret immediately, which will break in-flight retries signed with it.

Event types ​

28 events. Every one is subscribable; see the caveat below about which are actually produced today.

GroupEvent
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 returns the same list with a label, group and high_volume flag per event, and is the source to build a subscription UI from.

Only four events are dispatched today

campaign.started, campaign.contact_attempted and campaign.completed are produced by the campaign engine. assistant.request is produced only by POST /webhooks/{id}/test.

The remaining 24 are declared, subscribable and will validate — and nothing in the control plane emits them yet. Subscribing to call.ended today is not an error and will not deliver anything. Build against the four, and treat the rest as a published shape that is not yet a live feed.

High-volume events ​

Four events fire per utterance rather than per call: transcript.partial, speech.started, speech.stopped, call.status_update.

They are opt-in only, and the rule is not what you would guess:

events on the endpointWhat you receive
[] (empty, the default)Everything except the four high-volume events
["*"]Everything, high-volume included
["call.*"]Every call. event, call.status_update included
["call.ended"]Exactly that

So an empty list is not "everything" — it is "everything a busy account can survive". Only * or an explicit name opts you into thousands of deliveries a minute.

Wildcards match one level: the segment before the first dot, plus .*. So structured_output.* matches structured_output.produced. Any name ending in .* or * is accepted without being checked against the catalogue; an unknown exact name is rejected with 422.

An endpoint that names assistant_ids receives only events carrying one of those assistants — and an event with no assistant context is filtered out, not in.

Payloads that ship today ​

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 and nextAttemptAt are nullable.

campaign.completed

json
{
  "campaignId": "01JQ…",
  "campaignName": "Q1 renewals",
  "contactsTotal": 400,
  "contactsCompleted": 388,
  "contactsFailed": 9,
  "contactsSkipped": 3,
  "attemptsTotal": 512,
  "budgetSpent": 41.72
}

assistant.request — sent only by 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…"
}

That test event travels the full signing and delivery path, so it is the correct way to prove a verifier works before you depend on it.

Values are redacted before they are sent ​

Any data key whose lowercased name contains password, secret, token, api_key, authorization, credential, card_number, iban, cvv, pan, ssn and about twenty more has its value replaced with ••••••••. Nesting is walked; null stays null; keys are never renamed.

The match is a substring, so it over-fires: a key called company contains pan and will arrive masked. If you control a payload's keys and a value comes through as bullets, that is why.

Token-counting keys (total_tokens, input_tokens, max_tokens, …) are explicitly exempt.

Delivery, retries and failure ​

MethodPOST
SuccessHTTP 200–299. Nothing else. The response body is ignored.
Retried408, 429, any 5xx, and any transport failure (connect error, timeout, DNS)
Not retriedEvery other 4xx — it will fail identically forever
Attemptsmax_attempts on the endpoint, default 6, settable 1–12
Backoff10s, 30s, 120s, 600s, 1800s, 3600s — deterministic, no jitter, last value repeats
Timeouttimeout_seconds on the endpoint, default 15, settable 1–60; connect timeout is min(5, timeout)

With the default six attempts the schedule is 0s, +10s, +40s, +160s, +760s, +2560s — about 43 minutes from first attempt to exhaustion.

Do not redirect. A 3xx is followed manually, at most 3 hops, and a 301/302/303downgrades to GET and drops the body — so your receiver gets a bodyless GET and the delivery then fails. Publish the final URL.

Your URL is re-resolved on every attempt. If it starts resolving to a private address the delivery is marked blocked and never retried, because a blocked destination is not a transient failure.

Endpoint health ​

consecutive_failures counts failed attempts, not failed deliveries, and resets to zero on any 2xx.

Failuresstatus
0healthy
1–4degraded
5–19failing
20disabled — active is set to false and delivery stops

A disabled endpoint is excluded from fan-out entirely. Re-enable it from the dashboard; setting active: true with PATCH /webhooks/{id} on its own is not currently enough to bring a disabled endpoint back.

Deleting an endpoint does not stop queued deliveries

DELETE /webhooks/{id} soft-deletes the endpoint, but deliveries already queued for it are still attempted. If you need delivery to stop immediately, rotate the secret with overlap_hours: 0 — every in-flight attempt then arrives with a signature your receiver will reject — or leave the endpoint in place and unsubscribe it from every event.

Idempotency and ordering ​

Dedupe on X-CallAgent-Event-Id (identical to id in the body). It is stable across every retry of a delivery, across fan-out to multiple endpoints, and across an operator replay.

IdentifierStable across retriesSame for two endpointsSame on replay
X-CallAgent-Event-Idyesyesyes
X-CallAgent-Delivery-Idyesnono
X-CallAgent-Attemptincrements—resets to 1

Because the event id is shared across endpoints, a receiver serving two endpoints on one URL and deduping on event id alone will silently drop the second copy. If you need per-endpoint accounting, dedupe on the pair (event id, delivery id).

There is no ordering guarantee, and this is not a detail you can ignore. Attempts are independent queue jobs with no per-endpoint serialisation, and a retried event is delayed by its backoff — so a failed call.started will arrive after a successful call.ended. Order your own processing by something in the payload, and make each handler idempotent. created_at (enqueue time) is the only ordering signal available, and there is no sequence number.

Inspecting and replaying ​

GET /webhook-deliveriesFilter by webhook_endpoint_id, event_type, status, call_id. Newest first.
GET /webhook-deliveries/{delivery}Adds attempt_log[] — per attempt: attempt, outcome, response_status, duration_ms, error, resolved_ip, created_at.
POST /webhook-deliveries/{delivery}/redeliver202. New delivery row, same event id and byte-identical payload, redelivery_of_id pointing at the original.

A delivery record carries the exact payload we sent, the request_headers (with the signature masked), your response_headers and the first 8 KB of your response_body — which is usually enough to see why a receiver rejected something.

Delivery status is one of pending, delivering, succeeded, dead, skipped. Attempt outcome is succeeded, http_error, network_error or blocked.

Only a succeeded, failed or dead delivery can be replayed; anything else is a 422. Delivery records are pruned after 30 days.

A correct receiver, end to end ​

  1. Read the raw body. Do not let a framework parse it before you verify.
  2. Verify the timestamp is within 300 seconds, both directions.
  3. Verify the signature, constant-time, accepting any v1= in the header.
  4. Return 2xx immediately — before doing the work. You have 15 seconds, and a slow receiver is retried, which is how you end up processing the same event six times.
  5. Dedupe on X-CallAgent-Event-Id in durable storage, not in memory.
  6. Do the work asynchronously, assuming events can arrive out of order.
  7. On an unrecoverable error, still return 2xx and record it — returning 4xx marks the delivery dead on the first attempt and you will never see that event again.
  • Errors — the error shapes the REST API returns, which are not the webhook envelope.
  • Event types — the ~60 internal CallEventType values on GET /calls/{call}/events. A different vocabulary from the 28 above, with deliberately similar names: call.created and tool.started are call events; call.started and tool.called are webhook events.
  • Webhook endpoints — creating and managing endpoints.
  • Webhook deliveries — the delivery log.

Documented against the code. Anything not yet implemented is labelled, never described as working.