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_jsontool_calls[*].resultanalysis_results[*].raw_responsetrace_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:

  1. Enable signing — generate a fresh secret and configure your handler.
  2. Rotate — generates a new primary; the previous secret stays valid as a secondary. During this window, X-Webhook-Signature-V1 contains comma-separated entries (one per active secret) so verification with either secret succeeds.
  3. Finalize — drops the old secret. Only the new secret signs new requests.
  4. 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.

Did this answer your question? Thanks for the feedback There was a problem submitting your feedback. Please try again later.