Skip to content
RelationsAI
Guides

Receiving webhooks

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. If it is false, creating an endpoint, sending test events and manual retries answer 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.

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 registers the URL and the event types it receives:

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" ] }'
Complete runnable example

Register a webhook endpoint for the key's mode. Prints the signing secret once.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

The response (201) contains the signing secret once:

JSONJSON
{ "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.

Event types you can subscribe to:

EventSent when
call.endedA call ended, whatever the outcome. Artifacts and the charge may still be pending.
call.settledEverything about a call is final, including transcript, recording, analysis and the charge. Can be several minutes after call.ended.
campaign.startedA campaign run started dialling
campaign.pausedA campaign was paused, by a request or automatically (data.reason)
campaign.resumedA paused campaign resumed
campaign.stoppedA campaign was stopped
campaign.completedA run worked through every recipient (in live mode up to about 15 minutes after the last call)
campaign.failedA 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); one endpoint can receive every type.

Event format

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

JSONJSON
{ "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.
  • 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.

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.
// Minimal webhook receiver: verify, record each event once, answer 200, process afterwards. Node.js 20+. import { createHmac, timingSafeEqual } from "node:crypto"; import { createServer } from "node:http"; 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);
Complete runnable example

A complete webhook receiver: verifies the signature, records each event once, answers 2xx

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

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:

StageWhat it meansWhere you see it
Accepted for deliveryRelationsAI created the event and queued a delivery to your endpointThe delivery log: pending
DeliveredYour endpoint answered 2xx within 10 secondsThe delivery log: succeeded
ProcessedYour own background step finishedOnly 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. 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.

curl -X POST https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test \ -H "Authorization: Bearer <token>"

Response 202:

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

curl -X GET "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries?limit=20" \ -H "Authorization: Bearer <token>"

Response 200 (one delivery shown):

JSONJSON
{ "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

Send a webhook_endpoint.test event to an endpoint and wait for the delivery result.

The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.

GET/webhook-endpoints/{webhook_endpoint_id}/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:

errorMeaning
timeoutNo answer within 10 seconds
connection_failedThe connection could not be made
tls_errorThe TLS handshake or certificate failed
blocked_destinationThe URL resolved to a non-public address
http_errorYour server answered with a non-2xx status (see response_status)
signing_failedRelationsAI 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 (only failed or retrying deliveries; others answer 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).

PATCH/webhook-endpoints/{webhook_endpoint_id} 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 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 (403): Webhooks are not enabled for the workspace. Fix: Write to support@relationsai.com.
  • 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 (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.
  • 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.