Receiving webhooks
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:writeandwebhooks:readto manage endpoints, andanalysis:readfor 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.webhookson GET/workspace. If it isfalse, creating an endpoint, sending test events and manual retries answerwebhooks_not_enabledand 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:
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.
- cURL script (bash)
create-endpoint.sh - JavaScript (Node.js 20+)
create-endpoint.mjs - Python 3.9+ (requests)
create-endpoint.py
The response (201) contains the signing secret once:
JSON
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:
| 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); one endpoint can receive every type.
Event format
Every delivery is a POST with the event as the JSON body:
JSON
id(evt_...) identifies the event. The same event can be delivered more than once; it keeps its id.data.objectis a snapshot of the resource when the event was created: a call forcall.*, a campaign forcampaign.*(plusdata.reasononcampaign.pausedandcampaign.stopped), the endpoint for the test event (never its secret).api_versionis 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 asidin 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:
- Verify the signature over the raw body, before parsing it.
- Record the event once, keyed by its id, in durable storage. A duplicate is acknowledged and ignored.
- Answer
200straight away, without waiting for your business logic. - Process in the background, and pick up anything left unprocessed after a crash or a failed attempt.
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.
- JavaScript (Node.js 20+)
receiver.mjs - Python 3.9+ (requests)
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 theRelationsAI-Event-Idheader) 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.settledcan arrive before thecall.endedof the same call. Never let an older snapshot overwrite newer state. Use the resource's own fields to decide (settled: truebeatssettled: false; a campaign'sstatusand the event'screated_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. 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.
Response 202:
JSON
A few seconds later, read the delivery log to see what your server answered:
Response 200 (one delivery shown):
JSON
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.
- cURL script (bash)
send-test-event.sh - JavaScript (Node.js 20+)
send-test-event.mjs - Python 3.9+ (requests)
send-test-event.py
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:
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 (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
failedwithtimeout: The handler does too much before answering. Fix: Answer first, process in the background. - Deliveries
failedwithhttp_error401 or 403: Your framework or firewall requires credentials. Fix: Exempt the webhook path; authenticate by signature instead. - Deliveries
failedwithhttp_error400: 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
2xxquickly 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,eventsandstatus. - 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.