# Webhooks

**Availability: Preview**

This reference page specifies how webhook deliveries work: the event format, delivery guarantees, headers, signatures and retries. For step-by-step instructions, see [Receiving webhooks](/guides/receiving-webhooks), [Verifying webhook signatures](/guides/verifying-webhook-signatures) and [Retry and reliability](/guides/retry-and-reliability).

## Endpoints

A webhook endpoint is an HTTPS URL on your server, registered with [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint) (scope `webhooks:write`) or on the console's API & Developers page, with the event types it should receive.

- An endpoint belongs to **one mode**: one registered with a test key receives only test-mode events (`"livemode": false`), one registered with a live key only live events. Register separate endpoints for each.
- The URL must be public HTTPS on port 443, without credentials. It is checked when you register it and again on every delivery; a URL that is not allowed answers [`webhook_url_not_allowed`](/errors/webhook_url_not_allowed).
- The signing secret (`whsec_...`) is returned **once**, in the first response when you create the endpoint or rotate its secret. Store it like an API key. A replay of either request with the same `Idempotency-Key` returns the endpoint with `secret: null`: the secret is never stored in readable form. If you lose it, rotate the secret.
- Rotating the secret ([`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret)) always issues a new current secret, returned once. At most **two** secrets ever sign a delivery: the current one and, while a grace period runs, one previous one.
  - `grace_period_hours: 0` is always allowed, also during a running grace period, and retires every previous secret at once: only the new secret signs from the next delivery. Use it when a secret may have leaked.
  - A grace period (`grace_period_hours` above 0) with no grace period running keeps the old secret signing until the grace period ends.
  - A grace period while another one is running keeps the **oldest** still-valid secret, until its original end (not extended), and discards the secret issued in between. So if you lost a rotation's response, rotate again: receivers still on the oldest secret keep verifying meanwhile.
- An endpoint is `enabled` or `disabled`; disabled endpoints receive nothing.
- Webhook delivery is in private preview and is switched on for the platform as a whole, separately from API access. `capabilities.webhooks` in [`GET /workspace`](/api/workspace#retrieve-the-workspace) says whether it is on. While it is off, endpoints cannot be created, test events and retries are refused with [`webhooks_not_enabled`](/errors/webhooks_not_enabled), and no events are sent.

## Events

Every delivery is a `POST` with one event as its JSON body:

```json
{
  "id": "evt_5Zx9Cv3Bn7Mq2Wl8Ek4Rt6",
  "object": "event",
  "type": "call.ended",
  "created_at": "2026-10-21T09:32:15.000Z",
  "livemode": true,
  "api_version": "2026-10-21",
  "data": {
    "object": {
      "id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1",
      "object": "call",
      "status": "ended",
      "outcome": "completed",
      "settled": false
    }
  }
}
```

(The call object is shortened here; events carry the full object.)

| Field | Meaning |
| --- | --- |
| `id` | Event id (`evt_...`). The same event can be delivered more than once: use it to discard duplicates. |
| `type` | The event type. |
| `created_at` | When the event happened. |
| `livemode` | The event's mode. |
| `api_version` | The payload format version, today `2026-10-21`. |
| `data.object` | A snapshot of the object (a call or a campaign) when the event happened, in the same shape as the API returns it. |

Event types in v1:

| Type | Sent when |
| --- | --- |
| `call.ended` | A call ended, whatever the outcome. Outcome and duration are known but can still change until `call.settled`; artifacts and the charge may still be pending. |
| `call.settled` | Everything about a call is final, including transcript, recording, analysis and the charge. |
| `campaign.started` | A campaign started a run and began dialling. |
| `campaign.paused` | A campaign was paused, by a request or automatically (budget, balance, telephony). |
| `campaign.resumed` | A paused campaign resumed its run. |
| `campaign.stopped` | A campaign was stopped. |
| `campaign.completed` | A campaign run finished dialling every recipient. |
| `campaign.failed` | A campaign run ended in failure before finishing. |
| `webhook_endpoint.test` | A test event you sent to an endpoint (see below). |

Send a `webhook_endpoint.test` event to an endpoint with [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event). The full payload of each type is on the [Event types](/api/events) page. New event types can be added at any time; ignore types you do not know and still answer `2xx`.

## Delivery

- **At least once.** The same event can arrive more than once, for example when your answer was lost. Discard duplicates by event `id` (also in the `RelationsAI-Event-Id` header).
- **Unordered.** Events about different objects, and sometimes about the same object, can arrive in any order. Decide what is newer from the object itself (`status`, `settled`, timestamps), not from arrival order. If in doubt, read the object from the API.
- **HTTPS only**, and redirects are **not** followed: a `3xx` answer counts as a failure.
- **Answer within 10 seconds with any `2xx`.** The response body is ignored. Do the work after answering, for example by putting the event on a queue.
- Each endpoint gets its own delivery (`wd_...`) of each event it subscribes to.

## Headers

- `RelationsAI-Signature`: The signature; see [Signature scheme](#signature-scheme).
- `RelationsAI-Event-Id`: The event id, the same as `id` in the body.
- `RelationsAI-Event-Type`: The event type, the same as `type` in the body.
- `RelationsAI-Delivery-Id`: The delivery id (`wd_...`), different for each endpoint and unchanged across retries.
- `User-Agent`: `RelationsAI-Webhooks/1.0`
- `Content-Type`: `application/json`

## Signature scheme

```text
RelationsAI-Signature: t=1792575135,v1=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
```

1. Take `t` (the time the signature was made, in Unix seconds) and each `v1` value from the header.
2. Compute HMAC-SHA256 with the endpoint's signing secret as the key, over the string `<t>.<raw request body>`: the exact bytes you received, before any JSON parsing. Hex-encode it.
3. Compare it with each `v1` in constant time. Accept the event if any matches.
4. Reject the event if `t` is more than **5 minutes** from your server's clock, to stop replays.

During a secret rotation's grace period, the header carries **two** `v1` values (never more), one per signing secret, so your receiver keeps verifying while you deploy the new secret. Code in three languages is in [Verifying webhook signatures](/guides/verifying-webhook-signatures).

## Retry schedule

A delivery fails when your server answers anything other than `2xx`, takes longer than 10 seconds, redirects, or cannot be reached. Failed deliveries are retried after about:

| Attempt | Delay after the previous attempt |
| --- | --- |
| 2 | 10 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 6 hours |
| 8 | 12 hours |

After the last attempt the delivery is marked `failed`.

Back-pressure can stretch this schedule:

- At most 2 attempts are in flight to one endpoint at a time.
- After 5 failed attempts in a row, an endpoint is paused (for 1 minute, doubling up to 1 hour). Its waiting deliveries are attempted after the pause without using up their attempts, and one success resumes it. An endpoint is never disabled automatically.
- Live-mode deliveries are attempted before test-mode ones, and a workspace's test-mode deliveries are limited to 60 attempts a minute.

So a delivery to an endpoint that has been failing for a while can arrive later than the schedule above. Deliveries are kept for 30 days: list them with [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries) (each shows its `status`, `attempts`, `response_status` and `error`) and send one again with [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery).

## Webhooks or polling

Webhooks are the recommended way to follow calls and campaigns: they arrive within seconds and do not use your rate limits. Keep a fallback: if an expected event has not arrived, read the object from the API (for example, a call that has not reported `call.settled` after an hour). See [Tracking call progress](/guides/tracking-call-progress).
