> ## Documentation Index
> Fetch the complete documentation index at: https://help.callaro.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Post call webhook payload

# 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)

```json theme={null}
{
  "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)

```js theme={null}
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)

```python theme={null}
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.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.