# Guide to Cartesia Webhooks: Features and Best Practices

Cartesia webhooks report what happens on calls handled by Line, Cartesia's voice agent platform: a call starts, each turn of the conversation, the call ends or fails, and a post-call summary is ready. If you run voice agents on Cartesia, webhooks are how transcripts, outcomes and summaries reach your CRM, analytics and follow-up workflows.

This guide covers how Cartesia webhooks are created and attached to agents, how to authenticate them, the five events and their envelope, Cartesia's retry rules, and the best practices for running a receiver in production.

## What are Cartesia webhooks?

A Cartesia webhook is a URL and a secret. You create it once and attach it to one or more agents, and attaching it turns on delivery for every call those agents handle. Cartesia then sends an HTTPS `POST` with a JSON body at each point in the call lifecycle.

The body is not signed. Cartesia sends the secret you set, unchanged, in an `x-webhook-secret` header on every delivery, and your endpoint checks that it matches its stored copy. Cartesia's own examples do exactly that: compare the header with the secret and return `401` if it differs.

## Cartesia webhook features

| Feature | Details |
| --- | --- |
| Configuration | The agent's **Webhook** settings in the Playground, or the Agents API |
| Attachment | A webhook is attached to agents through the agent's `webhook_id`. One webhook can serve several agents |
| Event selection | None documented. An attached webhook receives every event type |
| Authentication | `x-webhook-secret: <secret>`, sent verbatim |
| Secret | Required when you create the webhook. Omitted from API responses |
| Body signature | None |
| Envelope | `type`, `call_id`, `agent_id`, `webhook_id`, `webhook_request_id`, `timestamp`, plus one typed field |
| Deduplication key | `webhook_request_id`, stable across retries of one event |
| Retries | Up to 3 attempts on network errors and `403`, `408`, `429` or any `5xx` |
| Permanent failures | Other `4xx` responses, such as `400`, `401` and `404` |
| Timeout | Not documented |
| IP allowlist | None published |

## Common events

| Event | Sent when | Typed field |
| --- | --- | --- |
| `call_started` | A call session begins | `call` |
| `call_turn` | Each conversational turn, from the agent or the user | `turn` |
| `call_completed` | A call ends normally | `call` |
| `call_failed` | A call ends with an error | `call` |
| `post_call_analysis` | Asynchronous analysis finishes (currently the summary) | `analysis` |

The `call` object matches the response of Cartesia's Get Call endpoint: `id`, `agent_id`, `agent_name`, `status`, `start_time`, `end_time`, `end_reason`, `transcript`, `telephony_params`, `deployment_region`, `dynamic_variables`, `summary` and, on failed calls, `error_message`. Fields that do not have a value yet, such as `end_time` on `call_started`, may be missing.

Here is Cartesia's `call_completed` example, trimmed:

```json
{
  "type": "call_completed",
  "call_id": "ac_sid_gqkgRWUz2u64qFUjA1mZyr",
  "agent_id": "agent_rwh4HGMgyhK7rM5ucVqbiC",
  "webhook_id": "agent_webhook_P3MgdLf1cpaucZJ7xWehCC",
  "webhook_request_id": "webhook_req_8h2NcVqbiCP3MgdLf1cpa",
  "end_reason": "client_hangup",
  "timestamp": "2026-04-16T01:08:50.061907836Z",
  "call": {
    "id": "ac_sid_gqkgRWUz2u64qFUjA1mZyr",
    "agent_name": "My Agent",
    "status": "completed",
    "start_time": "2026-04-16T01:08:37.413659Z",
    "end_time": "2026-04-16T01:08:50.036327Z",
    "end_reason": "client_hangup",
    "transcript": [
      { "role": "assistant", "text": "Hi there! How can I help you today?", "start_timestamp": 0.41, "end_timestamp": 3.2 },
      { "role": "user", "text": "I want to schedule an appointment.", "start_timestamp": 3.5, "end_timestamp": 5.8 }
    ]
  }
}

```

The top-level `end_reason` (and a top-level `body` on some events) are deprecated fields Cartesia still sends for backwards compatibility. Read `call.end_reason` instead.

`call_turn` events carry a `turn` with `id`, `role` (`assistant` or `user`), `text`, start and end times in seconds from the start of the call, `was_interrupted`, time-to-first-byte figures and any `tool_calls`. `post_call_analysis` carries an `analysis.summary`, and it is not sent when nobody spoke on the call.

## Setting up Cartesia webhooks

Generate a long random secret first, for example with `openssl rand -hex 32`, and store it server-side as `CARTESIA_WEBHOOK_SECRET`. Cartesia leaves the secret out of every API response, so your copy is the only one you can read back.

In the Playground, open your agent's **Webhook** settings and enter an `https` URL and the secret.

With the API, create the webhook and attach it to an agent:

```bash
curl -X POST "https://api.cartesia.ai/agents/webhooks" \
  -H "X-API-Key: $CARTESIA_API_KEY" \
  -H "Cartesia-Version: 2026-03-01" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/webhooks/cartesia", "secret": "'"$CARTESIA_WEBHOOK_SECRET"'"}'

curl -X PATCH "https://api.cartesia.ai/agents/{agent_id}" \
  -H "X-API-Key: $CARTESIA_API_KEY" \
  -H "Cartesia-Version: 2026-03-01" \
  -H "Content-Type: application/json" \
  -d '{"webhook_id": "agent_webhook_..."}'

```

If you receive Cartesia webhooks through Hookdeck, enter the same secret in the Hookdeck source.

## Securing Cartesia webhooks

Read `x-webhook-secret`, compare it with your stored secret in constant time, and reject the request if it is missing or different. Fail closed if the secret is not configured.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyCartesiaSecret(headerValue, secret) {
  if (typeof headerValue !== 'string' || !secret) return false;
  const received = Buffer.from(headerValue, 'utf8');
  const expected = Buffer.from(secret, 'utf8');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/cartesia', express.raw({ type: '*/*' }), (req, res) => {
  if (!verifyCartesiaSecret(req.get('x-webhook-secret'), process.env.CARTESIA_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.webhook_request_id, route on event.type, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hmac
import os

def verify_cartesia_secret(header_value, secret) -> bool:
    if not header_value or not secret:
        return False
    return hmac.compare_digest(header_value.encode("utf-8"), secret.encode("utf-8"))

# verify_cartesia_secret(request.headers.get("x-webhook-secret"), os.environ["CARTESIA_WEBHOOK_SECRET"])

```

Cartesia's documented examples compare with `!=` and `!==`. A constant-time comparison, as above, avoids leaking information about the secret through response timing.

> **Make Cartesia webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) checks the `x-webhook-secret` header, stores every call, turn and summary event, and retries delivery on your schedule rather than Cartesia's three attempts.

## Cartesia webhook limitations and pain points

### Three attempts, and most 4xx responses are final

**The Problem:** Call records and transcripts go missing after a short outage or a configuration slip.

**Why It Happens:** Cartesia tries a delivery up to 3 times, and only for network errors and `403`, `408`, `429` or `5xx` responses. A `400`, `401` or `404` is treated as permanent, so a rotated secret or a broken route loses every delivery on the first attempt.

**Workarounds:**

* Return `200` as soon as the secret matches, and process afterwards.
* Alert on `401` and `404` responses from your webhook route.
* Recover missed calls from Cartesia's Get Call API.

**How Hookdeck Can Help:** Hookdeck accepts each delivery as soon as it arrives and stores it, then delivers to your service with [automatic retries](/docs/retries) and replay, so a short outage on your side does not count against Cartesia's three attempts.

### Every event, whether you want it or not

**The Problem:** A service that only needs the summary still receives every turn of every call.

**Why It Happens:** Attaching a webhook to an agent turns on all event types. Cartesia documents no per-type selection, and `call_turn` fires for each utterance.

**Workarounds:**

* Acknowledge events you do not need with `200` and drop them.
* Keep the handler cheap for `call_turn`, which is the most frequent event.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/filters) can drop or route events by `type`, so `call_turn` can go to analytics while `post_call_analysis` goes to your CRM, without your services seeing the rest.

### A shared secret, not a signature

**The Problem:** Anyone with the secret can send fabricated call data, and the body has no integrity check of its own.

**Why It Happens:** Cartesia returns the secret in a header rather than signing the body.

**Workarounds:**

* Use an HTTPS URL and keep the secret out of logs.
* For high-value actions, confirm the call through the Get Call API.

**How Hookdeck Can Help:** Hookdeck checks the header on every request and rejects any that fail with a `VERIFICATION_FAILED` cause, so forged requests never become events for your services.

## Best practices

### Deduplicate on `webhook_request_id`

Cartesia keeps `webhook_request_id` the same across every attempt of one event and different between events, and recommends using it to skip duplicates. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Route on `type` and read the typed field

Switch on `type`, then read `call`, `turn` or `analysis`. Ignore the deprecated top-level `body` and `end_reason`.

### Do not wait for `post_call_analysis`

The summary may already be in `call.summary` on `call_completed`, and `post_call_analysis` is not sent when nobody spoke. Do not hold up call processing waiting for it.

### Acknowledge fast and process asynchronously

Return `200` once the secret matches and the event is stored, then write transcripts or trigger follow-ups. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks), and for voice and AI pipelines, [Hookdeck for AI agents and LLMs](/docs/use-cases/ai-agents).

### Handle personal data carefully

Transcripts and `dynamic_variables` can contain what callers said and what your agent collected. Store them with the same controls as any other customer data.

## Conclusion

Cartesia sends Line call events to a webhook you attach to your agents, with your secret in the `x-webhook-secret` header and no body signature. Compare the header in constant time, deduplicate on `webhook_request_id`, route on `type`, and answer quickly, because Cartesia makes at most three attempts and treats most `4xx` responses as final.

[Hookdeck Event Gateway](https://hookdeck.com) checks the secret, stores every call event, and redelivers on your terms, so a short outage on your side does not cost you transcripts.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Cartesia webhooks reliably in minutes.