# Receiving webhooks

**Availability: Preview**

Webhooks deliver events such as `call.ended` and `campaign.completed` to an HTTPS endpoint you control. This guide covers registering an endpoint, the event format, responding correctly, and handling duplicates and ordering.

By the end you will have a complete receiver, in Node.js or Python with no framework, that verifies signatures, records each event exactly once, acknowledges within milliseconds, processes events in the background and, on `call.settled`, fetches the call's analysis from the API.

## Before you begin

- A key with `webhooks:write` and `webhooks:read` to manage endpoints, and `analysis:read` for the receiver's API call.
- Webhook delivery available. It is in private preview and can be unavailable even when the API is enabled: check `capabilities.webhooks` on [`GET /workspace`](/api/workspace#retrieve-the-workspace). If it is `false`, creating an endpoint, sending test events and manual retries answer [`webhooks_not_enabled`](/errors/webhooks_not_enabled) and no events are sent; existing endpoints can still be listed, read, changed and deleted. Write to support@relationsai.com to ask when delivery will be available.
- A public HTTPS URL on port 443, without credentials in it. For local development, see [Develop locally](#develop-locally).

Endpoints belong to one mode: an endpoint created with a test key receives only test-mode events, and one created with a live key only live events. Create one of each.

## Register an endpoint

[`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint) registers the URL and the event types it receives:

```bash
curl -X POST https://voice-api.relationsai.com/v1/webhook-endpoints \
     -H "Authorization: Bearer <token>" \
     -H "Content-Type: application/json" \
     -d '{
  "url": "https://example.com/webhooks/relationsai",
  "description": "CRM sync (test)",
  "events": [
    "call.ended",
    "call.settled",
    "campaign.completed"
  ]
}'
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "url": "https://example.com/webhooks/relationsai",
    "description": "CRM sync (test)",
    "events": [
      "call.ended",
      "call.settled",
      "campaign.completed"
    ]
  }),
});
const endpoint = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/webhook-endpoints",
    headers={
        "Authorization": "Bearer <token>",
    },
    json={
        "url": "https://example.com/webhooks/relationsai",
        "description": "CRM sync (test)",
        "events": [
            "call.ended",
            "call.settled",
            "campaign.completed",
        ],
    },
    timeout=30,
)
endpoint = response.json()
```

Complete runnable example: [cURL script](/examples/guides/receiving-webhooks/create-endpoint.sh) · [JavaScript](/examples/guides/receiving-webhooks/create-endpoint.mjs) · [Python](/examples/guides/receiving-webhooks/create-endpoint.py)

The response (`201`) contains the signing secret **once**:

```json
{
  "id": "we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1",
  "object": "webhook_endpoint",
  "livemode": false,
  "url": "https://example.com/webhooks/relationsai",
  "description": "CRM sync (test)",
  "events": ["call.ended", "call.settled", "campaign.completed"],
  "status": "enabled",
  "secret_last4": "x9Qa",
  "created_at": "2026-10-21T08:00:00.000Z",
  "updated_at": "2026-10-21T08:00:00.000Z",
  "last_delivery": null,
  "secret": "whsec_..."
}
```

Store `secret` in your secret manager straight away; later reads show only `secret_last4`, and if you send an idempotency key, a retry of the same create request with it returns the endpoint with `secret: null`. If you lose it, [rotate it](/guides/verifying-webhook-signatures#during-secret-rotation).

Event types you can subscribe to:

| Event | Sent when |
|---|---|
| `call.ended` | A call ended, whatever the outcome. Artifacts and the charge may still be pending. |
| `call.settled` | Everything about a call is final, including transcript, recording, analysis and the charge. Can be several minutes after `call.ended`. |
| `campaign.started` | A campaign run started dialling |
| `campaign.paused` | A campaign was paused, by a request or automatically (`data.reason`) |
| `campaign.resumed` | A paused campaign resumed |
| `campaign.stopped` | A campaign was stopped |
| `campaign.completed` | A run worked through every recipient (in live mode up to about 15 minutes after the last call) |
| `campaign.failed` | A run ended in failure (in live mode up to about 15 minutes after it ends) |

`webhook_endpoint.test` is sent only when you ask for it and cannot be subscribed to. The list is an open enum: ignore types you do not handle rather than failing.

The URL is checked when you create or change it and again on every delivery: it must be public HTTPS on port 443, must not contain a user name or password, and must resolve only to public internet addresses. Each workspace can have a limited number of endpoints per mode ([`webhook_endpoint_limit_reached`](/errors/webhook_endpoint_limit_reached)); one endpoint can receive every type.

## Event format

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

```json
{
  "id": "evt_8Nm4Bv1Cx6Zl3Kj9Hg2Fd5",
  "object": "event",
  "type": "call.settled",
  "created_at": "2026-10-21T09:41:02.000Z",
  "livemode": true,
  "api_version": "2026-10-21",
  "data": {
    "object": {
      "id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1",
      "object": "call",
      "status": "ended",
      "outcome": "completed",
      "metadata": { "crm_record_id": "L-1042" },
      "settled": true,
      "...": "the rest of the call object"
    }
  }
}
```

- `id` (`evt_...`) identifies the event. The same event can be delivered more than once; it keeps its id.
- `data.object` is a snapshot of the resource **when the event was created**: a call for `call.*`, a campaign for `campaign.*` (plus `data.reason` on `campaign.paused` and `campaign.stopped`), the endpoint for the test event (never its secret).
- `api_version` is the payload format version, a date.

Headers on every delivery:

- `RelationsAI-Signature`: `t=<unix seconds>,v1=<hex>`, see [Verifying webhook signatures](/guides/verifying-webhook-signatures).
- `RelationsAI-Event-Id`: The event id, the same as `id` in the body.
- `RelationsAI-Event-Type`: The event type.
- `RelationsAI-Delivery-Id`: The delivery (`wd_...`), one per endpoint, unchanged across retries.
- `User-Agent`: `RelationsAI-Webhooks/1.0`.
- `Content-Type`: `application/json`.

All event types and their payloads are in [Events](/api/events).

## A complete receiver

This receiver does the four things every webhook handler must do, in this order:

1. **Verify the signature** over the raw body, before parsing it.
2. **Record the event once**, keyed by its id, in durable storage. A duplicate is acknowledged and ignored.
3. **Answer `200` straight away**, without waiting for your business logic.
4. **Process in the background**, and pick up anything left unprocessed after a crash or a failed attempt.

```js
// Minimal webhook receiver: verify, record each event once, answer 200, process afterwards. Node.js 20+.

const SECRET = "whsec_..."; // the endpoint's signing secret, read from your secret store
const seen = new Set(); // in production: a database table with a unique event id

function verify(raw, header = "") {
  const t = /(?:^|,)t=(\d+)/.exec(header)?.[1];
  const signatures = [...header.matchAll(/v1=([0-9a-f]{64})/g)].map((m) => Buffer.from(m[1], "hex"));
  if (!t || !signatures.length || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const expected = createHmac("sha256", SECRET).update(`${t}.`).update(raw).digest();
  return signatures.some((s) => timingSafeEqual(s, expected));
}

function handle(event) {
  // Your processing. On call.settled, fetch the analysis (see Working with analysis results).
  console.log(event.type, event.data.object.id);
}

createServer((req, res) => {
  const chunks = [];
  req.on("data", (chunk) => chunks.push(chunk));
  req.on("end", () => {
    const raw = Buffer.concat(chunks);
    if (!verify(raw, req.headers["relationsai-signature"])) return res.writeHead(400).end();
    const event = JSON.parse(raw);
    res.writeHead(200).end(); // answer first
    if (seen.has(event.id)) return; // a duplicate delivery: already handled
    seen.add(event.id);
    setImmediate(() => handle(event));
  });
}).listen(8000);
```
```python
# Minimal webhook receiver: verify, record each event once, answer 200, process afterwards. Python 3.9+.

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

SECRET = b"whsec_..."  # the endpoint's signing secret, read from your secret store
seen = set()  # in production: a database table with a unique event id
lock = threading.Lock()

def verify(raw, header):
    t = re.search(r"(?:^|,)t=(\d+)", header or "")
    signatures = re.findall(r"v1=([0-9a-f]{64})", header or "")
    if not t or not signatures or abs(time.time() - int(t.group(1))) > 300:
        return False
    expected = hmac.new(SECRET, t.group(1).encode() + b"." + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)

def handle(event):
    # Your processing. On call.settled, fetch the analysis (see Working with analysis results).
    print(event["type"], event["data"]["object"]["id"], flush=True)

class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        if not verify(raw, self.headers.get("RelationsAI-Signature")):
            self.send_response(400)
            self.end_headers()
            return
        event = json.loads(raw)
        self.send_response(200)  # answer first
        self.end_headers()
        with lock:
            duplicate = event["id"] in seen  # a duplicate delivery: already handled
            seen.add(event["id"])
        if not duplicate:
            threading.Thread(target=handle, args=(event,)).start()

ThreadingHTTPServer(("", 8000), Receiver).serve_forever()
```

Complete runnable example: [JavaScript](/examples/guides/receiving-webhooks/receiver.mjs) · [Python](/examples/guides/receiving-webhooks/receiver.py)

Put your endpoint's secret in place of `whsec_...` (in production, load it from your secret store) and run it with `node receiver.mjs` or `python receiver.py`. It keeps processed ids in memory to stay short. The complete runnable receiver adds what production needs: it records each event durably before answering (files in Node.js, a SQLite table keyed by the event id in Python), re-processes events left unfinished after a crash, and fetches the analysis on `call.settled`.

## Respond quickly

Answer with any `2xx` **within 10 seconds**. The response body is ignored. Anything else (a `4xx` or `5xx`, a timeout, a redirect, a refused connection) counts as a failed attempt and is retried. Redirects are never followed, so register the final URL.

Keep the handler small: verify, record, answer. Calling the API, updating a CRM or sending an email belongs in the background step. A slow handler causes timeouts, timeouts cause retries, and retries add load at the worst moment.

## Duplicates and ordering

Delivery is **at least once** and **unordered**:

- **Duplicates happen**: after a timeout on your side, a retry, or a delivery that was attempted again. Deduplicate by the event `id` (or the `RelationsAI-Event-Id` header) in durable storage, as the receiver does. Do not deduplicate in memory only: a restart forgets it.
- **Order is not guaranteed**, across resources and within one resource: a `call.settled` can arrive before the `call.ended` of the same call. Never let an older snapshot overwrite newer state. Use the resource's own fields to decide (`settled: true` beats `settled: false`; a campaign's `status` and the event's `created_at`), or read the resource from the API when the order matters.

## Retries

A delivery that fails is retried after about **10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours**, so for roughly a day, and then marked `failed`. The event and its id stay the same on every attempt, and **every attempt is signed again with a fresh timestamp** (`t`), so a retry hours later still passes the 5-minute check.

The schedule can stretch, because deliveries are paced to protect your server:

- **At most 2 attempts are in flight to one endpoint at a time.** A slow endpoint receives its backlog gradually.
- **After 5 failed attempts in a row, the endpoint is paused** for 1 minute, then 2, 4 and so on up to 1 hour while failures continue. Its waiting deliveries are attempted after the pause **without using up their attempts**, and one successful answer ends the pause. An endpoint is never disabled automatically, so a long outage on your side delays deliveries rather than losing them, until each delivery has used its attempts.
- **Live-mode deliveries go before test-mode ones**, and a workspace's test-mode deliveries are limited to 60 attempts a minute. A burst of test traffic can therefore arrive with a delay.

Events are created only while your workspace has API access and webhook delivery is available. Events that happen while either is off are not created, so they are never delivered; reconcile with the list endpoints after such a period.

Three different things can be true about an event, and they are easy to confuse:

| Stage | What it means | Where you see it |
|---|---|---|
| Accepted for delivery | RelationsAI created the event and queued a delivery to your endpoint | The delivery log: `pending` |
| Delivered | Your endpoint answered `2xx` within 10 seconds | The delivery log: `succeeded` |
| Processed | Your own background step finished | Only in your system |

Once you have answered `2xx`, RelationsAI will not send that event again on its own. That is why the receiver records the event **before** answering and has a sweep for events it acknowledged but did not finish processing.

## Test and inspect deliveries

Send a test event to an endpoint with [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event). It is signed like any other event, delivered only to that endpoint, and answers `202` with the delivery, usually `pending`. `202` means accepted for delivery, not that your server received it: read the delivery log to see the result.

```bash
curl -X POST https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test", {
  method: "POST",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const delivery = await response.json();
```
```python

response = requests.post(
    "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test",
    headers={
        "Authorization": "Bearer <token>",
    },
    timeout=30,
)
delivery = response.json()
```

Response `202`:

```json
{
  "id": "wd_8Ik2Ol5Pm9Nb3Vc6Xz1As4",
  "object": "webhook_delivery",
  "livemode": false,
  "webhook_endpoint_id": "we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1",
  "event_id": "evt_3Df7Gh1Jk5Lz9Xc2Vb6Nm8",
  "event_type": "webhook_endpoint.test",
  "status": "pending",
  "attempts": 0,
  "last_attempt_at": null,
  "next_attempt_at": "2026-10-21T09:45:00.000Z",
  "response_status": null,
  "duration_ms": null,
  "error": null,
  "created_at": "2026-10-21T09:45:00.000Z"
}
```

A few seconds later, read the delivery log to see what your server answered:

```bash
curl -X GET "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries?limit=20" \
     -H "Authorization: Bearer <token>"
```
```js
const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries?limit=20", {
  method: "GET",
  headers: {
    "Authorization": "Bearer <token>",
  },
});
const deliveries = await response.json();
```
```python

response = requests.get(
    "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries",
    headers={
        "Authorization": "Bearer <token>",
    },
    params={
        "limit": 20,
    },
    timeout=30,
)
deliveries = response.json()
```

Response `200` (one delivery shown):

```json
{
  "object": "list",
  "data": [
    {
      "id": "wd_1Qa5Ws9Ed3Rf7Tg2Yh6Uj4",
      "object": "webhook_delivery",
      "livemode": false,
      "webhook_endpoint_id": "we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1",
      "event_id": "evt_5Zx9Cv3Bn7Mq2Wl8Ek4Rt6",
      "event_type": "call.ended",
      "status": "succeeded",
      "attempts": 1,
      "last_attempt_at": "2026-10-21T09:31:02.000Z",
      "next_attempt_at": null,
      "response_status": 200,
      "duration_ms": 184,
      "error": null,
      "created_at": "2026-10-21T09:31:01.000Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

Complete runnable example: [cURL script](/examples/guides/receiving-webhooks/send-test-event.sh) · [JavaScript](/examples/guides/receiving-webhooks/send-test-event.mjs) · [Python](/examples/guides/receiving-webhooks/send-test-event.py)

[`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries) lists deliveries newest first (kept for 30 days), filterable by `status`: `pending`, `retrying`, `succeeded` or `failed`, with `attempts`, `response_status`, `duration_ms` and `error`:

| `error` | Meaning |
|---|---|
| `timeout` | No answer within 10 seconds |
| `connection_failed` | The connection could not be made |
| `tls_error` | The TLS handshake or certificate failed |
| `blocked_destination` | The URL resolved to a non-public address |
| `http_error` | Your server answered with a non-2xx status (see `response_status`) |
| `signing_failed` | RelationsAI could not sign the delivery, so nothing was sent; the attempt still counts. Contact support if it persists. |

After fixing a problem, redeliver with [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery) (only `failed` or `retrying` deliveries; others answer [`webhook_delivery_not_retryable`](/errors/webhook_delivery_not_retryable)). The event id is unchanged, so your duplicate check still works. Deliveries older than 30 days cannot be retried; reconcile with the list endpoints instead (see [Common integration patterns](/guides/common-integration-patterns)).

[`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint) changes the URL, events or `status`. **Disabling an endpoint stops deliveries, and events that occur while it is disabled are not delivered later.** Deleting an endpoint cancels its pending deliveries.

## Develop locally

RelationsAI can only deliver to a public HTTPS URL. While you develop:

- Test your receiver without RelationsAI: [sign-and-send.sh](/guides/verifying-webhook-signatures#test-your-verifier) sends correctly signed events, signed with your own secret, to `http://localhost:8000/webhooks/relationsai`.
- To receive real test-mode events on your machine, expose the local port through any HTTPS tunnelling tool you trust, register the tunnel's HTTPS URL as a **test-mode** endpoint, and send a test event. Delete the endpoint when you are done.

## What can go wrong

- **[`webhooks_not_enabled`](/errors/webhooks_not_enabled) (403)**: Webhooks are not enabled for the workspace. Fix: Write to support@relationsai.com.
- **[`webhook_url_not_allowed`](/errors/webhook_url_not_allowed) (422)**: Not HTTPS, a port other than 443, credentials in the URL, or a host that is not public (`details.reason`) Fix: Use a public HTTPS URL on 443.
- **[`webhook_endpoint_disabled`](/errors/webhook_endpoint_disabled) (409) on a test event or retry**: The endpoint is disabled. Fix: Enable it with PATCH.
- **Deliveries `failed` with `timeout`**: The handler does too much before answering. Fix: Answer first, process in the background.
- **Deliveries `failed` with `http_error` 401 or 403**: Your framework or firewall requires credentials. Fix: Exempt the webhook path; authenticate by signature instead.
- **Deliveries `failed` with `http_error` 400**: Your signature check fails. Fix: See [Verifying webhook signatures](/guides/verifying-webhook-signatures).
- **Deliveries arrive late, in bursts**: After 5 failures in a row the endpoint is paused (1 minute, doubling up to 1 hour) and at most 2 attempts run at once; test-mode deliveries are limited to 60 a minute. Fix: Answer `2xx` quickly and reliably; the backlog drains on its own after one success.
- **Nothing arrives at all**: Wrong mode (test endpoint, live events), not subscribed to the type, or the endpoint is disabled. Fix: Check the endpoint's `livemode`, `events` and `status`.
- **The same event processed twice**: Deduplication in memory, or after processing instead of before answering. Fix: Record the event id durably before answering, as the receiver does.

## Next steps

[Verifying webhook signatures](/guides/verifying-webhook-signatures).
