Post-call webhook reference
When a call ends, Callaro fires a single post-call webhook to the URL you configure on the agent (and any custom webhooks you've added). The webhook fires after intelligent-prompt analysis completes, so the payload includes analysis results — not just the transcript and metadata.
This page is the source of truth for:
- The payload shape you receive
- How to verify the HMAC v1 signature
- Idempotency, retries, and the manual-retry button
- Webhook secret rotation
Delivery guarantees
- At-least-once. We retry transient failures up to 3 times automatically with exponential backoff. A failsafe sweeper job picks up sessions that somehow miss the dispatch path within the first 30 minutes.
- Manual retry. From any Call detail → Webhooks tab, an operator can press Retry (up to 10 manual retries per webhook target, rate-limited to one retry per minute).
- Ordered after analysis. When analysis prompts are attached, the webhook fires after analysis finishes. If analysis fails, the webhook still fires (with
analysis_status: "failed"or"partial") — analysis is never a blocker.
Payload (schema_version: 2026-04-28)
{
"event": "call.completed",
"event_id": "f1b6cf7c-…",
"idempotency_key": "call.completed.123456.primary",
"attempt_number": 1,
"is_retry": false,
"is_test": false,
"schema_version": "2026-04-28",
"tenant_id": 42,
"agent_id": 17,
"voice_session_id": 123456,
"call_status": "completed",
"call_outcome": "answered",
"answered_by": "human",
"voicemail_detected": false,
"amd_category": null,
"hangup_reason": "user_hangup",
"started_at": "2026-04-28T20:34:11Z",
"ended_at": "2026-04-28T20:36:53Z",
"duration_seconds": 162,
"transcript_json": [ /* turns */ ],
"extracted_data": { "name": "…", "email": "…" },
"tool_calls": [ /* tool name + args + result */ ],
"analysis_results": [
{
"prompt_name": "Sentiment",
"status": "completed",
"result": { "label": "positive", "confidence": 0.91 },
"completed_at": "2026-04-28T20:37:02Z"
}
],
"analysis_status": "completed",
"payload_truncated": false,
"truncated_fields": []
}
Idempotency
Always dedupe on idempotency_key. Within a session, the same key is reused for retries (manual or automatic). Treat the receipt of a key you've already processed as a no-op. Use attempt_number and is_retry purely for observability — they are not sufficient for dedup on their own.
Payload size cap (1 MB)
We cap each delivery at ~1 MB. When over budget, fields are dropped in this priority order: transcript_json → tool_calls[*].result → analysis_results[*].raw_response → trace_summary. The fields dropped are listed in truncated_fields and payload_truncated is set to true.
To fetch the full transcript when truncated, GET /api/v1/voice_sessions/{voice_session_id}/transcript.
Verifying the signature (HMAC v1)
Signing is optional. If you have not configured a secret, no signature headers are sent and the request goes out unsigned. When a secret is configured we send the following headers:
| Header | Format | Notes |
|---|---|---|
X-Webhook-Timestamp |
Unix epoch seconds | Reject requests older than 5 minutes. |
X-Webhook-Signature-V1 |
v1=<hex> (comma-separated for rotation) |
HMAC-SHA256 of "<timestamp>.<body>". |
X-Webhook-Signature |
<hex> (legacy unversioned) |
HMAC-SHA256 of body only. Sent during rollout overlap so existing handlers keep working unchanged. |
Use X-Webhook-Signature-V1 for new integrations. The legacy header is kept identical to its pre-v1 shape so old handlers do not break when v1 is rolled out.
Verification example (Node)
import crypto from "crypto";
function verifyWebhook(req, secret) {
const ts = req.header("x-webhook-timestamp");
const sigHeader = req.header("x-webhook-signature-v1");
if (!ts || !sigHeader) return false;
// Reject replays
const ageSec = Math.abs(Date.now() / 1000 - Number(ts));
if (ageSec > 5 * 60) return false;
// Compute expected
const signed = `${ts}.${req.rawBody}`;
const expected = crypto.createHmac("sha256", secret).update(signed).digest("hex");
// Header may contain multiple comma-separated v1=<hex> entries during rotation.
const candidates = sigHeader.split(",").map(s => s.trim().replace(/^v1=/, ""));
return candidates.some(hex =>
hex.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(hex, "hex"), Buffer.from(expected, "hex"))
);
}
Verification example (Python)
import hmac, hashlib, time
def verify(headers, body, secret):
ts = headers.get("X-Webhook-Timestamp")
sig_header = headers.get("X-Webhook-Signature-V1")
if not ts or not sig_header:
return False
if abs(time.time() - int(ts)) > 300:
return False
expected = hmac.new(secret.encode(), f"{ts}.{body}".encode(), hashlib.sha256).hexdigest()
candidates = [s.strip().removeprefix("v1=") for s in sig_header.split(",")]
return any(hmac.compare_digest(c, expected) for c in candidates)
Rotating the secret
Use the Webhook signing section under Step 7 of the agent wizard:
- Enable signing — generate a fresh secret and configure your handler.
- Rotate — generates a new primary; the previous secret stays valid as a secondary. During this window,
X-Webhook-Signature-V1contains comma-separated entries (one per active secret) so verification with either secret succeeds. - Finalize — drops the old secret. Only the new secret signs new requests.
- Disable — removes all secrets; webhooks ship unsigned.
Secret rotation takes effect immediately for in-flight deliveries; you do not need to re-publish the agent.
Test webhook
The agent settings include a Test webhook button. It sends a synthetic payload (is_test: true, voice_session_id: -1, idempotency_key: call.completed.test.primary) to your configured URL using the live signing secret. Use this to validate verification code before going live.