# Set up webhooks

We send one `POST` per completed record to a URL you configure. There is one event, `cdr.completed`, and it carries the record.

## Point us at your endpoint

Go to **Organization → Settings**, put your URL in **Webhook URL**, and save. It has to be reachable from the public internet. There is no tunnel for local development, so use a request-inspection service or a deployed staging endpoint while you build. Clearing the field switches webhooks off.

The URL is only checked for being a URL. A typo, a private hostname, or a stale host all save cleanly and then fail at delivery time with nothing in the dashboard to tell you, so confirm your first record arrives rather than assuming.

Then generate a signing secret in the same section. It is optional and you should do it anyway: without one, anyone who learns your URL can post whatever they like to it, and you have no way to tell. [Verifying signatures](https://app.expressconsent.com/docs/webhooks/signatures) is one function.

There is no test-event button. Capture a CDR on a real page and watch what arrives. Setting the URL does not backfill either. Records captured while it was blank are never delivered, and enabling webhooks starts at your next capture.

## What arrives

A `POST` with a JSON body and these headers:

```
Content-Type: application/json
X-EC-Webhook-Id: 6f9619ff-8b86-4fd1-b42f-7fd68a2c0f5a
X-EC-Timestamp: 1741234571
X-EC-Signature: sha256=9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
```

`X-EC-Signature` is present only when you have set a signing secret. The body is on [the `cdr.completed` event](https://app.expressconsent.com/docs/webhooks/cdr-completed).

Answer with any `2xx`. We read nothing out of a successful response, so acknowledge as soon as you have the payload stored and do your own work afterwards. The request is abandoned after 15 seconds and treated as a failure. If you answer `4xx` or `5xx` we read the first kilobyte of your body and log part of it so we can help you debug, so keep credentials out of your error responses.

Nothing caps the size of what we send. A record carrying the maximum metadata and fifty long disclosures runs to a few hundred kilobytes, and an endpoint behind a small request-body limit answers `413`, which is a permanent failure. Size your limit generously.

## When it does not get through

Anything other than a `2xx` is a failed delivery, and what happens next depends on which kind.

**Retried:** a `5xx`, a `429`, a connection error, and a timeout. Five attempts in total, so four retries, each waiting roughly twice as long as the one before starting at 30 seconds. The last one lands about eight minutes after the first. There is no hour-long queue: if your endpoint is down for a quarter of an hour, the record has left the webhook pipeline.

Those five are the queue's budget rather than five requests to your endpoint. An attempt can be spent before the `POST` goes out, so a failing endpoint may see fewer.

**Not retried:** anything below `500` other than `429`. A `400`, `401`, `403`, or `404` says the request itself is wrong and an identical one will not fare better. We follow redirects, so a `301` to another host is fine and the status at the end of the chain is what counts.

> **Warning: A 4xx answer loses the record**
>
> We do not deliberately re-send a record we have recorded as delivered. There is
> no replay button in the dashboard, and no endpoint asks for one. So if you put
> authentication in front of your endpoint and forget to let us through, every
> record captured before you notice arrives as a `401`, is never retried, and is
> gone from your pipeline eight minutes later. Reconcile against the API rather
> than assuming.

To reconcile, list your records and read `webhookSuccessfullySentAt`. It is set only when your endpoint answered `2xx`, and its absence means no delivery succeeded. A metadata filter on your own identifier is how you find what you missed. See [CDR endpoints](https://app.expressconsent.com/docs/reference/api/cdrs).

## Delivery guarantees, stated plainly

**At least once, not exactly once.** We skip a record whose delivery we have already recorded as successful, but that record is written *after* your response arrives. Anything failing in between (your `2xx` lost on the way back, or our own write not landing) produces a second delivery of a record you already processed. Make your handler idempotent.

> **Warning: Deduplicate on cdrId, not on webhookId**
>
> `webhookId` and the `X-EC-Webhook-Id` header identify the *attempt*, not the
> record. Every retry carries a new one, so storing them and skipping repeats
> will not stop you processing the same record twice. `cdrId` is the stable key.

## The download URL

The payload's `downloadUrl` is good for **seven days**, rather than the 300 seconds a URL fetched from the API lasts. That is deliberate: a webhook consumer is often a queue worker that will not reach the record for a while.

Seven days is still not storage. Store the `cdrId` and ask the API for a fresh URL when you need one.

It is present only when your organization can already download the record, which for captures on your own domains means billing is set up and auto-collect is on. An organization with no billing receives every event and never receives a `downloadUrl`. If it is missing and you expected it, [access and collection](https://app.expressconsent.com/docs/concepts/access) explains why.

## Records we have flagged

A record we have marked invalid (bot traffic, most often) is still delivered, with `invalidated: true` and no `downloadUrl`. You are told it exists precisely so you know not to use or sell it.

Only the download is withheld. The IP address, the user agent, your metadata, and the derived consent facts all still arrive, so a record you must not act on is still consumer data you have received and now have to look after.

## Next

- [The cdr.completed event](https://app.expressconsent.com/docs/webhooks/cdr-completed): Every field on the webhook payload, when each one is present, and what it means.
- [Verify webhook signatures](https://app.expressconsent.com/docs/webhooks/signatures): Confirm a delivery came from us and not from someone who found your endpoint.
