Post-call webhooks
This guide explains how to receive, authenticate, acknowledge, and process Cartra post-call webhooks.
Quick start#
- Expose a public HTTPS
POSTendpoint. - In the Cartra dashboard, open the agent's Post-call Webhook settings, enter the endpoint URL, and save it.
- Copy the generated signing secret when it is displayed. Signing secrets
begin with
whsec_. - Read the request body as raw bytes and verify the signature before parsing the JSON.
- Store or enqueue the event using its webhook ID as an idempotency key.
- Return any
2xxresponse within 10 seconds. - Use Send test webhook in the Cartra dashboard to verify the integration.
Do not perform slow business processing before acknowledging the webhook.
Durably store or enqueue it first, return 2xx, and process it asynchronously.
Endpoint requirements#
The configured endpoint must:
- Use HTTPS.
- Be publicly reachable and resolve only to public network addresses.
- Not use credentials embedded in the URL.
- Accept
POSTrequests withContent-Type: application/json. - Return within 10 seconds.
Cartra does not follow redirects. Configure the final HTTPS URL directly rather
than returning a 3xx.
Request headers#
Each request includes:
| Header | Description |
|---|---|
Content-Type | application/json |
User-Agent | Cartra-Post-Call-Webhook/1.0 |
X-Cartra-Webhook-Id | Stable UUID for this delivery. Use it for deduplication. |
X-Cartra-Webhook-Timestamp | Unix timestamp in whole seconds, generated for this attempt. |
X-Cartra-Webhook-Attempt | Recipient delivery attempt, beginning at 1. |
X-Cartra-Webhook-Signature | HMAC signature formatted as v1=<hex digest>. |
HTTP header names are case-insensitive.
Verify the signature#
Never trust or process the JSON before verifying its signature.
Cartra calculates:
signed_content = <timestamp> + "." + <raw request body>
signature = "v1=" + hex(HMAC-SHA256(signing_secret, signed_content))Verification must use the exact body bytes received over HTTP. Parsing and then re-serializing the JSON changes the bytes and invalidates the signature.
The receiver should:
- Read the raw request body.
- Read
X-Cartra-Webhook-Timestampand reject malformed timestamps. - Reject timestamps outside its replay-protection window. Five minutes is a reasonable default.
- Calculate the expected signature using the full
whsec_...secret. - Compare signatures in constant time.
- Confirm the JSON body's
idequalsX-Cartra-Webhook-Id.
Node.js and Express example#
Register the raw-body route before any global JSON parser:
import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";
const app = express();
const signingSecret = process.env.CARTRA_WEBHOOK_SECRET!;
const toleranceSeconds = 5 * 60;
function validSignature(
rawBody: Buffer,
timestamp: string,
providedSignature: string,
): boolean {
if (!/^\d+$/.test(timestamp)) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (age > toleranceSeconds) return false;
const expected = createHmac("sha256", signingSecret)
.update(timestamp)
.update(".")
.update(rawBody)
.digest("hex");
const provided = providedSignature.startsWith("v1=")
? providedSignature.slice(3)
: "";
const expectedBytes = Buffer.from(expected, "hex");
const providedBytes = Buffer.from(provided, "hex");
return (
expectedBytes.length === providedBytes.length &&
timingSafeEqual(expectedBytes, providedBytes)
);
}
app.post(
"/webhooks/cartra",
express.raw({ type: "application/json" }),
async (request, response) => {
const rawBody = request.body as Buffer;
const timestamp = request.header("x-cartra-webhook-timestamp") ?? "";
const signature = request.header("x-cartra-webhook-signature") ?? "";
const webhookId = request.header("x-cartra-webhook-id") ?? "";
if (!validSignature(rawBody, timestamp, signature)) {
return response.sendStatus(401);
}
const event = JSON.parse(rawBody.toString("utf8"));
if (event.id !== webhookId) {
return response.sendStatus(400);
}
// Insert webhookId and rawBody into durable storage or a queue.
// Enforce a unique constraint on webhookId and treat duplicates as success.
await durablyAcceptWebhook(webhookId, event);
return response.sendStatus(204);
},
);Python and FastAPI example#
import hashlib
import hmac
import json
import os
import time
from fastapi import FastAPI, HTTPException, Request, Response
app = FastAPI()
signing_secret = os.environ["CARTRA_WEBHOOK_SECRET"].encode()
tolerance_seconds = 5 * 60
@app.post("/webhooks/cartra")
async def receive_cartra_webhook(request: Request) -> Response:
raw_body = await request.body()
timestamp = request.headers.get("x-cartra-webhook-timestamp", "")
provided_signature = request.headers.get("x-cartra-webhook-signature", "")
webhook_id = request.headers.get("x-cartra-webhook-id", "")
if not timestamp.isdigit():
raise HTTPException(status_code=401)
if abs(int(time.time()) - int(timestamp)) > tolerance_seconds:
raise HTTPException(status_code=401)
signed_content = timestamp.encode() + b"." + raw_body
expected_signature = "v1=" + hmac.new(
signing_secret,
signed_content,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, provided_signature):
raise HTTPException(status_code=401)
event = json.loads(raw_body)
if event.get("id") != webhook_id:
raise HTTPException(status_code=400)
# Persist or enqueue using webhook_id as a unique idempotency key.
await durably_accept_webhook(webhook_id, event)
return Response(status_code=204)Delivery behavior#
Production webhooks use at-least-once delivery. The same event can therefore be received more than once.
- Any
2xxresponse acknowledges delivery. - Network errors, the 10-second timeout,
408,425,429, and5xxresponses are retryable. - The second attempt occurs approximately one minute after the first.
- The third and final attempt occurs approximately five minutes after the second.
- Other
4xxresponses fail immediately and are not retried.
The JSON payload and webhook ID remain unchanged across attempts. The attempt
number, timestamp, and signature headers are regenerated for each attempt.
Always acknowledge duplicate IDs with 2xx.
Webhook delivery is post-call processing, not a real-time call event. It begins after the call is finalized. When structured-data inclusion is enabled, delivery waits for extraction to reach a terminal state. Following a successful warm transfer, the destination-side human and original caller can continue talking after the AI agent leaves; the webhook is produced after that remaining call ends.
Event envelope#
The JSON body has this top-level structure:
{
"id": "5e938027-cc8d-4cb8-86b8-78b390be44c6",
"type": "call.ended",
"api_version": "v1",
"created_at": "2026-07-24T18:25:04.123Z",
"data": {
"call": {},
"transcript": [],
"transfer": {},
"extracted_data": {}
}
}| Field | Description |
|---|---|
id | Stable delivery UUID. It matches X-Cartra-Webhook-Id. |
type | call.ended for production calls or call.test for synthetic tests. |
api_version | Payload contract version. Currently v1. |
created_at | When the immutable webhook payload was created. |
data.call | Curated call and agent metadata. |
data.transcript | Ordered conversation, tool, and handoff events. |
data.transfer | Transfer summary and attempt history. |
data.extracted_data | Optional terminal structured-data results. |
data.extracted_data is omitted when the agent has not enabled structured-data
inclusion.
Complete call.ended example#
The shape of extracted_data.scopes[].data is configured per agent. The example
below is illustrative.
{
"id": "5e938027-cc8d-4cb8-86b8-78b390be44c6",
"type": "call.ended",
"api_version": "v1",
"created_at": "2026-07-24T18:25:04.123Z",
"data": {
"call": {
"id": "93ea83ec-5ec7-4031-8597-4641f8898ee2",
"room_name": "call-93ea83ec",
"tenant_id": "1e52c246-a8aa-4655-89c8-89e037f86c57",
"agent": {
"id": "82ea1090-0649-426c-ac99-c6a07ab778e8",
"name": "customer-service-agent",
"display_name": "Customer Service"
},
"caller_phone": "+15555550100",
"recipient_phone": "+15555550101",
"channel": "phone",
"direction": "inbound",
"status": "completed",
"started_at": "2026-07-24T18:20:00.000Z",
"ended_at": "2026-07-24T18:25:00.000Z",
"duration_seconds": 300,
"data_completeness": "full"
},
"transcript": [
{
"role": "user",
"content": "Please remove Alex Smith from my policy on August 1.",
"interrupted": false,
"created_at": 1784917210.25
},
{
"role": "assistant",
"content": "I can help with that. What is the reason for the removal?",
"interrupted": false,
"created_at": 1784917212.5
},
{
"role": "user",
"content": "They moved out of the household.",
"interrupted": false,
"created_at": 1784917218.75
},
{
"role": "tool_call",
"name": "warm_transfer_to_human",
"arguments": "{\"destination\":\"service\"}",
"call_id": "call_transfer_1"
},
{
"role": "tool_result",
"name": "warm_transfer_to_human",
"output": "warm_transfer=completed; destination=Service team (+15555550123); reason=service",
"is_error": false
}
],
"transfer": {
"attempted": true,
"type": "warm",
"status": "completed",
"reason_code": "transfer_completed",
"verification": "human_confirmed",
"destination": {
"key": "service",
"label": "Service team",
"address": "+15555550123"
},
"attempts": [
{
"number": 1,
"type": "warm",
"status": "completed",
"reason_code": "transfer_completed",
"verification": "human_confirmed",
"destination": {
"key": "service",
"label": "Service team",
"address": "+15555550123"
},
"started_at": "2026-07-24T18:23:10.000Z",
"ended_at": "2026-07-24T18:23:31.000Z"
}
]
},
"extracted_data": {
"status": "completed",
"scopes": [
{
"scope": {
"kind": "procedure",
"key": "remove_drivers",
"occurrence": 1
},
"status": "completed",
"data": {
"drivers_to_remove": [
{
"driver_full_name": "Alex Smith",
"desired_effective_date": "2026-08-01",
"removal_reason": "Moved out of the household"
}
]
},
"diagnostics": [
{
"path": "/drivers_to_remove/0/driver_full_name",
"status": "extracted",
"evidence_message_ids": [0]
},
{
"path": "/drivers_to_remove/0/desired_effective_date",
"status": "extracted",
"evidence_message_ids": [0]
},
{
"path": "/drivers_to_remove/0/removal_reason",
"status": "extracted",
"evidence_message_ids": [2]
}
]
}
]
}
}
}Call fields#
| Field | Description |
|---|---|
id | Call-session UUID. |
room_name | LiveKit room identifier for the call. |
tenant_id | Cartra tenant UUID. |
agent.id | Agent configuration UUID. |
agent.name | Stable agent name. |
agent.display_name | Human-readable agent name. |
caller_phone | Caller number when available; otherwise null. |
recipient_phone | Number assigned to the agent for phone calls; otherwise null. |
channel | Currently phone or web. |
direction | Usually inbound; consumers should accept future values. |
status | Final call status, such as completed or abandoned. |
started_at | Call start time. |
ended_at | Call finalization time. |
duration_seconds | Recorded duration in seconds; it can be null. |
data_completeness | Currently full for webhook-eligible calls. |
Phone values use E.164 where available, but consumers should store them as strings rather than numbers.
Transcript events#
data.transcript is ordered and can contain:
User or assistant message#
{
"role": "user",
"content": "Message text",
"interrupted": false,
"created_at": 1784917210.25
}created_at is a Unix timestamp in seconds and can be absent on legacy events.
Tool call#
{
"role": "tool_call",
"name": "tool_name",
"arguments": "{\"example\":\"value\"}",
"call_id": "optional-call-id"
}arguments is a string and can itself contain JSON. Parse it separately only
when the receiving application understands that tool's contract.
Tool result#
{
"role": "tool_result",
"name": "tool_name",
"output": "Tool output",
"is_error": false
}Agent handoff#
{
"role": "agent_handoff",
"old_agent_id": "old-agent",
"new_agent_id": "new-agent"
}Consumers should accept new transcript event roles and additional fields.
Transfer object#
data.transfer is present on all new production and test payloads. A delivery
created before transfer-object rollout can omit it, so receivers should still
handle a missing value defensively.
When no transfer workflow was invoked:
{
"attempted": false,
"type": null,
"status": "not_attempted",
"reason_code": null,
"verification": null,
"destination": null,
"attempts": []
}attempted: true means a transfer workflow was invoked. It does not guarantee
that dialing began or succeeded.
Transfer types:
| Type | Meaning |
|---|---|
warm | The AI agent contacted the destination and attempted an assisted handoff. |
cold | The system issued a direct SIP transfer request. |
unknown | A legacy or unrecognized transfer tool. |
Transfer statuses:
| Status | Meaning |
|---|---|
not_attempted | No transfer workflow was recorded. |
started | The workflow has no terminal recorded outcome. |
completed | The transfer reached its type-specific completion condition. |
failed | The destination could not be reached or another ordinary failure occurred. |
declined | The destination declined the transfer. |
disconnected | A participant disconnected during the transfer. |
aborted | The workflow was stopped by policy or caller action. |
simulated | A QA/test route simulated an outcome. |
error | A configuration, runtime, or otherwise unclassified error occurred. |
Verification distinguishes the guarantees behind completed:
| Verification | Meaning |
|---|---|
human_confirmed | Warm transfer: a destination-side human answered and accepted before the calls were merged. |
provider_accepted | Cold transfer: the provider accepted the SIP transfer request; a human answer is not verified. |
null | The transfer was not completed or its verification level is unknown. |
reason_code is machine-readable. Store unknown codes and use a fallback
display rather than rejecting the webhook. Raw provider errors and internal
diagnostics are intentionally excluded.
attempts is an ordered list of logical attempts. Its timestamps are ISO-8601
UTC strings and can be null for legacy or incomplete lifecycle records.
Destination addresses contain phone or SIP routing data and should be handled
as sensitive information.
Structured data#
data.extracted_data is optional. When present, its status is:
| Status | Meaning |
|---|---|
completed | All applicable scopes were extracted successfully. |
partial | At least one applicable scope contains missing, ambiguous, conflicting, or invalid values. |
not_applicable | No configured scope applied to this call. |
failed | Extraction reached a terminal failure. |
not_configured | Inclusion was enabled but no extraction job existed for the call. |
Only completed and partial contain scope results. Other statuses have an
empty scopes array. A partial extraction is valid webhook data, not a webhook
delivery failure.
Each scope contains:
scope.kind:callorprocedure.- For a procedure scope,
scope.keyand its one-basedscope.occurrence. status:completedorpartial.data: arbitrary JSON matching that agent's configured schema.diagnostics: per-value extraction diagnostics.
Diagnostic paths are RFC 6901 JSON Pointers relative to the scope's data
object. Array items use numeric indexes, such as /drivers/0/name.
Diagnostic statuses are extracted, not_mentioned, ambiguous,
conflicting, or invalid. evidence_message_ids identifies supporting
conversation messages.
Evidence IDs are not raw indexes into data.transcript. To reproduce them,
iterate through the transcript in order, keep only entries whose role is
user or assistant, and assign IDs beginning at zero. Tool and handoff events
do not increment the evidence ID.
Test webhooks#
The dashboard's Send test webhook action sends a signed call.test event
immediately:
- Call IDs and values are clearly synthetic.
- It always includes a representative completed warm transfer.
- It includes synthetic structured data only when structured-data inclusion is enabled.
- It uses attempt header
1. - It is one immediate request and does not use the production retry queue.
Verify and acknowledge test events exactly like production events, but do not create production call records from them.
Signing-secret rotation#
Treat signing secrets as credentials:
- Store them in a secret manager.
- Never put them in source control or logs.
- Rotate them if exposed.
Production deliveries freeze the signing secret that was active when the call ended. During rotation, an already-created delivery can therefore arrive using the previous secret. To avoid rejecting in-flight events, support both the old and new secrets for an agreed transition period, attempting verification against each without logging either value.
Versioning and forward compatibility#
- Branch on
api_versionbefore processing. - Ignore unknown JSON fields.
- Accept unknown
reason_codevalues. - Accept new transcript roles and call-field values.
- Do not hardcode the shape of structured
data; it is agent-specific. - Treat optional and legacy fields defensively.
Additive fields can be introduced within v1. A breaking contract change will
use a new API version.
Troubleshooting#
| Symptom | Likely cause |
|---|---|
| Signature mismatch | The body was parsed or modified before verification, the wrong secret was used, or a rotated old secret is still signing an in-flight delivery. |
| Timestamp rejected | The receiver clock is skewed or its replay window is too small. |
| Repeated deliveries | The receiver did not return 2xx, timed out, or did not deduplicate by webhook ID. |
| Delivery stops after one request | The endpoint returned a non-retryable 4xx. |
| Delivery stops after three requests | All retryable attempts were exhausted. |
| Endpoint receives no request | The URL is not public HTTPS, redirects, the call has not finalized, or structured-data extraction is still pending. |
| Test succeeds but no record should exist | call.test is synthetic and should not be stored as a production call. |
Receiver checklist#
- Public HTTPS endpoint configured without redirects.
- Raw request body retained for signature verification.
- Timestamp age checked.
- Signature compared in constant time.
- Body ID checked against
X-Cartra-Webhook-Id. - Unique constraint or equivalent deduplication on the webhook ID.
- Durable queue or storage before returning
2xx. - Response completes in less than 10 seconds.
-
call.testevents excluded from production workflows. - Previous and current secrets accepted during rotation.
- Unknown fields and reason codes handled safely.