On this page
  1. Quick start
  2. Endpoint requirements
  3. Request headers
  4. Verify the signature
  5. Node.js and Express example
  6. Python and FastAPI example
  7. Delivery behavior
  8. Event envelope
  9. Complete call.ended example
  10. Call fields
  11. Transcript events
  12. User or assistant message
  13. Tool call
  14. Tool result
  15. Agent handoff
  16. Transfer object
  17. Structured data
  18. Test webhooks
  19. Signing-secret rotation
  20. Versioning and forward compatibility
  21. Troubleshooting
  22. Receiver checklist

Post-call webhooks

This guide explains how to receive, authenticate, acknowledge, and process Cartra post-call webhooks.

Quick start#

  1. Expose a public HTTPS POST endpoint.
  2. In the Cartra dashboard, open the agent's Post-call Webhook settings, enter the endpoint URL, and save it.
  3. Copy the generated signing secret when it is displayed. Signing secrets begin with whsec_.
  4. Read the request body as raw bytes and verify the signature before parsing the JSON.
  5. Store or enqueue the event using its webhook ID as an idempotency key.
  6. Return any 2xx response within 10 seconds.
  7. 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 POST requests with Content-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:

HeaderDescription
Content-Typeapplication/json
User-AgentCartra-Post-Call-Webhook/1.0
X-Cartra-Webhook-IdStable UUID for this delivery. Use it for deduplication.
X-Cartra-Webhook-TimestampUnix timestamp in whole seconds, generated for this attempt.
X-Cartra-Webhook-AttemptRecipient delivery attempt, beginning at 1.
X-Cartra-Webhook-SignatureHMAC 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:

text
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:

  1. Read the raw request body.
  2. Read X-Cartra-Webhook-Timestamp and reject malformed timestamps.
  3. Reject timestamps outside its replay-protection window. Five minutes is a reasonable default.
  4. Calculate the expected signature using the full whsec_... secret.
  5. Compare signatures in constant time.
  6. Confirm the JSON body's id equals X-Cartra-Webhook-Id.

Node.js and Express example#

Register the raw-body route before any global JSON parser:

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

python
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 2xx response acknowledges delivery.
  • Network errors, the 10-second timeout, 408, 425, 429, and 5xx responses 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 4xx responses 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:

json
{
  "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": {}
  }
}
FieldDescription
idStable delivery UUID. It matches X-Cartra-Webhook-Id.
typecall.ended for production calls or call.test for synthetic tests.
api_versionPayload contract version. Currently v1.
created_atWhen the immutable webhook payload was created.
data.callCurated call and agent metadata.
data.transcriptOrdered conversation, tool, and handoff events.
data.transferTransfer summary and attempt history.
data.extracted_dataOptional 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.

json
{
  "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#

FieldDescription
idCall-session UUID.
room_nameLiveKit room identifier for the call.
tenant_idCartra tenant UUID.
agent.idAgent configuration UUID.
agent.nameStable agent name.
agent.display_nameHuman-readable agent name.
caller_phoneCaller number when available; otherwise null.
recipient_phoneNumber assigned to the agent for phone calls; otherwise null.
channelCurrently phone or web.
directionUsually inbound; consumers should accept future values.
statusFinal call status, such as completed or abandoned.
started_atCall start time.
ended_atCall finalization time.
duration_secondsRecorded duration in seconds; it can be null.
data_completenessCurrently 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#

json
{
  "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#

json
{
  "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#

json
{
  "role": "tool_result",
  "name": "tool_name",
  "output": "Tool output",
  "is_error": false
}

Agent handoff#

json
{
  "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:

json
{
  "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:

TypeMeaning
warmThe AI agent contacted the destination and attempted an assisted handoff.
coldThe system issued a direct SIP transfer request.
unknownA legacy or unrecognized transfer tool.

Transfer statuses:

StatusMeaning
not_attemptedNo transfer workflow was recorded.
startedThe workflow has no terminal recorded outcome.
completedThe transfer reached its type-specific completion condition.
failedThe destination could not be reached or another ordinary failure occurred.
declinedThe destination declined the transfer.
disconnectedA participant disconnected during the transfer.
abortedThe workflow was stopped by policy or caller action.
simulatedA QA/test route simulated an outcome.
errorA configuration, runtime, or otherwise unclassified error occurred.

Verification distinguishes the guarantees behind completed:

VerificationMeaning
human_confirmedWarm transfer: a destination-side human answered and accepted before the calls were merged.
provider_acceptedCold transfer: the provider accepted the SIP transfer request; a human answer is not verified.
nullThe 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:

StatusMeaning
completedAll applicable scopes were extracted successfully.
partialAt least one applicable scope contains missing, ambiguous, conflicting, or invalid values.
not_applicableNo configured scope applied to this call.
failedExtraction reached a terminal failure.
not_configuredInclusion 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: call or procedure.
  • For a procedure scope, scope.key and its one-based scope.occurrence.
  • status: completed or partial.
  • 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_version before processing.
  • Ignore unknown JSON fields.
  • Accept unknown reason_code values.
  • 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#

SymptomLikely cause
Signature mismatchThe 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 rejectedThe receiver clock is skewed or its replay window is too small.
Repeated deliveriesThe receiver did not return 2xx, timed out, or did not deduplicate by webhook ID.
Delivery stops after one requestThe endpoint returned a non-retryable 4xx.
Delivery stops after three requestsAll retryable attempts were exhausted.
Endpoint receives no requestThe URL is not public HTTPS, redirects, the call has not finalized, or structured-data extraction is still pending.
Test succeeds but no record should existcall.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.test events excluded from production workflows.
  • Previous and current secrets accepted during rotation.
  • Unknown fields and reason codes handled safely.