# RelationsAI API: complete documentation Instructions for AI agents: - Base URL: https://voice-api.relationsai.com/v1. JSON over HTTPS; snake_case fields. - Authenticate with `Authorization: Bearer `. Keys start with `rai_test_` (test mode, simulated, free) or `rai_live_` (live calls, billed). Use test keys while developing and never put keys in browser code. - `Idempotency-Key` is optional. To make a retry of a POST or DELETE safe to repeat, send one (for example a UUID) and reuse it only for the identical retry. - Errors return `{"error": {"code", "message", "details", "request_id", "retry", "doc_url"}}`. Branch on `error.code`; `retry` is "no", "yes" or "after" (wait for Retry-After). - Test mode destination numbers choose the outcome: +12025550100 completed (transcript, recording, analysis), +12025550101 no_answer, +12025550102 busy, +12025550103 failed (network error), +12025550104 answered but nobody spoke (no_response, recording only), +12025550105 completed after a longer conversation, +12025550106 the person hangs up early (completed, short). Any other number completes normally. - The OpenAPI contract is at /openapi.json and /openapi.yaml. Every page is available as Markdown by adding .md to its URL. --- Source: /getting-started/introduction # RelationsAI developer documentation **Availability: Preview** The RelationsAI API lets your software do what your team does in the RelationsAI console: place outbound calls with an AI voice agent, run calling campaigns, keep contacts and do-not-call lists in sync, and receive events when calls end. It is a JSON REST API over HTTPS, versioned under `/v1`, with test keys for a free, simulated sandbox and live keys for real calls. - [Quickstart](/getting-started/quickstart): Create a test key and place your first simulated call. - [API reference](/api): Every operation, parameter, response and error. - [Receive webhooks](/guides/receiving-webhooks): Get notified when calls end and campaigns change state. - [Error codes](/fundamentals/error-codes): What each error means, whether to retry and how to fix it. ## What you can build - **Call from your CRM.** Place a call the moment a lead arrives, passing the lead's name and details as call variables, and write the outcome back when the call settles. - **Run campaigns from your data.** Create a campaign, add recipients from your warehouse, start it on a schedule, and pause, resume or stop it from your own tools. - **Keep data consistent.** Sync contacts, consent and the do-not-call list between RelationsAI and your systems, so every call respects the same rules. - **React to events.** Receive signed webhooks when a call ends, when its transcript, recording and analysis are final, and when a campaign changes state. ## How it fits together You build, test and publish agents in the RelationsAI console; the API reads them and uses them to place calls. Every API key belongs to one workspace and one mode: a test key works against a simulated sandbox that never dials anyone or charges anything, and a live key places real calls charged to your workspace. Read [Platform overview](/getting-started/platform-overview) for the full picture, and [API availability](/getting-started/api-availability) for what the API covers today. ## Start here 1. [Quickstart](/getting-started/quickstart): a complete, free test-mode call in about five minutes. 2. [Authentication](/getting-started/authentication) and [API keys](/getting-started/api-keys): how keys, scopes and modes work. 3. [Test mode](/getting-started/test-mode): the simulated outcomes you can trigger. 4. [Error handling](/fundamentals/error-handling) and [Idempotency](/fundamentals/idempotency): how to handle failures and retry without repeating an action, before you go live. 5. [Going live](/guides/going-live) when you are ready for real calls. ## How the docs are organised **Getting started** explains keys, environments and the core objects. **Guides** walk through complete tasks. **API fundamentals** covers the conventions every request follows: errors, idempotency, pagination, rate limits and data formats. The **API reference** is generated from the OpenAPI contract, which you can also download as [JSON](/openapi.json) or [YAML](/openapi.yaml). There is no official SDK yet: every example uses plain HTTP in cURL, JavaScript (Node.js 20+ `fetch`) and Python (`requests`). ## Docs for AI tools These docs are also published as [llms.txt](/llms.txt), [llms-full.txt](/llms-full.txt) and as Markdown: add `.md` to any page URL. ## Getting help Email [support@relationsai.com](mailto:support@relationsai.com) and include the `request_id` of the request you are asking about. See [Support](/resources/support) for what to include and what never to send. --- Source: /getting-started/platform-overview # Platform overview RelationsAI runs AI voice agents that hold phone conversations on your behalf. You design and publish agents in the RelationsAI console, then use the API to start calls and campaigns, read results and receive events. This page shows how the pieces connect so you know which part of your integration talks to what. ## The console and the API The console and the API work on the same workspace. The console is where people set things up; the API is where your software does the day-to-day work. | Task | Where | | --- | --- | | Build, configure, test and publish agents | Console **Availability: Console only** | | Add phone numbers and assign them to agents | Console **Availability: Console only** | | Members, roles, billing, balance, calling hours, consent policy, security settings | Console **Availability: Console only** | | Create, rotate and revoke API keys | Console **Availability: Console only** | | Read agents, check whether an agent can call, read phone numbers | API | | Place calls and read their status, transcript, recording and analysis | API | | Create, start, pause, resume and stop campaigns | API (or console) | | Sync contacts, consent and the do-not-call list | API (or console) | | Register webhook endpoints and inspect deliveries | API (or console) | Agent authoring through the API is **Availability: Planned**. See [API availability](/getting-started/api-availability) for the full list. ## Lifecycle of a call A call is asynchronous: the request that places it returns before anyone answers. 1. **Accepted.** `POST /v1/calls` checks the agent, your account, balance and spend limits, the do-not-call list, consent, calling hours and the per-number daily limit. If everything passes, it answers `202 Accepted` with the call in `queued` status. If anything fails, nothing is dialled and you get an error that says why. 2. **Dialled and connected.** The call moves to `in_progress` when the person answers. (A separate `ringing` status is not reported today; the workspace's `capabilities.call_ringing_status` says when it is.) 3. **Ended.** `status` becomes `ended` and `outcome` says how: `completed`, `no_response`, `no_answer`, `busy`, `failed` or `unknown`. RelationsAI sends a `call.ended` event. The outcome is known at this point but can still be corrected until the call settles. 4. **Settled.** After the call ends, the transcript, recording and analysis are produced and the charge is finalised, usually within a minute. When everything is final, `settled` becomes `true` and RelationsAI sends `call.settled`. Until then, `outcome`, `duration_seconds` and `cost` can still change. Campaigns follow the same idea at a larger scale: you create one, add recipients, start it, and its status (`running`, `paused`, `stopped`, `completed`, `failed`) and progress change as it calls each recipient. See [Core concepts](/getting-started/core-concepts). ## Events and webhooks Instead of polling, register a webhook endpoint: an HTTPS URL on your server. RelationsAI sends it a signed JSON event when something happens, such as `call.ended`, `call.settled`, `campaign.started` or `campaign.completed`. Deliveries are at least once and can arrive out of order, so your receiver verifies the signature, discards duplicates by event id and answers quickly. See [Webhooks](/fundamentals/webhooks) and [Receiving webhooks](/guides/receiving-webhooks). ## Workspaces and modes Your organisation in RelationsAI is a **workspace**. Every key belongs to one workspace and one **mode**: - A **test key** (`rai_test_...`) works in test mode: calls are simulated, nothing is dialled or charged, and every object has `"livemode": false`. - A **live key** (`rai_live_...`) works in live mode: real calls to real people, charged to the workspace balance, with `"livemode": true`. Calls, campaigns, contacts, do-not-call entries, webhook endpoints and events created in one mode are invisible in the other. Agents and phone numbers are shared configuration, readable in both. See [Environments](/getting-started/environments). ## Compliance built in The same checks apply to calls from the console, campaigns and the API, in test mode as in live mode. They run when a call is placed, and for a campaign when it starts and when it resumes: - **Do-not-call list.** A call to a number on the list is refused ([`dnc_listed`](/errors/dnc_listed)), and a campaign leaves listed numbers out when it starts. A campaign that is already running is not screened again: a number added to the list during a run can still be called by that run. To stop that, pause and resume the campaign (the resume is refused while a remaining recipient is listed) or stop it. - **Consent.** When the workspace requires consent, a number that matches a contact without recorded consent is refused ([`consent_required`](/errors/consent_required)). A number with no contact record is not checked for consent. - **Calling hours.** Calls outside the hours set in the console are refused ([`outside_calling_hours`](/errors/outside_calling_hours)). - **Per-number daily limit.** One number is not called more often in 24 hours than the workspace allows ([`contact_daily_limit_reached`](/errors/contact_daily_limit_reached)). - **AI disclosure.** An agent cannot be published unless its instructions tell the people it calls that they are speaking with an AI. No API key or request option can switch these checks off. Do-not-call screening can be switched off for a single campaign only in the console, by a person; the API refuses to start or resume such a campaign ([`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled)), and the campaign's `dnc_screening` shows `disabled_in_console`. Your integration's job is to keep consent and the do-not-call list accurate; see [Consent and do-not-call](/guides/consent-and-dnc). ## Where to go next - [Core concepts](/getting-started/core-concepts): every object and how they relate. - [Quickstart](/getting-started/quickstart): the lifecycle above, in test mode. --- Source: /getting-started/quickstart # Quickstart: your first call **Availability: Test mode** In this quickstart you check an API key, pick an agent, place a call in test mode, follow it until it is final, and read its transcript, recording and analysis: eight requests, about five minutes. > **Test mode never places a real call:** Everything on this page uses a test key (`rai_test_...`). Test mode never dials a phone, never reaches a phone network and never charges your workspace. The call, its transcript, its recording and its analysis are simulated, and every object says `"livemode": false`. ## Before you begin - A RelationsAI workspace with API access. The API is in private preview: a workspace that has not been enabled answers every authenticated request with [`api_access_not_enabled`](/errors/api_access_not_enabled). Write to [support@relationsai.com](mailto:support@relationsai.com) to request access. - A workspace owner or admin to create the key (only they can create keys). - One of: `curl`; Node.js 20 or newer (built-in `fetch`); Python 3.9 or newer with `requests` (`pip install requests`). - No agent of your own is needed. Test mode has a built-in **Sample agent** (`agent_1ZltiWzxrXmMprGD6q63`) that is always ready. ### Step 1: Create a test API key In the RelationsAI platform ([voice.relationsai.com](https://voice.relationsai.com)), open **Settings → API Keys** and select **Create API key** (step by step: [Get an API key](/getting-started/api-keys#get-an-api-key)): 1. Name it after what will use it, for example `Quickstart`. 2. Choose the **Test** environment. 3. Keep the default read permissions and also tick **Place calls** (`calls:write`). 4. Choose an expiry. Test keys may also be set to never expire. 5. Select **Create API key**, copy the key and confirm that you have stored it. The key is shown once. In the requests on this page, replace `` with that key. The ids shown (`call_6Vb3Nm8Qw2Er5Ty9Ui4Op7` and so on) are examples: use the ids from your own responses. Keep the key in a secret manager, never in source code. See [API keys](/getting-started/api-keys) for scopes, expiry and rotation. ### Step 2: Check the key Retrieve the workspace. It is the quickest way to check that a key works, which mode it is in and which scopes it has, and it never changes anything. [`GET /workspace`](/api/workspace#retrieve-the-workspace) ```bash curl https://voice-api.relationsai.com/v1/workspace \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/workspace", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/workspace", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "id": "ws_7Hk2Lm9QxR4tB8vN3cZp6W", "object": "workspace", "livemode": false, "name": "Acme Lending", "timezone": "Asia/Kolkata", "currency": "INR", "api_key": { "id": "key_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "name": "CRM sync (test)", "environment": "test", "scopes": [ "workspace:read", "agents:read", "numbers:read", "calls:read", "calls:write", "transcripts:read", "recordings:read", "analysis:read" ], "created_at": "2026-10-20T08:15:00.000Z", "expires_at": null }, "capabilities": { "campaign_pause": true, "campaign_resume": true, "call_ringing_status": false, "live_transcript": false, "sentiment_analysis": false, "end_call": false, "agent_editing": false, "inbound_calls": false, "webhooks": true }, "limits": { "rate_limits": { "read": { "per_key_per_minute": 600, "per_workspace_per_minute": 1200 }, "write": { "per_key_per_minute": 120, "per_workspace_per_minute": 240 }, "dial": { "per_key_per_minute": null, "per_workspace_per_minute": 30 } }, "daily_call_limit": null, "campaign_max_concurrency": 10 } } ``` **Next:** check that `livemode` is `false` (a test key) and that `api_key.scopes` contains `calls:write`. ### Step 3: List agents List the agents your key can use. Test mode lists your workspace's real agents plus the built-in Sample agent, marked `"sample": true`. [`GET /agents`](/api/agents#list-agents) ```bash curl https://voice-api.relationsai.com/v1/agents \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/agents", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/agents", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "list", "data": [ { "id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "object": "agent", "livemode": false, "name": "Loan renewal reminder", "description": "Reminds customers that their loan is due for renewal and books a callback.", "status": "active", "published": true, "published_version": 4, "is_default": true, "sample": false, "created_at": "2026-09-02T10:12:44.000Z", "updated_at": "2026-10-18T14:03:10.000Z" }, { "id": "agent_1ZltiWzxrXmMprGD6q63", "object": "agent", "livemode": false, "name": "Sample agent", "description": "A built-in agent for trying the API in test mode.", "status": "active", "published": true, "published_version": 1, "is_default": false, "sample": true, "created_at": "2026-10-21T00:00:00.000Z", "updated_at": null } ], "has_more": false, "next_cursor": null } ``` **Next:** use the Sample agent's `id`, `agent_1ZltiWzxrXmMprGD6q63`. It has the same id in every workspace, exists only in test mode, and calls from the sample number `+12025550150`. If `has_more` is `true`, see [Pagination](/fundamentals/pagination). ### Step 4: Check that the agent is ready Ask whether the agent can place calls right now. The Sample agent is always ready; one of your own agents reports every problem it has, such as being switched off in the console. [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls) ```bash curl https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "agent_readiness", "livemode": true, "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "ready": true, "reasons": [], "checked_at": "2026-10-21T09:30:00.000Z" } ``` This example from the API reference is a live agent; for the Sample agent in test mode, `agent_id` is `agent_1ZltiWzxrXmMprGD6q63` and `livemode` is `false`. **Next:** continue when `ready` is `true`. Otherwise each entry in `reasons` says what to fix in the console. ### Step 5: Place a test call Send the agent and a destination number. In test mode the destination chooses the simulated result: `+12025550100` answers and completes a conversation. `variables` are values the agent can use in the conversation; `metadata` is your own data, returned as sent. Each `POST /v1/calls` places a call: if a request times out, list your calls before sending it again, or send an optional `Idempotency-Key` so that a retry returns the same call (see [Idempotency](/fundamentals/idempotency)). [`POST /calls`](/api/calls#place-a-call) ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", "amount_due": "4,200" }, "metadata": { "crm_lead_id": "L-20931" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ agent_id: "agent_1ZltiWzxrXmMprGD6q63", to: "+12025550100", variables: { first_name: "Asha", amount_due: "4,200", }, metadata: { crm_lead_id: "L-20931", }, }), }); console.log(response.status, await response.json()); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", "amount_due": "4,200", }, "metadata": { "crm_lead_id": "L-20931", }, }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` `202` means the call passed every check and was accepted; it does not mean anyone answered. Other test numbers simulate other results: | Destination | Simulated result | `outcome` | | --- | --- | --- | | `+12025550100` | Answered and completed, with transcript, recording and analysis | `completed` | | `+12025550101` | No answer | `no_answer` | | `+12025550102` | Busy | `busy` | | `+12025550103` | Failed: the call could not be connected | `failed` | | `+12025550104` | Answered by voicemail; nobody spoke | `no_response` | | `+12025550105` | Completed after a longer conversation | `completed` | | `+12025550106` | The person hangs up early; short duration | `completed` | | Any other valid number | Completed | `completed` | **Next:** keep the call's `id` and read the call until it is settled. ### Step 6: Retrieve the call Read the call. A test call reaches `status: ended` within about 20 seconds and `settled: true` within about 30 seconds; `settled` means everything about the call is final: outcome, duration, artifacts and charge. [`GET /calls/{call_id}`](/api/calls#retrieve-a-call) ```bash curl https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "object": "call", "livemode": true, "status": "ended", "outcome": "completed", "end_reason": "hangup", "direction": "outbound", "source": "api", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "campaign_id": null, "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "from": "+918000000001", "to": "+919000000001", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": "2026-10-21T09:32:14.000Z", "duration_seconds": 118, "billable_minutes": 2, "cost": { "amount": 600, "currency": "INR" }, "artifacts": { "transcript": { "state": "ready" }, "recording": { "state": "ready" }, "analysis": { "state": "ready" } }, "settled": true, "settled_at": "2026-10-21T09:41:02.000Z" } ``` This example from the API reference is a settled live call; a settled test call looks the same except that `livemode` is `false` and `cost.amount` is always `0`. **Next:** repeat this request every few seconds until `settled` is `true`. In production, let RelationsAI tell you instead (last step). ### Step 7: Read the transcript Once `artifacts.transcript.state` is `ready`, read the conversation turn by turn, verbatim. This needs `transcripts:read`, which is in the default scopes of a new key. [`GET /calls/{call_id}/transcript`](/api/transcripts#retrieve-a-call-transcript) ```bash curl https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "call_transcript", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "turns": [ { "index": 0, "speaker": "agent", "text": "Hello, this is an AI assistant calling from Acme Lending. Am I speaking with Asha?", "start_ms": 400, "end_ms": 4900 }, { "index": 1, "speaker": "caller", "text": "Yes, speaking.", "start_ms": 5600, "end_ms": 6500 } ] } ``` In test mode the transcript is a fixture with `"simulated": true` and `"livemode": false`; this example from the API reference comes from a live call. **Next:** read the recording. ### Step 8: Get the recording link Ask for a short-lived link to the audio (about 15 minutes, see `expires_at`). This needs `recordings:read`. [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) ```bash curl https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "call_recording", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "url": "https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF8ySGQ4S3M0THE3TXczTng5UHo1UmMx", "expires_at": "2026-10-21T10:00:00.000Z", "content_type": "audio/ogg" } ``` The link works without an API key, so treat it like a password: use it right away, do not log or store it, and ask for a new one when you need it. It also stops working as soon as the key that obtained it is revoked or expires, loses `recordings:read`, or its creator leaves the workspace. In test mode the audio is a short synthetic `audio/wav` file and the recording has `"simulated": true`. **Next:** download the audio with a plain `GET` of `url` (no `Authorization` header), or go on to the analysis. ### Step 9: Read the analysis Read the summary, the fields the agent extracts (configured in the console) and whether the call met its goal. This needs `analysis:read`. [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis) ```bash curl https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "call_analysis", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "summary": { "state": "ready", "text": "The customer confirmed the renewal and asked for a callback on Friday afternoon." }, "extracted_fields": [ { "name": "wants_renewal", "type": "boolean", "state": "ready", "value": true }, { "name": "callback_day", "type": "string", "state": "ready", "value": "Friday" } ], "goal_met": true } ``` In test mode the analysis is a fixture with `"simulated": true`. More in [Retrieving recordings and transcripts](/guides/retrieving-recordings-and-transcripts) and [Working with analysis results](/guides/working-with-analysis-results). **Next:** stop polling and receive events instead. ### Step 10: Get notified instead of polling Register a webhook endpoint and RelationsAI sends your server a signed `call.ended` event when the call ends and `call.settled` when everything is final, in test mode too. Webhook delivery is a separate private preview: check that `capabilities.webhooks` is `true` in the workspace response from step 2 before you build on it. Follow [Receiving webhooks](/guides/receiving-webhooks), then [Verifying webhook signatures](/guides/verifying-webhook-signatures). ## Run the whole flow The same flow as one tested program in cURL, JavaScript and Python: it checks the key (and refuses anything but a test key), finds the Sample agent, checks readiness, places a call to `+12025550100`, polls until the call settles and prints the transcript, the recording state and the analysis. Complete runnable example: [cURL script](/examples/getting-started/quickstart.sh) · [JavaScript](/examples/getting-started/quickstart.mjs) · [Python](/examples/getting-started/quickstart.py) ## What can go wrong - [`authentication_required`](/errors/authentication_required): No `Authorization` header, or not in the form `Bearer `. Check that the `Authorization` header carries your key in place of ``. - [`invalid_api_key`](/errors/invalid_api_key): The key is mistyped, truncated or not an API key. Copy it again from where you stored it. If it is lost, create a new key: secrets are shown only once. - [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported): An older `sk_live_` key. Create a scoped key under **Settings → API Keys**. - [`api_access_not_enabled`](/errors/api_access_not_enabled): The workspace is not in the API preview. Write to [support@relationsai.com](mailto:support@relationsai.com) to request access. - [`insufficient_scope`](/errors/insufficient_scope) on `POST /calls`: The key has no `calls:write`. Create a test key with **Place calls** ticked. `details.required_scopes` lists what is missing. - [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser): A live key was sent from browser code. Use a test key, and call the API from a server. - [`resource_not_found`](/errors/resource_not_found): The id is wrong, from another workspace or from the other mode. The Sample agent exists only in test mode; copy ids from your own responses. - [`agent_not_ready`](/errors/agent_not_ready): One of your own agents cannot call yet. Read its readiness `reasons` and fix them in the console, or use the Sample agent. - [`dnc_listed`](/errors/dnc_listed) or [`consent_required`](/errors/consent_required): Your test-mode do-not-call list or consent rules refuse the number. Test mode applies these exactly as live mode. Use another test number, or see [Consent and do-not-call](/guides/consent-and-dnc). - [`rate_limited`](/errors/rate_limited): Too many requests in a minute. Wait for `Retry-After` seconds. See [Rate limits](/fundamentals/rate-limits). Every error has the same shape and a `request_id`; see [Error handling](/fundamentals/error-handling). ## Check that it worked - The call's `settled` is `true`, `outcome` is `completed` and all three `artifacts` are `ready`. - Every object you received has `"livemode": false`, and the call's `cost.amount` is `0`. Nothing was charged. - Test-mode calls are kept apart from live data: they do not appear in the console's call history or to live keys. Read them with your test key. ## Next steps - [Authentication](/getting-started/authentication) and [API keys](/getting-started/api-keys): scopes, expiry, rotation. - [Test mode](/getting-started/test-mode): every simulated outcome and what is enforced. - [Making an outbound call](/guides/making-an-outbound-call) and [Tracking call progress](/guides/tracking-call-progress) in depth. - [Receiving webhooks](/guides/receiving-webhooks) instead of polling. - [Going live](/guides/going-live) when you are ready for real calls. --- Source: /getting-started/authentication # Authentication Every request to the RelationsAI API carries an API key in the `Authorization` header. This page explains the header format, how the key decides which workspace and mode you act in, and what the API returns when authentication fails. For the exact rules, see [Authentication](/fundamentals/authentication) in API fundamentals. ## Before you begin You need an API key, created in the RelationsAI platform under **Settings → API Keys** (see [Get an API key](/getting-started/api-keys#get-an-api-key)) and a workspace that is enabled for the API preview. ## Sending your key Send the key as a Bearer token on every request, over HTTPS: ```http Authorization: Bearer rai_test_... ``` ```bash curl https://voice-api.relationsai.com/v1/workspace \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/workspace", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/workspace", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` [`GET /workspace`](/api/workspace#retrieve-the-workspace) is the best first request: it answers `200 OK` with the workspace, the key's name, environment, effective scopes and expiry, the workspace's capabilities and the limits that apply. It needs no scope beyond `workspace:read`, which every key has, and it never changes anything. ```json { "id": "ws_7Hk2Lm9QxR4tB8vN3cZp6W", "object": "workspace", "livemode": false, "name": "Acme Lending", "api_key": { "id": "key_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "name": "CRM sync (test)", "environment": "test", "scopes": ["workspace:read", "agents:read", "numbers:read", "calls:read", "calls:write", "transcripts:read", "recordings:read", "analysis:read"], "created_at": "2026-10-20T08:15:00.000Z", "expires_at": null } } ``` Only RelationsAI API keys are accepted. Console sign-in sessions, other tokens and older `sk_live_` keys are refused. ## Workspace and mode A key belongs to exactly one workspace and one mode, and there is no header or parameter to pick either: - `rai_test_...` keys act in **test mode** (`"livemode": false`): simulated calls, no charges. - `rai_live_...` keys act in **live mode** (`"livemode": true`): real calls, charged to the workspace. Objects created with a test key exist only in test mode, and a live key can never read them (and the other way round). An id from the other mode, or from another workspace, answers [`resource_not_found`](/errors/resource_not_found), exactly as if it did not exist. See [Environments](/getting-started/environments). ## Scopes Each key carries scopes such as `calls:read` or `campaigns:write`, chosen when it is created. Each operation needs one scope, shown in the API reference. A request outside the key's scopes fails with [`insufficient_scope`](/errors/insufficient_scope), and `details.required_scopes` says which scope was missing. See [API keys](/getting-started/api-keys#scopes) for the full list. A key can never do more than the member who created it is allowed to do **right now**. If that member's role changes, the key's effective scopes shrink with it; if the member leaves the workspace, the key stops working. Both cases answer [`insufficient_permissions`](/errors/insufficient_permissions). ## Keep keys on your server API keys are secrets. Use them only from server-side code: never in a web page, a mobile app, a browser extension or anything else you ship to users. The API enforces this for live keys: a request that carries an `Origin` header (which browsers add) with a live key is refused with [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser), before the key is even checked. Test keys can be used from browser-based tools. If your workspace has an IP allowlist in its security settings, API requests from other addresses are refused with [`ip_not_allowed`](/errors/ip_not_allowed). ## Authentication errors - [`authentication_required`](/errors/authentication_required) (401): No `Authorization` header, a scheme other than `Bearer`, or an empty token. Send `Authorization: Bearer `. - [`invalid_api_key`](/errors/invalid_api_key) (401): A typo, a truncated key, extra quotes or whitespace, or a credential that is not an API key. Copy the key again from where you stored it; if it is lost, create a new one. - [`api_key_expired`](/errors/api_key_expired) (401): The key reached its expiry, or its rotation grace period ended. Switch to the replacement key, or create a new key. - [`api_key_revoked`](/errors/api_key_revoked) (401): An admin revoked the key. Ask an admin for a new key. - [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported) (401): An older `sk_live_` key. Create a scoped key under **Settings → API Keys**. - [`insufficient_scope`](/errors/insufficient_scope) (403): The key lacks the scope the operation needs. Create a key with the scopes in `details.required_scopes`. - [`insufficient_permissions`](/errors/insufficient_permissions) (403): The key's creator no longer has that permission, or left the workspace. Ask an admin to create a new key from an account that holds the permission. - [`api_access_not_enabled`](/errors/api_access_not_enabled) (403): The workspace is not enabled for the API preview. Write to [support@relationsai.com](mailto:support@relationsai.com). - [`ip_not_allowed`](/errors/ip_not_allowed) (403): The workspace IP allowlist does not include your server. Ask an admin to add your server's address in the console security settings. - [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser) (403): A live key sent from browser code. Call the API from your server; use a test key in browser tools. None of these errors did anything: retrying the same request will fail the same way until you fix the cause. If the API cannot check a key because of a temporary problem on our side, it refuses the request with [`service_unavailable`](/errors/service_unavailable) rather than letting it through; retry after `Retry-After` seconds. ## Check that it works Run the request above. A `200 OK` with the expected `name`, `livemode` and `api_key.scopes` means the key, the workspace and the mode are right. Then continue with the [Quickstart](/getting-started/quickstart). --- Source: /getting-started/api-keys # API keys API keys are created and managed in the RelationsAI platform, under **Settings → API Keys**; the API itself cannot create, rotate or revoke keys. This page covers key types, scopes, expiry, rotation, revocation and how to store keys safely. **Availability: Console only** Key management happens in the console only. ## Who can manage keys Workspace owners and admins can create, rotate and revoke keys. Other members cannot see the key list; they ask an admin for a key. A key belongs to the workspace it was created in and to the member who created it (see [Keys follow their creator's role](#keys-follow-their-creators-role)). ## Key types | Property | Test key | Live key | | --- | --- | --- | | Prefix | `rai_test_` | `rai_live_` | | Mode | Test mode, `"livemode": false` | Live mode, `"livemode": true` | | Calls | Simulated: never dials anyone | Real calls to real people | | Charges | Never | Charged to the workspace balance | | Data | Test-mode objects only | Live objects only | | Expiry | 30, 90, 180 or 365 days, or never | 30, 90, 180 or 365 days; always expires | | Browser use | Allowed from browser-based tools | Refused | A live key sent from browser code is refused with [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser). A key is the prefix followed by 40 letters and digits. Build and test with a test key; create a live key only for the system that will place real calls. ## Get an API key You create keys in the RelationsAI platform. You need to be an owner or admin of the workspace. ### Step 1: Sign in to the platform Go to [voice.relationsai.com](https://voice.relationsai.com) and sign in to the workspace the key is for. ### Step 2: Open Settings Select **Settings** in the side navigation. ### Step 3: Open the API Keys tab Select the **API Keys** tab. It lists the workspace's keys with their name, environment, permissions, expiry and when each was last used. ### Step 4: Create the key Select **Create API key**, then: 1. **Name** the key after the system that will use it, for example `CRM sync`, so you know what breaks if you revoke it. Names are 1 to 64 characters. 2. Choose the **Environment**: **Test** to build and try things for free, **Live** for real calls. 3. Choose the **Permissions** (scopes). Read scopes are ticked by default; write scopes, such as **Place calls** (`calls:write`), must be added. Scopes your own role cannot grant are shown as unavailable. 4. Choose when it **Expires**. 5. Select **Create API key**. ### Step 5: Copy and store the key The full key is shown **once**. Copy it, store it on your server (see [Storing keys safely](#storing-keys-safely)) and confirm that you have stored it. Then send it in the `Authorization` header: `Authorization: Bearer `. If the tab says the API is not yet enabled for your workspace, keys cannot be created yet: email [support@relationsai.com](mailto:support@relationsai.com) to join the preview. The same keys also appear on the **API & Developers** page, next to webhooks. After that, the console shows only the key's prefix and last four characters, with its name, environment, permissions, status, who created it and when, its expiry, when and from which address it was last used, and how many requests and errors it had in the last 24 hours. Check a new key with [`GET /workspace`](/api/workspace#retrieve-the-workspace): `api_key` in the response shows its id, name, environment, effective scopes and expiry. ## Scopes Grant only what an integration needs. Each operation needs one scope, shown in the [API reference](/api) for that operation. A request without it fails with [`insufficient_scope`](/errors/insufficient_scope). | Scope | Allows | Default | | --- | --- | --- | | `workspace:read` | Read the workspace, the current key and its limits. | Every key | | `agents:read` | List and retrieve agents and check their readiness. | Yes | | `numbers:read` | List and retrieve phone numbers. | Yes | | `calls:read` | List and retrieve calls, with status, outcome, timing and cost. | Yes | | `calls:write` | Place outbound calls. Live calls are charged to the workspace. | No | | `transcripts:read` | Read call transcripts, verbatim. | Yes | | `recordings:read` | Get short-lived links to call recordings. | Yes | | `analysis:read` | Read call summaries, extracted fields and goal outcome. | Yes | | `campaigns:read` | List and retrieve campaigns and their recipients. | Yes | | `campaigns:write` | Create and edit campaigns, add recipients, and start, pause, resume and stop them. Live campaigns are charged. | No | | `contacts:read` | List and retrieve contacts, including consent and custom fields. | Yes | | `contacts:write` | Create, update and delete contacts and record consent. | No | | `dnc:read` | List and look up do-not-call entries. | Yes | | `dnc:write` | Add numbers to and remove numbers from the do-not-call list. Removals are audited. | No | | `webhooks:read` | List and retrieve webhook endpoints and their deliveries. | Yes | | `webhooks:write` | Create, update, delete and test webhook endpoints, rotate their secrets and retry deliveries. | No | Some typical sets: | Integration | Scopes | | --- | --- | | Reporting or BI export | `calls:read`, `transcripts:read`, `analysis:read`, `campaigns:read` | | CRM that triggers calls and stores results | `agents:read`, `calls:read`, `calls:write`, `transcripts:read`, `recordings:read`, `analysis:read` | | Contact and consent sync | `contacts:read`, `contacts:write`, `dnc:read`, `dnc:write` | | Campaign scheduler | `agents:read`, `campaigns:read`, `campaigns:write`, `contacts:read` | No key can change workspace settings, compliance settings, members, billing, integrations or other keys: those stay in the console. ## Keys follow their creator's role A key's **effective scopes** are the scopes it was created with, limited to what the member who created it is allowed to do right now. This is checked on every request, so a change takes effect immediately: - **The creator's role is reduced.** The key loses the scopes the new role cannot grant. An operation that needs one of them fails with [`insufficient_permissions`](/errors/insufficient_permissions) (an operation that needs a scope the key never had still fails with [`insufficient_scope`](/errors/insufficient_scope)). `GET /v1/workspace` shows the effective scopes. - **The creator leaves the workspace or is blocked.** Every request with the key fails with [`insufficient_permissions`](/errors/insufficient_permissions). Plan for this: when someone who created production keys changes role or leaves, have an admin create replacement keys from an account that will stay, deploy them, then revoke the old ones. ## Expiry and rotation Every live key expires after 30, 90, 180 or 365 days; a test key can also be set to never expire. After the expiry, requests fail with [`api_key_expired`](/errors/api_key_expired). Rotate before the date: `expires_at` from `GET /v1/workspace` tells your monitoring when. **Rotating** a key in the console creates a replacement with the same name, scopes and environment, shown once like a new key. You choose how long the old key keeps working: | Grace period | Use when | | --- | --- | | Immediately | The old key may have leaked. | | 1 hour | One service, deployed quickly. | | 24 hours | Several services, or a slower release process. | | 7 days | Many systems, or teams that deploy weekly. | During the grace period both keys work and the old one shows as **Expiring**; afterwards the old key answers [`api_key_expired`](/errors/api_key_expired). A good rotation: 1. Rotate the key in the console with a grace period long enough for your deploy. 2. Put the new secret in your secret manager and deploy it everywhere the old key is used. 3. Check that requests succeed with the new key (`GET /v1/workspace` shows the new `api_key.id`), and that the old key's **Last used** time in the console stops moving. 4. Let the grace period run out, or revoke the old key. The replacement key belongs to the admin who rotates it, so it follows that person's role from then on. ## Revoking a key Revoke a key in the console as soon as it may have leaked or is no longer needed. Revocation is immediate and cannot be undone: the next request with the key fails with [`api_key_revoked`](/errors/api_key_revoked), and recording links the key obtained stop working too. If the key was in use, rotate first (with **Immediately** if it leaked) so the replacement is ready. ## Older keys Keys that start with `sk_live_` were issued before API v1. They predate scopes and test mode, and they keep working where they worked before, but API v1 refuses them with [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported). The console lists them as legacy keys and offers **Create a scoped key instead**. They cannot be rotated; create a scoped key, move to it, then revoke the old one. ## Storing keys safely - Keep keys in a secret manager or in environment variables set by your deployment, read at runtime. The examples in these docs show `` where your key goes. - Never put a key in source control, client-side code, mobile apps, logs, error reports, analytics, URLs, or support emails. Log the key's id (`key_...`) or the request id instead. - Use a separate key per system and per environment, so you can rotate or revoke one without touching the others. - If a key is exposed, rotate it with **Immediately**, then check its recent use in the console. More in [Security practices](/fundamentals/security-practices). --- Source: /getting-started/environments # Environments RelationsAI has two modes, test and live, selected by the key you send rather than by a different URL. This page explains what each mode does, how data is kept apart and how to tell which mode an object belongs to. ## One base URL, two modes All requests go to `https://voice-api.relationsai.com/v1`. A `rai_test_` key operates in test mode and a `rai_live_` key in live mode; there is no header, parameter or separate host to choose a mode. | Property | Test mode | Live mode | | --- | --- | --- | | Key | `rai_test_...` | `rai_live_...` | | `livemode` | `false` | `true` | | Calls | Simulated; the number picks the result | Real calls | | Charges | Never; no balance or spend limit checks | Per started minute of talk time | | Daily API call limit | None | Applies | | Compliance checks | Enforced, on test-mode data | Enforced | | Webhooks | To test-mode endpoints | To live endpoints | | Rate limits | Counted separately | Counted separately | | Sample agent and number | Available | Not available | The compliance checks are the do-not-call list, consent, calling hours and the per-number daily limit. The daily API call limit is described in [Rate limits](/fundamentals/rate-limits#daily-call-limit); in test mode `limits.daily_call_limit` is `null`. ## The livemode field Every object, list item and event includes `"livemode": true` or `false`, so you can always tell which mode produced it. Check it where it matters: a production worker can refuse to act on an event or an object with `"livemode": false`, and a test suite can assert that everything it touched was `false`. ## Data separation **Activity data is per mode.** Calls, campaigns and their recipients, contacts, do-not-call entries, webhook endpoints, events and webhook deliveries created in one mode exist only in that mode. A test key never sees live calls or contacts and cannot change them, and a live key never sees test data. An id from the other mode answers [`resource_not_found`](/errors/resource_not_found), exactly as an id that does not exist. Test-mode activity does not appear in the console's normal screens. This also means each mode has its **own** contacts, consent records and do-not-call list. A number on your live do-not-call list is not on your test list unless you add it there too. ## Shared configuration Agents and phone numbers are configured in the console and are visible, read-only, in both modes, so you test with the same agents you will use live. (`livemode` on an agent or a number tells you which key read it.) Test mode also adds a built-in **Sample agent** (`agent_1ZltiWzxrXmMprGD6q63`, always published and ready) and a sample caller number `+12025550150` (`pn_6Vhm3sFrB5UpmXAnsvmDu`), so a new workspace can start before it has agents or numbers of its own. A live key cannot see or use them. One difference for your own agents: in test mode an agent must be switched on and saved for calling in the console, but it does not need to be published or to have a phone number. In live mode it needs both. Check with [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls) using the key you will use. ## Using environments in your code - Read the key from configuration (for example an environment variable or a secret manager) and never branch on its value in business logic. Switching from test to live should be a configuration change, not a code change. - Give each environment its own key: test keys for local development, CI and staging; a live key only in production. - Give each environment its own webhook endpoint, and register test endpoints with a test key and live endpoints with a live key. - Store ids together with the mode they came from. A test-mode `call_...` id is meaningless to a live key. - At startup, call [`GET /workspace`](/api/workspace#retrieve-the-workspace) and check `livemode` against what the environment expects, so a misconfigured deploy fails loudly instead of placing real calls from staging (or simulated ones from production). When you are ready to switch, follow [Going live](/guides/going-live). --- Source: /getting-started/test-mode # Test mode **Availability: Test mode** Test mode lets you exercise the whole API without dialling a real phone or spending balance. Calls progress through realistic states on a compressed timeline and produce simulated transcripts, recordings and analysis, and webhooks fire just as they do in live mode. Every request made with a `rai_test_` key is in test mode. ## What test mode does and does not do Test mode **never** contacts a phone network, never places a real call, never charges your workspace and never reads or changes your live data. What is the same as live mode: - The same operations, request validation, error codes, scopes, idempotency and rate limits. - The same compliance checks, against your **test-mode** contacts and do-not-call list: a number on the test do-not-call list is refused with [`dnc_listed`](/errors/dnc_listed), a number that matches a contact without consent (when the workspace requires it) with [`consent_required`](/errors/consent_required) (a number with no contact record is not checked for consent), and calling hours and the per-number daily limit apply. As in live mode, campaigns are screened when they start and resume, not again while they run. - Webhook events, sent to test-mode endpoints with `"livemode": false`. What does not apply in test mode: - Balance, monthly spend limit and account status checks. - The daily limit on calls placed through the API. - Charges: `cost.amount` is always `0`. ## Test phone numbers The destination number chooses the simulated result. Use these fictional numbers: | Destination | Simulated result | `outcome` | | --- | --- | --- | | `+12025550100` | Answered and completed, with transcript, recording and analysis | `completed` | | `+12025550101` | No answer | `no_answer` | | `+12025550102` | Busy | `busy` | | `+12025550103` | Failed: the call could not be connected | `failed` | | `+12025550104` | Answered by voicemail; nobody spoke | `no_response` | | `+12025550105` | Completed after a longer conversation | `completed` | | `+12025550106` | The person hangs up early; short duration | `completed` | | Any other valid number | Completed | `completed` | Calls that are not answered (`+12025550101` to `+12025550103`) have no transcript, recording or analysis: their `artifacts` report `absent`. The voicemail case (`+12025550104`) has a recording but no transcript or analysis. Prefer the numbers above to any real number, even though test mode never dials it. ## Sample agent and number Every workspace has a **Sample agent** in test mode, id `agent_1ZltiWzxrXmMprGD6q63`, marked `"sample": true`, always switched on, published and ready. It calls from the sample number `+12025550150` (id `pn_6Vhm3sFrB5UpmXAnsvmDu`, also `"sample": true`). Both appear in [`GET /agents`](/api/agents#list-agents) and [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers) for test keys only; with a live key they answer [`resource_not_found`](/errors/resource_not_found). You can also test with your own agents. In test mode an agent must be switched on and saved for calling in the console; it does not need to be published or to have a phone number. ## Timing A test call reaches `status: ended` within about 20 seconds and `settled: true` within about 30 seconds of being accepted. The progress is computed from timestamps, so reading the call later gives the same result. `duration_seconds` is simulated too and does not match the wall-clock time the test call took (for example 42 seconds for `+12025550100`). Test-mode campaigns use test-mode contacts only. When you start one, each recipient gets a simulated call whose result is chosen by the recipient's number (the table above), and campaign events fire with `"livemode": false`. Pause and stop take effect immediately. ## Simulated results - **Transcripts** and **analysis** are fixtures marked `"simulated": true`. - **Recordings** are short synthetic audio files (`audio/wav`, about one second), served through the same short-lived link as live recordings, also with `"simulated": true`. - Every simulated object has `"livemode": false`. Do not use simulated content to test your own language processing: it is fixed sample content, not a real conversation. ## Test data Calls, campaigns, contacts, do-not-call entries and webhook endpoints that you create with a test key stay in test mode. They are invisible to live keys and to the console's normal screens. Use them freely: nothing in test mode affects real people or your balance. ## Moving to live mode Switch the key, not the code: deploy a `rai_live_` key, check `GET /v1/workspace` says `"livemode": true`, and make sure your live agents are published and ready. See [Going live](/guides/going-live). --- Source: /getting-started/core-concepts # Core concepts The API is built around a small set of objects. This page defines each one, how they relate and which identifier prefix it uses, so the rest of the documentation reads naturally. ```text Workspace (ws_) ├── API keys (key_) one mode each: test or live ├── Agents (agent_) configured in the console, read by the API ├── Phone numbers (pn_) the numbers agents call from ├── Contacts (ct_) people you call, with consent and custom fields │ └── Do-not-call entries (dnc_) numbers your workspace must not call ├── Calls (call_) one conversation each: transcript, recording, analysis ├── Campaigns (cmp_) one agent calling many recipients │ └── Recipients (rcp_) a contact in a campaign, with its latest call └── Webhook endpoints (we_) where events (evt_) are delivered (wd_) ``` ## Workspace (`ws_`) Your organisation in RelationsAI. Every key, object and charge belongs to one workspace, and nothing is ever shared between workspaces: another workspace's id answers [`resource_not_found`](/errors/resource_not_found). [`GET /workspace`](/api/workspace#retrieve-the-workspace) returns your workspace with its name, timezone, currency, the current key, its `capabilities` (what the API offers this workspace today) and its `limits`. Inside a workspace, every key works in one **mode**, test or live, and activity data never crosses between modes. Every object says which with `livemode`. See [Environments](/getting-started/environments). ## Agent (`agent_`) An AI voice agent: what the person called hears and how the conversation goes. Agents are created, configured, published and switched on in the console; a call always uses the agent's published version. The API lists agents, returns one, and checks whether one can place calls right now with [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls), which returns every problem it finds (for example `agent_not_published` or `no_outbound_number`). `status` is `active`, `paused`, `draft` or `unknown`; only `active` agents can call. Test mode adds the built-in Sample agent. ## Phone number (`pn_`) A number your workspace calls from, in E.164 format. Numbers are added and assigned to agents in the console. Each lists the agents that call from it (`outbound_agent_ids`) and the agent that answers it (`inbound_agent_id`). A call uses the agent's outbound number unless you choose one with `phone_number_id`. ## Contact (`ct_`) A person you may call, identified by phone number (unique per workspace and mode), with a name, language, whether they consented to be called (`consent_granted`), whether they are on the do-not-call list (`do_not_call`) and custom field values. When you call a number that matches a contact, the contact's name and custom fields are passed to the agent as call variables. [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) creates or updates a contact by phone number, which is the easiest way to sync from a CRM. ## Do-not-call entry (`dnc_`) A number your workspace must not call, in that mode. Calls to it are refused with [`dnc_listed`](/errors/dnc_listed), and campaigns leave it out when they start or resume. A campaign that is already running is not screened again: pause and resume it, or stop it, after adding a number. Matching ignores formatting. Each mode has its own list. ## Call (`call_`) One outbound phone conversation placed by an agent, from the API, the console or a campaign (`source`). A call moves through these statuses: | `status` | Meaning | | --- | --- | | `queued` | Accepted; being placed. `POST /v1/calls` returns the call in this status. | | `ringing` | Not reported today; see `capabilities.call_ringing_status`. | | `in_progress` | Connected. | | `ended` | Finished. `outcome` and `end_reason` say how. | | `unknown` | The state could not be confirmed for an unusually long time. Never a success. | `outcome` is `completed` (a conversation took place), `no_response` (answered but nobody spoke, including most voicemail), `no_answer`, `busy`, `failed` or `unknown`. After a call ends, its three **artifacts** become available, each with its own state (`pending`, `ready`, `absent` or `failed`): - the **transcript**, turn by turn and verbatim; - the **recording**, through a short-lived link; - the **analysis**: a summary, the fields the agent extracts, and whether the call met its goal. When outcome, duration, artifacts and charge are all final, `settled` becomes `true`. Treat a call's `outcome`, `duration_seconds` and `cost` as final only once it is settled. ## Campaign (`cmp_`) and recipient (`rcp_`) A campaign calls a list of recipients with one agent, a number of calls at a time (`concurrency_limit`) and an optional budget cap. The budget cap pauses the campaign when spend reaches it; it is checked about every 15 minutes, so spend can exceed the cap by the calls placed in that time. Each recipient is a contact in the campaign, with what happened to them in the current or latest run. | `status` | Meaning | | --- | --- | | `draft` | Created, never started. Recipients can be added only now. | | `running` | Calling recipients. | | `paused` | No new calls; resume continues the same run. Can also happen automatically, for example when the budget cap is reached. | | `stopped` | Ended by request. | | `completed` | The run called every recipient. | | `failed` | The run ended in failure. | Every start and resume screens recipients against the do-not-call list, consent and the per-number daily limit; a running campaign is not screened again. `dnc_screening` is `enforced`, or `disabled_in_console` when a person switched do-not-call screening off for that campaign in the console, in which case the API refuses to start or resume it. Campaigns and contacts carry an `ETag` for safe updates (see [Concurrency](/fundamentals/concurrency)). ## Webhook endpoint (`we_`), event (`evt_`) and delivery (`wd_`) A webhook endpoint is an HTTPS URL on your server that receives events of one mode. An event is a signed JSON notification such as `call.ended`, `call.settled` or `campaign.completed`, carrying a snapshot of the object. A delivery is one event's attempts to reach one endpoint, which you can list and retry. See [Webhooks](/fundamentals/webhooks). ## Identifiers Ids are strings with a type prefix, at most 64 characters, for example `call_6Vb3Nm8Qw2Er5Ty9Ui4Op7`. Treat them as opaque: store them as strings and do not parse them or build them yourself. Every response also has a request id (`req_...`) in the `X-Request-Id` header. See [Data types](/fundamentals/data-types). --- Source: /getting-started/api-availability # API availability **Availability: Preview** API v1 is in private preview. This page lists what you can do through the API today, what is done in the console and what is planned, so you can design your integration around what exists. ## Availability labels These labels appear throughout the docs: - **Availability: Available** works in API v1 and is not expected to change incompatibly. During the private preview your workspace still has to be enabled to use it. - **Availability: Preview** works in API v1 for workspaces in the preview; details may still change before general availability. Changes are announced in the [changelog](/resources/changelog). - **Availability: Test mode** works with test keys against simulated data. - **Availability: Console only** done in the RelationsAI console, not through the API. - **Availability: Planned** on the roadmap, not available yet. Every operation works in both test mode and live mode, with the differences described in [Test mode](/getting-started/test-mode). ## Available in v1 Every operation of API v1, grouped as in the [API reference](/api): ### Workspace - [`GET /workspace`](/api/workspace#retrieve-the-workspace) Retrieve the workspace. **Availability: Available** ### Agents - [`GET /agents`](/api/agents#list-agents) List agents. **Availability: Available** - [`GET /agents/{agent_id}`](/api/agents#retrieve-an-agent) Retrieve an agent. **Availability: Available** - [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls) Check whether an agent can place calls. **Availability: Available** ### Phone numbers - [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers) List phone numbers. **Availability: Available** - [`GET /phone-numbers/{phone_number_id}`](/api/phone-numbers#retrieve-a-phone-number) Retrieve a phone number. **Availability: Available** ### Calls - [`POST /calls`](/api/calls#place-a-call) Place a call. **Availability: Preview** - [`GET /calls`](/api/calls#list-calls) List calls. **Availability: Available** - [`GET /calls/{call_id}`](/api/calls#retrieve-a-call) Retrieve a call. **Availability: Available** - [`GET /calls/{call_id}/transcript`](/api/transcripts#retrieve-a-call-transcript) Retrieve a call transcript. **Availability: Available** - [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) Retrieve a call recording link. **Availability: Preview** - [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis) Retrieve a call analysis. **Availability: Available** - [`GET /recordings/{recording_token}`](/api/recordings#download-recording-audio) Download recording audio. **Availability: Preview** ### Campaigns - [`POST /campaigns`](/api/campaigns#create-a-campaign) Create a campaign. **Availability: Available** - [`GET /campaigns`](/api/campaigns#list-campaigns) List campaigns. **Availability: Available** - [`GET /campaigns/{campaign_id}`](/api/campaigns#retrieve-a-campaign) Retrieve a campaign. **Availability: Available** - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign) Update a campaign. **Availability: Available** - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients) Add recipients. **Availability: Available** - [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients) List recipients. **Availability: Preview** - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign) Start a campaign. **Availability: Preview** - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign) Pause a campaign. **Availability: Available** - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign) Resume a campaign. **Availability: Preview** - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign) Stop a campaign. **Availability: Available** ### Contacts - [`POST /contacts`](/api/contacts#create-a-contact) Create a contact. **Availability: Available** - [`GET /contacts`](/api/contacts#list-contacts) List contacts. **Availability: Available** - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) Create or update a contact by phone number. **Availability: Available** - [`GET /contacts/{contact_id}`](/api/contacts#retrieve-a-contact) Retrieve a contact. **Availability: Available** - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact) Update a contact. **Availability: Available** - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact) Delete a contact. **Availability: Available** ### Do-not-call list - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list) Add a number to the do-not-call list. **Availability: Available** - [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries) List or look up do-not-call entries. **Availability: Available** - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list) Remove a number from the do-not-call list. **Availability: Available** ### Webhook endpoints - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint) Create a webhook endpoint. **Availability: Preview** - [`GET /webhook-endpoints`](/api/webhook-endpoints#list-webhook-endpoints) List webhook endpoints. **Availability: Preview** - [`GET /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#retrieve-a-webhook-endpoint) Retrieve a webhook endpoint. **Availability: Preview** - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint) Update a webhook endpoint. **Availability: Preview** - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint) Delete a webhook endpoint. **Availability: Preview** - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret) Rotate a webhook signing secret. **Availability: Preview** - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event) Send a test event. **Availability: Preview** - [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries) List deliveries. **Availability: Preview** - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery) Retry a delivery. **Availability: Preview** The event types delivered by webhooks are listed on the [Event types](/api/events) page: `call.ended`, `call.settled`, `campaign.started`, `campaign.paused`, `campaign.resumed`, `campaign.stopped`, `campaign.completed`, `campaign.failed` and `webhook_endpoint.test`. ## Console only **Availability: Console only** These are done in the RelationsAI console: - Creating, configuring, testing and publishing agents, and switching them on or off. - Adding phone numbers and assigning them to agents. - Creating, rotating and revoking API keys (see [API keys](/getting-started/api-keys)). - Members, roles, billing, balance and spend limits. - Workspace settings: calling hours, consent policy, security settings such as the IP allowlist, and custom field definitions for contacts. ## Not offered in v1 The workspace object reports these as `capabilities`, so your code can check instead of discovering them from errors. Today each is `false`: | Capability | What `false` means | | --- | --- | | `agent_editing` | Agents cannot be created or edited through the API. **Availability: Planned** | | `call_ringing_status` | Calls go from `queued` straight to `in_progress` or `ended`; there is no separate `ringing` status. | | `live_transcript` | Transcripts are available after the call ends, not during it. | | `sentiment_analysis` | Analysis has a summary, extracted fields and the goal outcome, without sentiment. | | `end_call` | A call in progress cannot be hung up through the API. | | `inbound_calls` | Inbound calls do not appear in the call list. | `webhooks` says whether webhook delivery, a separate private preview switched on for the platform as a whole, is available: when it is `false`, endpoints cannot be created, test events and retries answer [`webhooks_not_enabled`](/errors/webhooks_not_enabled), and no events are sent. Read the capabilities with [`GET /workspace`](/api/workspace#retrieve-the-workspace). ## Planned **Availability: Planned** Creating, configuring and publishing agents through the API. Until then, agents are a console prerequisite: build and publish them in the console, then use them from the API. Nothing else is announced. New capabilities are added to the [changelog](/resources/changelog) when they ship. ## Getting access During the private preview, the API is enabled per workspace. A workspace that is not enabled answers every authenticated request with [`api_access_not_enabled`](/errors/api_access_not_enabled) (problems with the path, the body or the key are still reported first), and the platform's **Settings → API Keys** tab shows a preview notice instead of key creation. To request access, email [support@relationsai.com](mailto:support@relationsai.com) from an owner or admin account, with your workspace name and what you plan to build. --- Source: /guides/creating-and-preparing-agents # Creating and preparing agents **Availability: Console only** Agents are created, configured and published in the RelationsAI console. Through the API you read your agents and check whether each one is ready to place calls. This guide covers both halves so your integration only dials with an agent that can actually call. By the end you will have the `agent_...` id to use in calls and campaigns, and a readiness check you can run before every batch of calls or as part of a deploy. ## Before you begin | You need | Why | |---|---| | Access to the RelationsAI console as a member who can edit agents | Agents are built and published there | | An API key with `agents:read` | To list agents and check readiness (every new key has it by default) | | A test key (`rai_test_...`) to start with | Test mode has a built-in Sample agent, so you can follow this guide before your own agent is ready | ## Build the agent in the console An agent is what the person you call hears: its instructions, voice, language and goal. In the console: 1. **Create the agent** and write its instructions. The instructions must tell the people the agent calls that they are speaking with an AI: an agent without that disclosure cannot be published. Put it at the start, so it is the first thing callers hear. 2. **Choose a voice, language and call settings**, and configure the fields you want extracted from each conversation and the goal the call should achieve. These become the [analysis results](/guides/working-with-analysis-results) of each call. 3. **Assign a phone number for outbound calls.** A call shows this number to the person called. Numbers are added and assigned in the console. 4. **Publish the agent and switch it on.** Calls always use the agent's published version. Editing a draft does not change calls until you publish again. In test mode the **Sample agent** (`agent_1ZltiWzxrXmMprGD6q63`) and the sample number `+12025550150` are always available and always ready, so every example in these guides works with a brand-new workspace. They do not exist in live mode. See [Test mode](/getting-started/test-mode). ## List agents from the API [`GET /agents`](/api/agents#list-agents) returns your agents, newest first. Agents are shared configuration: a test key and a live key see the same agents (plus the Sample agent in test mode). ```bash curl -X GET "https://voice-api.relationsai.com/v1/agents?limit=100" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/agents?limit=100", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const agents = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/agents", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, }, timeout=30, ) agents = response.json() ``` Response `200` (one agent shown): ```json { "object": "list", "data": [ { "id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "object": "agent", "livemode": false, "name": "Loan renewal reminder", "description": "Reminds customers that their loan is due for renewal and books a callback.", "status": "active", "published": true, "published_version": 4, "is_default": true, "sample": false, "created_at": "2026-09-02T10:12:44.000Z", "updated_at": "2026-10-18T14:03:10.000Z" } ], "has_more": false, "next_cursor": null } ``` Complete runnable example: [cURL script](/examples/guides/creating-and-preparing-agents/list-agents.sh) · [JavaScript](/examples/guides/creating-and-preparing-agents/list-agents.mjs) · [Python](/examples/guides/creating-and-preparing-agents/list-agents.py) What the fields mean: | Field | Meaning | |---|---| | `status` | `active` agents are switched on. `paused` and `draft` agents cannot place calls. Open enum: treat any other value as "not callable". | | `published` | `true` when the agent has a published version. `null` means the publication state could not be read right now: use the readiness check for a definite answer. | | `published_version` | The version calls use, when known. | | `is_default` | The workspace's default agent in the console. The API never picks an agent for you: `agent_id` is always required when you place a call. | | `sample` | `true` only for the built-in Sample agent in test mode. | To read one agent, use [`GET /agents/{agent_id}`](/api/agents#retrieve-an-agent). ## Check readiness [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls) checks, right now, everything about the agent and the workspace that would stop a call, and returns **every** problem found rather than the first one. The example exits with status 1 when the agent is not ready, so you can use it as a gate in a deploy or before a batch job. ```bash curl -X GET https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const readiness = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", headers={ "Authorization": "Bearer ", }, timeout=30, ) readiness = response.json() ``` Complete runnable example: [cURL script](/examples/guides/creating-and-preparing-agents/check-readiness.sh) · [JavaScript](/examples/guides/creating-and-preparing-agents/check-readiness.mjs) · [Python](/examples/guides/creating-and-preparing-agents/check-readiness.py) A ready agent returns `200` with an empty `reasons` list: ```json { "object": "agent_readiness", "livemode": true, "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "ready": true, "reasons": [], "checked_at": "2026-10-21T09:30:00.000Z" } ``` An agent that is not ready also returns `200`, with `ready: false` and one entry per problem: ```json { "object": "agent_readiness", "livemode": true, "agent_id": "agent_7Yx2Wq9Lp4Mn8Bv3Kc6Hd1", "ready": false, "reasons": [ { "code": "agent_not_published", "message": "Publish the agent in the console before placing calls." }, { "code": "no_outbound_number", "message": "Assign a phone number to this agent for outbound calls in the console." } ], "checked_at": "2026-10-21T09:30:00.000Z" } ``` Each reason code and what a console admin does about it: | `reasons[].code` | Meaning | Fix in the console | |---|---|---| | `agent_paused` | The agent is switched off | Switch the agent on | | `agent_not_connected` | The agent has not been saved for calling yet | Save the agent; if the reason stays, contact support | | `agent_not_published` | No published version, or its live version is paused | Publish the agent | | `no_outbound_number` | No phone number is assigned to this agent for outbound calls | Assign an outbound number | | `calling_not_configured` | The workspace has no telephony account or no number | Finish calling setup (Numbers) | | `telephony_disconnected` | The workspace's telephony connection is not working | Reconnect the telephony account | | `account_inactive` | The trial ended or the account is suspended | Upgrade, or contact support | | `spend_limit_reached` | The monthly spend limit set by an admin is reached | Raise the limit | | `insufficient_balance` | The balance is too low to start a call | Top up the balance | The list is an open enum: show `message` to an operator for codes you do not recognise. Never parse `message`. > **Readiness is not a guarantee for a specific number:** Readiness checks the agent and the workspace. It does not check per-destination rules: the do-not-call list, consent, calling hours and the per-number daily limit are applied to each call when it is placed (see [Consent and DNC](/guides/consent-and-dnc)). A ready agent can still be refused for a particular number, and readiness can change at any time, so always handle errors from [`POST /calls`](/api/calls#place-a-call) too. In test mode the Sample agent is always ready. Your own agents are checked for configuration reasons only (agent and calling setup); balance, spend and account checks apply in live mode. ## Configuring agents through the API **Availability: Planned** Creating, editing and publishing agents through the API is planned for a later version. Today the workspace's `capabilities.agent_editing` (from [`GET /workspace`](/api/workspace#retrieve-the-workspace)) is `false`; check it rather than assuming. Until then, keep agent changes in the console and treat the API's agent list as read-only configuration. ## What can go wrong - **Placing a call with an agent that cannot call**: [`agent_not_ready`](/errors/agent_not_ready) (422). `details.reasons` uses the readiness codes above. Fix the agent in the console, then send the same request again (nothing was dialled). - **The agent id is unknown, malformed, from another workspace, or the Sample agent with a live key**: [`resource_not_found`](/errors/resource_not_found) (404). Use an id from `GET /agents` with a key of the same workspace and mode. - **The agent has several outbound numbers**: [`phone_number_required`](/errors/phone_number_required) (422). Send `phone_number_id` with the call, or leave one outbound number on the agent. - **The key lacks `agents:read`**: [`insufficient_scope`](/errors/insufficient_scope) (403). Ask an admin for a key with `agents:read`. ## Next steps - [Making an outbound call](/guides/making-an-outbound-call) with the agent you just checked. - [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers) to choose a caller number for `phone_number_id`. --- Source: /guides/making-an-outbound-call # Making an outbound call **Availability: Preview** This guide shows how to place a single outbound call: choosing the agent and destination, passing variables the agent can use and metadata you get back, making the request safe to retry, and handling the reasons a call can be refused. You will send one request, get back a call in status `queued`, and learn exactly what that response does and does not promise. Placing a call is a [chargeable operation](#charges) in live mode, so the guide is careful about retries. ## Before you begin - An API key with `calls:write` (and `calls:read` to follow the call afterwards). Start with a test key: test mode never dials anyone and never charges. - A ready agent. In test mode you can use the built-in Sample agent, `agent_1ZltiWzxrXmMprGD6q63`; for your own agent see [Creating and preparing agents](/guides/creating-and-preparing-agents). - Your key in place of `` in the examples. Every request goes to `https://voice-api.relationsai.com/v1`. - For the cURL examples in these guides: bash, `jq`, and curl 7.76 or newer (for `--fail-with-body`). In test mode the destination number chooses what happens, so you can try every outcome on purpose: | `to` | Simulated result | |---|---| | `+12025550100` | Answered and completed, with transcript, recording and analysis | | `+12025550101` | No answer | | `+12025550102` | Busy | | `+12025550103` | Failed (network error) | | `+12025550104` | Answered, nobody spoke (`no_response`, how most voicemail greetings appear) | | `+12025550105` | Completed after a longer conversation | | `+12025550106` | Completed, the person hangs up early (short duration) | | any other number | Completed | ## Create the call Send `POST /calls` with the agent and the destination in E.164 format (`+` and country code, no spaces). Every request you send is executed: sending the same request twice places two calls. Before retrying a call whose answer you did not see, check whether it exists (see [Make it safe to retry](#make-it-safe-to-retry)). Request body: | Field | Required | Notes | |---|---|---| | `agent_id` | yes | The agent that makes the call (`agent_...`). The API never picks one for you. | | `to` | yes | The number to call, E.164. | | `phone_number_id` | no | The workspace number to call from (`pn_...`). Default: the agent's outbound number. Required when the agent has more than one. | | `variables` | no | Values the agent can use in this conversation. | | `metadata` | no | Your own data, stored with the call and returned in reads and events. | Request bodies are strict: an unknown field is a `400 validation_error`, never silently ignored. [`POST /calls`](/api/calls#place-a-call) ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", "amount_due": "4,200" }, "metadata": { "crm_record_id": "L-1042" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", "amount_due": "4,200" }, "metadata": { "crm_record_id": "L-1042" } }), }); const call = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", "amount_due": "4,200", }, "metadata": { "crm_record_id": "L-1042", }, }, timeout=30, ) call = response.json() ``` Complete runnable example: [cURL script](/examples/guides/making-an-outbound-call/create-call.sh) · [JavaScript](/examples/guides/making-an-outbound-call/create-call.mjs) · [Python](/examples/guides/making-an-outbound-call/create-call.py) ## What the response means A successful request answers **`202 Accepted`** with the call object: ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_record_id": "L-1042" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` `202` means the call **passed every check** (agent readiness, account, balance and spend limits, the do-not-call list, consent, calling hours and the per-number daily limit) and was handed to the telephony network, or is being handed over. It does **not** mean anyone answered, and nothing about the conversation is known yet: `outcome`, `duration_seconds`, `cost` and every artifact are still empty. Store `id`. It is how you match later webhook events and reads to this call. ## What happens next The call runs after your request returns. It moves through these statuses: 1. **`queued`**: accepted and being placed. 2. **`ringing`**: not reported today. The workspace's `capabilities.call_ringing_status` is `false`, so a call goes from `queued` straight to `in_progress` or to `ended`. Do not wait for `ringing`. 3. **`in_progress`**: the person answered and the conversation is happening. 4. **`ended`**: `outcome`, `end_reason` and `duration_seconds` are known. The `call.ended` event is sent. 5. **Artifacts ready**: the transcript, recording and analysis become `ready` (or `absent` when there is none, for example a call nobody answered). 6. **Settled**: `settled` becomes `true` when outcome, duration, artifacts and the charge are all final, and the `call.settled` event is sent. Treat `outcome`, `duration_seconds` and `cost` as final only now. In test mode a call reaches `ended` within about 20 seconds and settles within about 30. In live mode artifacts are usually ready within a minute of the end and sometimes several minutes later. Follow the call with webhooks (recommended) or by polling: see [Tracking call progress](/guides/tracking-call-progress). > **A start that cannot be confirmed:** In rare cases the telephony network does not confirm that a call started. You still get `202` with the call `queued`, because the call may be starting. The API then resolves it on its own: either the call proceeds normally, or it ends within about two minutes with `outcome: failed` and `end_reason: not_confirmed`: the start was never confirmed, and the call is not charged. Do not place the call again yourself; follow the existing call id. ## Variables and metadata `variables` are values the agent can use in this conversation, for example `{"first_name": "Asha"}`. Up to 50 keys; keys are lowercase letters, digits and `_`, starting with a letter, up to 64 characters; values are strings up to 500 characters. When `to` matches one of your [contacts](/guides/managing-contacts), the contact's name and custom fields are added as variables automatically, and the values you send win. `metadata` is yours: up to 16 keys (letters, digits, `_`, `-`, `.`, up to 40 characters) with string values up to 500 characters. It is stored with the call and returned unchanged in every read and in `call.ended` and `call.settled`. It is never sent to the person called and never used by the agent. That makes it the right place for your own ids, such as a CRM record id. Do not put secrets or anything you would not log in it. ## Choosing the caller number Without `phone_number_id`, the call uses the agent's outbound number and the response shows it in `phone_number_id` and `from`. If the agent is assigned to several outbound numbers the API will not guess, and refuses with [`phone_number_required`](/errors/phone_number_required). List your numbers with [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers) and send the `pn_...` id of one whose `capabilities.outbound` is `true`. ## Make it safe to retry Every request is executed, so a blind retry after a timeout can place a **second call**. Put your own id in `metadata`, and if you did not see the answer (a timeout, a dropped connection, a `5xx`), first list calls with `to` and `created_after` and look for your `metadata`; send the request again only if the call is not there. Answers that say nothing happened (refusals with `retry: "no"`, `429`, `503`) can be resent without a check. For extra safety you can also send an optional `Idempotency-Key` header and reuse it on every retry of that call: a retry of a request that already succeeded then returns the original response with `Idempotent-Replayed: true` instead of placing another call. See [Idempotency](/fundamentals/idempotency) and the full pattern in [Retry and reliability practices](/guides/retry-and-reliability). ## Charges Live calls are charged per started minute of talk time at your workspace rate, from the workspace balance. A call that is not answered is not charged. `billable_minutes` and `cost` (in minor units; for INR that is paise, so an amount of `600` would be ₹6.00) are final once the call is settled. Test-mode calls always cost zero. Before dialling, the API checks the balance and any spend limit, and in live mode counts the call against the workspace's daily limit of calls placed through the API (`limits.daily_call_limit` on [`GET /workspace`](/api/workspace#retrieve-the-workspace); resets at 00:00 UTC). A refused call is not dialled and not charged. ## Responses Response 202 (Accepted and queued (test mode)): ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` Response 400 (validation_error): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (invalid_json): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (invalid_idempotency_key): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 400 (invalid_phone_number): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (authentication_required): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (invalid_api_key): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (api_key_expired): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (api_key_revoked): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (legacy_api_key_not_supported): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 402 (insufficient_balance): ```json { "error": { "code": "insufficient_balance", "message": "The workspace balance is too low to start this call. Top up to continue.", "details": { "balance": { "amount": 250, "currency": "INR" }, "required": { "amount": 600, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_balance" } } ``` Response 402 (spend_limit_reached): ```json { "error": { "code": "spend_limit_reached", "message": "This workspace has reached the monthly spend limit set by its admin.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/spend_limit_reached" } } ``` Response 403 (insufficient_scope): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (insufficient_permissions): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (api_access_not_enabled): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (ip_not_allowed): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (live_key_not_allowed_in_browser): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (account_inactive): ```json { "error": { "code": "account_inactive", "message": "This workspace cannot place calls because its account is not active.", "details": { "reason": "trial_expired" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/account_inactive" } } ``` Response 404 (resource_not_found): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (idempotency_key_reused): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (idempotency_key_in_progress): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (idempotency_outcome_unknown): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 413 (payload_too_large): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (unsupported_media_type): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (agent_not_ready): ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` Response 422 (calling_not_configured): ```json { "error": { "code": "calling_not_configured", "message": "Calling is not set up for this workspace yet.", "details": { "reason": "no_outbound_assignment" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/calling_not_configured" } } ``` Response 422 (outside_calling_hours): ```json { "error": { "code": "outside_calling_hours", "message": "This is outside the permitted calling window for this workspace. Nothing was dialled.", "details": { "window": "09:00-21:00", "timezone": "Asia/Kolkata" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/outside_calling_hours" } } ``` Response 422 (phone_number_not_usable): ```json { "error": { "code": "phone_number_not_usable", "message": "This phone number cannot place outbound calls for this agent.", "details": { "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_not_usable" } } ``` Response 422 (phone_number_required): ```json { "error": { "code": "phone_number_required", "message": "This agent has more than one outbound number. Choose one with phone_number_id.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_required" } } ``` Response 422 (dnc_listed): ```json { "error": { "code": "dnc_listed", "message": "This number is on your do-not-call list, so the call was not placed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/dnc_listed" } } ``` Response 422 (consent_required): ```json { "error": { "code": "consent_required", "message": "This contact has no recorded consent, and consent is required for calls in this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/consent_required" } } ``` Response 422 (contact_daily_limit_reached): ```json { "error": { "code": "contact_daily_limit_reached", "message": "This number has already been called the maximum number of times allowed in 24 hours.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_daily_limit_reached" } } ``` Response 422 (call_not_accepted): ```json { "error": { "code": "call_not_accepted", "message": "The call could not be placed. Nothing was dialled and nothing was charged.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/call_not_accepted" } } ``` Response 429 (rate_limited): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 429 (daily_call_limit_reached): ```json { "error": { "code": "daily_call_limit_reached", "message": "This workspace has reached its daily limit of calls placed through the API.", "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached" } } ``` Response 429 (calling_capacity_reached): ```json { "error": { "code": "calling_capacity_reached", "message": "Too many calls are being started right now. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/calling_capacity_reached" } } ``` Response 500 (internal_error): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (service_unavailable): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## What can go wrong Every error has `error.code`, `error.retry` and `error.request_id`. Branch on the code, and log the request id. Most refusals with `retry: "no"` happened before anything was dialled, so after fixing the cause you can send the same request again. If you send an idempotency key, two codes are different: [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) means an earlier attempt may have placed the call (find out whether it exists first), and [`idempotency_key_reused`](/errors/idempotency_key_reused) means the key belongs to a different request (use a new key). **Fix the request** (nothing was done): - [`validation_error`](/errors/validation_error) (400): a field is missing, unknown or out of range; `details.errors` lists each one. - [`invalid_phone_number`](/errors/invalid_phone_number) (400): `to` is not E.164. Send `+` and the country code. - [`resource_not_found`](/errors/resource_not_found) (404): `agent_id` or `phone_number_id` is not in this workspace and mode. Use ids read with a key of the same workspace and mode. - [`idempotency_key_reused`](/errors/idempotency_key_reused) (409): only if you send an idempotency key: this key was used for a different request. Use a new key for a new call. **Fix the setup** (an admin acts in the console, then you send the same request again): - [`agent_not_ready`](/errors/agent_not_ready) (422): `details.reasons` says why ([readiness](/guides/creating-and-preparing-agents)). - [`phone_number_required`](/errors/phone_number_required) (422): the agent has several outbound numbers; send `phone_number_id`. - [`phone_number_not_usable`](/errors/phone_number_not_usable) (422): that number cannot place outbound calls for this agent; pick another or omit it. - [`calling_not_configured`](/errors/calling_not_configured) (422): calling setup is incomplete; `details.reason` names the step. - [`insufficient_balance`](/errors/insufficient_balance) (402), [`spend_limit_reached`](/errors/spend_limit_reached) (402), [`account_inactive`](/errors/account_inactive) (403): top up, raise the limit, or upgrade. **Respect the refusal** (the rules protecting the person; see [Consent and DNC](/guides/consent-and-dnc)): - [`dnc_listed`](/errors/dnc_listed) (422): the number is on your do-not-call list. Do not call it. - [`consent_required`](/errors/consent_required) (422): the contact has no recorded consent and the workspace requires it. Record consent only if it was really given. - [`outside_calling_hours`](/errors/outside_calling_hours) (422): outside the calling window in `details.window` and `details.timezone`. Retry inside it. - [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached) (422): this number was called the maximum times in 24 hours. - [`call_not_accepted`](/errors/call_not_accepted) (422): the network definitively refused this call; nothing was dialled. Check the number; contact support if it repeats. **Retry** (if you sent an idempotency key, send the same one): - [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress) (409): only with an idempotency key: the first attempt is still running. Wait `Retry-After`. - [`calling_capacity_reached`](/errors/calling_capacity_reached) (429): too many call starts right now; nothing was dialled. Wait `Retry-After`. - [`rate_limited`](/errors/rate_limited) (429): over the `dial` rate limit. Wait `Retry-After`. - [`service_unavailable`](/errors/service_unavailable) (503): a dependency is down; nothing was done. Wait `Retry-After`. - [`internal_error`](/errors/internal_error) (500): unexpected, and the call **may** have been accepted. Check first: list calls with `to` and `created_after` and match your `metadata`; send it again only if the call is not there. **Stop and check first**: - [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) (409): only with an idempotency key: an earlier attempt with this key was interrupted. Check whether the call exists before using a new key ([how](/guides/retry-and-reliability#when-the-outcome-is-unknown)). - [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429): the workspace's daily API call limit is reached. Wait for the reset, or contact support. All codes, with causes and fixes, are in the [error-code reference](/fundamentals/error-codes). ## Check it works in test mode 1. Send the request above with the Sample agent and `"to": "+12025550100"`. You get `202` and `"status": "queued"`. 2. List calls with `to` set to `+12025550100`: your call is there with your `metadata`. (Sending the request a second time would place a second call, unless you send it with the same optional idempotency key, which replays the first answer.) 3. Try `TO=+12025550101` and follow the call: it ends with `outcome: no_answer` and `artifacts.recording.state: absent`. 4. Add `+12025550107` to the do-not-call list ([Consent and DNC](/guides/consent-and-dnc)) and call it: `422 dnc_listed`, exactly as in live mode. ## Next steps [Tracking call progress](/guides/tracking-call-progress). --- Source: /guides/tracking-call-progress # Tracking call progress **Availability: Available** A call moves through several states after you create it. This guide explains the difference between a call's status, its outcome and settlement, and shows how to track progress with webhooks (recommended) or polling. ## Before you begin - A key with `calls:read`. To receive events you also need `webhooks:write` to register an endpoint once (see [Receiving webhooks](/guides/receiving-webhooks)). - A call id (`call_...`) from [`POST /calls`](/api/calls#place-a-call). The requests below use the documented example id. ## Status, outcome and settlement Three fields answer three different questions: | Field | Question | Values | |---|---|---| | `status` | Where is the call in its lifecycle? | `queued`, `ringing` (not reported today), `in_progress`, `ended`, `unknown` | | `outcome` | How did it end? `null` until `status` is `ended` | `completed` (a conversation took place), `no_response` (answered, nobody spoke: most voicemail greetings), `no_answer`, `busy`, `failed` (could not connect), `unknown` | | `settled` | Is everything final? | `true` once outcome, duration, transcript, recording, analysis and the charge are all final | `end_reason` adds the detail behind the outcome: `hangup` (a normal end of a connected call), `no_answer`, `busy`, `invalid_number`, `network_error`, `insufficient_balance` (the balance ran out during the call), `not_confirmed` (the start was never confirmed; the call is not charged), `refused` (the network refused to start it) or `unknown`. `artifacts` shows each artifact's state: `pending` (not ready yet), `ready` (read it from its endpoint), `absent` (there is none for this call) or `failed`. A typical answered call, step by step: 1. **Accepted (`202`)**: status `queued`, outcome `null`, artifacts all `pending`, settled `false`. 1. **Answered**: status `in_progress`, outcome `null`, artifacts all `pending`, settled `false`. 1. **Hung up**: status `ended`, outcome `completed`, artifacts all `pending`, settled `false`, event `call.ended`. 1. **Artifacts produced**: status `ended`, outcome `completed`, artifacts all `ready`, settled `false`. 1. **Charge final**: status `ended`, outcome `completed`, artifacts all `ready`, settled `true`, event `call.settled`. A call that is not answered goes from `queued` to `ended` with `outcome: no_answer` and its recording, transcript and analysis `absent`. > **Values that can still change:** Between `ended` and settled, `outcome`, `duration_seconds`, `billable_minutes` and `cost` can still change. Write them to your system as final only when `settled` is `true` (or on `call.settled`). How long it takes: in test mode a call ends within about 20 seconds and settles within about 30. In live mode `call.ended` is sent usually within seconds and at most a few minutes after the call ends, and `call.settled` can be several minutes after that. ## Track with webhooks Webhooks are the recommended way: you get one HTTP request per change instead of polling every call. 1. [Register an endpoint](/guides/receiving-webhooks) subscribed to `call.ended` and `call.settled`. 2. On `call.ended`, update the call's status and outcome in your system if you need them early (for example to show "No answer" in a dashboard). The transcript and analysis may still be pending. 3. On `call.settled`, read the final values from the event's `data.object` (a snapshot of the call) and fetch the artifacts you need: [transcript and recording](/guides/retrieving-recordings-and-transcripts), [analysis](/guides/working-with-analysis-results). Events can arrive more than once and in any order. Deduplicate by the event `id`, and never let an older snapshot overwrite a newer state: for example, ignore a `call.ended` that arrives after you already processed `call.settled` for the same call. When in doubt, read the call with [`GET /calls/{call_id}`](/api/calls#retrieve-a-call). ## Track by polling When you cannot receive webhooks (a script, a batch job, early development), poll [`GET /calls/{call_id}`](/api/calls#retrieve-a-call) with backoff and stop when `settled` is `true`. Start at a couple of seconds, grow the interval, add jitter, cap it, and honour `Retry-After` on `429` and `503`. Read the call: ```bash curl -X GET https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const call = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", headers={ "Authorization": "Bearer ", }, timeout=30, ) call = response.json() ``` A few seconds after it was placed, the call is still `queued` (`200`): ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_record_id": "L-1042" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` `settled` is `false`, so wait and send the same request again: start at about 2 seconds and multiply the wait by 1.5 each time (with a little random jitter), up to 15 seconds between reads. On `429` or `503`, wait the seconds in `Retry-After` instead. Stop when `settled` is `true`: About 30 seconds later the test call is settled (`200`): ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "ended", "outcome": "completed", "end_reason": "hangup", "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_record_id": "L-1042" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": "2026-10-21T09:32:14.000Z", "duration_seconds": 42, "billable_minutes": 0, "cost": { "amount": 0, "currency": "INR" }, "artifacts": { "transcript": { "state": "ready" }, "recording": { "state": "ready" }, "analysis": { "state": "ready" } }, "settled": true, "settled_at": "2026-10-21T09:41:02.000Z" } ``` Complete runnable example: [cURL script](/examples/guides/tracking-call-progress/poll-call.sh) · [JavaScript](/examples/guides/tracking-call-progress/poll-call.mjs) · [Python](/examples/guides/tracking-call-progress/poll-call.py) Reads count against the `read` rate limit (600 requests per minute per key). Polling a handful of calls is fine; polling hundreds in a loop is not. For many calls, use webhooks, or list them in one request with [`GET /calls`](/api/calls#list-calls) filtered by `status`, `campaign_id` or `created_after` (see [Common integration patterns](/guides/common-integration-patterns)). ## Open enums `status`, `outcome`, `end_reason` and the artifact states are open enums: new values can be added without a new API version. Write your code so that: - an unknown `status` counts as "not finished", - an unknown `outcome` is shown as "other" and is never treated as success, - `status: unknown` (the state could not be confirmed for an unusually long time) is a reason to look again later or ask support, not a success, - an unknown artifact state is "not ready". ## What can go wrong - **[`rate_limited`](/errors/rate_limited) on reads**: Polling too fast or too many calls. Fix: Wait `Retry-After`, slow down, or switch to webhooks. - **[`resource_not_found`](/errors/resource_not_found)**: The id is from the other mode (a live id with a test key, or the reverse) or another workspace. Fix: Use the key that created the call. - **The call stays `queued` for a while**: The start is being confirmed. Fix: Keep following it: it either proceeds, or ends with `end_reason: not_confirmed` (never confirmed, not charged) within about two minutes. - **`ended` but artifacts `pending`**: Normal: they are produced after the call. Fix: Wait for `call.settled`. - **`settled` never arrives in your system**: Your webhook endpoint failed or was not subscribed. Fix: Check the delivery log ([Receiving webhooks](/guides/receiving-webhooks)) and reconcile with the list endpoint. ## Next steps [Retrieving recordings and transcripts](/guides/retrieving-recordings-and-transcripts). --- Source: /guides/retrieving-recordings-and-transcripts # Retrieving recordings and transcripts **Availability: Available** After a call settles you can read its transcript and get a link to its recording. This guide covers when the artifacts are available, the shape of a transcript and how to download recordings through short-lived links. ## Before you begin - `transcripts:read`: [`GET /calls/{call_id}/transcript`](/api/transcripts#retrieve-a-call-transcript) - `recordings:read`: [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) - `calls:read`: Reading the call itself to see `artifacts` Artifact scopes are separate from `calls:read` on purpose: a key that only needs call outcomes does not have to see what was said. The requests below use the documented example call id; use the id of your call. Any test call to `+12025550100` produces both artifacts. ## When artifacts are ready Artifacts are produced after the call ends. Each call reports their state in `artifacts`: ```json "artifacts": { "transcript": { "state": "ready" }, "recording": { "state": "ready" }, "analysis": { "state": "ready" } } ``` | State | Meaning | What to do | |---|---|---| | `pending` | Not ready yet | Wait for `call.settled`, or read the call again later | | `ready` | Available | Read it from its endpoint | | `absent` | There is none for this call | Nothing to fetch (for example a call nobody answered) | | `failed` | It could not be produced | Nothing to fetch; contact support with the call id if it keeps happening | The simplest rule: **fetch artifacts after `call.settled`** (or once `settled` is `true` when polling). In live mode they are usually ready within a minute of the call ending, sometimes several minutes later. In test mode they are ready about 30 seconds after the call is created. Transcripts are not available while a call is in progress (`capabilities.live_transcript` is `false`). Which test-mode calls have which artifacts: | `to` | Transcript | Recording | |---|---|---| | `+12025550100`, `+12025550105`, `+12025550106` | `ready` | `ready` | | `+12025550104` (answered, nobody spoke) | `absent` | `ready` | | `+12025550101`, `+12025550102`, `+12025550103` (not connected) | `absent` | `absent` | ## Transcript [`GET /calls/{call_id}/transcript`](/api/transcripts#retrieve-a-call-transcript) returns the conversation turn by turn, verbatim. Nothing is masked: it is what was said. ```bash curl -X GET https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const transcript = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/transcript", headers={ "Authorization": "Bearer ", }, timeout=30, ) transcript = response.json() ``` Complete runnable example: [cURL script](/examples/guides/retrieving-recordings-and-transcripts/get-transcript.sh) · [JavaScript](/examples/guides/retrieving-recordings-and-transcripts/get-transcript.mjs) · [Python](/examples/guides/retrieving-recordings-and-transcripts/get-transcript.py) The response (`200`): ```json { "object": "call_transcript", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "turns": [ { "index": 0, "speaker": "agent", "text": "Hello, this is an AI assistant calling from Acme Lending. Am I speaking with Asha?", "start_ms": 400, "end_ms": 4900 }, { "index": 1, "speaker": "caller", "text": "Yes, speaking.", "start_ms": 5600, "end_ms": 6500 } ] } ``` - `speaker` is `agent` or `caller` (the person called). Open enum. - `index` orders the turns from 0. `start_ms` and `end_ms` are milliseconds from the start of the call, or `null` when not known. - `turns` is empty unless `state` is `ready`. A transcript that is still `pending` is answered with `200` and `state: "pending"`, not an error. - `simulated` is `true` for test-mode fixture transcripts. Do not import them into production systems. ## Recording **Availability: Preview** The recording endpoints are in preview and may change. [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) returns the recording's state and, when it is ready, a short-lived HTTPS link to the audio. First read the recording to get a fresh link: ```bash curl -X GET https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const recording = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/recording", headers={ "Authorization": "Bearer ", }, timeout=30, ) recording = response.json() ``` Then, while `state` is `ready` and before `expires_at`, download the audio from its `url`. The link needs no API key (see the response below for its shape): ```bash curl https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF82VmIzTm04UXcyRXI1VHk5VWk0T3A3 \ -o call-recording.audio ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF82VmIzTm04UXcyRXI1VHk5VWk0T3A3"); await writeFile("call-recording.audio", Buffer.from(await response.arrayBuffer())); ``` ```python response = requests.get("https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF82VmIzTm04UXcyRXI1VHk5VWk0T3A3", timeout=60) open("call-recording.audio", "wb").write(response.content) ``` Complete runnable example: [cURL script](/examples/guides/retrieving-recordings-and-transcripts/download-recording.sh) · [JavaScript](/examples/guides/retrieving-recordings-and-transcripts/download-recording.mjs) · [Python](/examples/guides/retrieving-recordings-and-transcripts/download-recording.py) The response (`200`): ```json { "object": "call_recording", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "url": "https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF8ySGQ4S3M0THE3TXczTng5UHo1UmMx", "expires_at": "2026-10-21T10:00:00.000Z", "content_type": null } ``` How the link works: - `url` is valid for about 15 minutes, until `expires_at`. After that it answers [`media_link_expired`](/errors/media_link_expired) (410); call the recording endpoint again for a fresh link. - The link **needs no API key**: the token in the path is the credential. It is tied to the key that obtained it: it stops working (`404`) as soon as that key is revoked or expires, loses `recordings:read`, or its creator leaves the workspace, even before `expires_at`. - Until then it works for anyone who has it. Treat it like a password. Do not log it, store it, put it in a URL you share, or send it to a browser you do not control. Download the audio on your server and serve it from your own storage if people need to play it. - `content_type` is `audio/wav` for test-mode audio and is usually `null` in live mode, where the format is known only when you download it: the download's `Content-Type` header is authoritative. - The download supports `Range` requests, so a player can seek. A range that cannot be served is answered with the whole file (`200`). - In test mode the audio is a short synthetic file and `simulated` is `true`. If you need to keep recordings, copy them into your own storage promptly and apply your own retention rules. ## Handling call content Transcripts and recordings are personal data of the person you called: their voice and what they said. RelationsAI returns them to your workspace in full. Store and share them according to your own data-protection obligations and your agreements with the people you call: restrict who can read them, keep them only as long as you need, and do not copy them into logs, analytics tools or tickets. See [Security and data handling](/resources/security-and-data-handling). ## What can go wrong - **`state: "pending"`**: The call has not settled yet. Fix: Wait for `call.settled` and read again. - **`state: "absent"`**: The call never connected, or nobody spoke (transcript). Fix: Nothing to fetch; check `outcome` on the call. - **[`media_link_expired`](/errors/media_link_expired) (410) on download**: The link passed `expires_at`. Fix: Get a fresh link and download straight away. - **[`insufficient_scope`](/errors/insufficient_scope) (403)**: The key lacks `transcripts:read` or `recordings:read`. Fix: Ask an admin for a key with that scope. - **[`resource_not_found`](/errors/resource_not_found) (404)**: The call id is from the other mode or another workspace. Fix: Use the key that created the call. ## Next steps [Working with analysis results](/guides/working-with-analysis-results). --- Source: /guides/working-with-analysis-results # Working with analysis results **Availability: Available** RelationsAI analyses each completed conversation. This guide explains what the analysis contains, when it is available and how to map it into your own systems. ## Before you begin - A key with `analysis:read`. - An agent configured in the console with the fields you want extracted and, optionally, a goal. Analysis reports only what the agent was configured to look for. - A settled call. The requests below use the documented example id; a test call to `+12025550100` has a complete fixture analysis. ## What analysis contains | Part | Field | Notes | |---|---|---| | Summary | `summary.text` | A short written summary of the conversation | | Extracted fields | `extracted_fields[]` | One entry per field configured on the agent: `name`, `type` (for example `string`, `number`, `boolean`; open enum), `state` and `value` | | Goal | `goal_met` | `true` or `false` when the agent is configured to report a goal, otherwise `null` | Each part has its own state, because parts are produced separately: `summary.state` and each field's `state` are `pending`, `ready`, `absent` or `failed`. A field that is `absent` was not mentioned in the call: its `value` is `null`. The overall `state` is `pending` while any part is still being produced, `ready` once every part has finished (a part can still be `failed`), and `absent` when the call produced nothing to analyse. Sentiment is not part of analysis today; `capabilities.sentiment_analysis` on [`GET /workspace`](/api/workspace#retrieve-the-workspace) is `false`. ## Reading analysis Fetch [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis) after the call is settled (on `call.settled`, or once `settled` is `true`). ```bash curl -X GET https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const analysis = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls/call_6Vb3Nm8Qw2Er5Ty9Ui4Op7/analysis", headers={ "Authorization": "Bearer ", }, timeout=30, ) analysis = response.json() ``` Complete runnable example: [cURL script](/examples/guides/working-with-analysis-results/get-analysis.sh) · [JavaScript](/examples/guides/working-with-analysis-results/get-analysis.mjs) · [Python](/examples/guides/working-with-analysis-results/get-analysis.py) A ready analysis (`200`): ```json { "object": "call_analysis", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "summary": { "state": "ready", "text": "The customer confirmed the renewal and asked for a callback on Friday afternoon." }, "extracted_fields": [ { "name": "wants_renewal", "type": "boolean", "state": "ready", "value": true }, { "name": "callback_day", "type": "string", "state": "ready", "value": "Friday" } ], "goal_met": true } ``` Read before the call settles and you get `200` with `state: "pending"`, an empty `extracted_fields` and `summary.text: null`. That is not an error: read again after `call.settled`. ## Mapping to your systems Put your own record id in the call's `metadata` when you [place it](/guides/making-an-outbound-call) (for example `crm_record_id`). The `call.settled` event carries the call with its `metadata`, so your handler knows which record to update without a lookup table: 1. On `call.settled`, take `data.object.id` and `data.object.metadata.crm_record_id`. 2. Fetch the analysis. 3. Write `summary.text` to a note or activity on the record, map each `ready` field to a field on your side by `name`, and record `goal_met`. 4. Store the call id with what you wrote, so a duplicate event is a no-op. Practical rules: - Map by field `name`, and check `type` before writing a value into a typed column. A field you do not recognise (someone added it to the agent) should be logged and skipped, not crash the handler. - Write only `ready` values. `absent` means the topic did not come up: do not overwrite an existing value in your system with `null`. - Keep the call id next to the values you write, so anyone can trace a value back to the conversation and its transcript. The [CRM integration guide](/guides/integrating-with-crm-systems) shows the full flow, and the [webhook receiver](/guides/receiving-webhooks) fetches the analysis on `call.settled`. ## Limits Analysis is produced automatically from the conversation and can be wrong: a field can be misheard, a summary can miss a nuance, a goal can be judged met when it was not. Treat it as a strong hint: - Keep a person in the loop for decisions that matter to the person called (credit, health, legal, employment) and let them check the transcript. - Prefer fields with a small set of values (yes/no, a day of the week) over free text for automation. - Test-mode analysis is a fixture marked `simulated: true`; it does not reflect your agent's configuration. ## What can go wrong - **`state: "absent"`**: The call did not connect or nobody spoke. Fix: Nothing to read; check the call's `outcome`. - **`state: "pending"`**: The call has not settled. Fix: Wait for `call.settled`. - **A field is always `absent`**: The agent never asks about it, or it is not configured. Fix: Adjust the agent in the console and publish. - **[`insufficient_scope`](/errors/insufficient_scope) (403)**: The key lacks `analysis:read`. Fix: Ask an admin for a key with it. - **[`resource_not_found`](/errors/resource_not_found) (404)**: Wrong mode or workspace for this call id. Fix: Use the key that created the call. ## Next steps [Running campaigns](/guides/running-campaigns). --- Source: /guides/running-campaigns # Running campaigns **Availability: Preview** A campaign calls many recipients with one agent, on a schedule and at a controlled pace. This guide walks through creating a campaign, adding recipients, starting, pausing, resuming and stopping it, and reading per-recipient results. Use a campaign whenever you call more than a handful of people for the same purpose: it paces calls for you (`concurrency_limit` at a time), screens every recipient against your compliance rules, and gives you one object to follow instead of hundreds of calls. ## Before you begin | You need | For | |---|---| | `campaigns:write`, `campaigns:read` | Creating, starting and reading campaigns | | `contacts:write` | Recipients are contacts: you upsert them first | | A ready agent | In test mode, the Sample agent `agent_1ZltiWzxrXmMprGD6q63` | | `webhooks:write` (optional) | To receive `campaign.*` events | ## The campaign lifecycle ```text draft ──start──▶ running ──pause──▶ paused ──resume──▶ running │ │ ├──stop──▶ stopped ◀──stop──┤ ├──▶ completed (every recipient worked through) └──▶ failed (the run ended in failure) start again from paused, stopped, completed or failed ──▶ a new run (run_number + 1) ``` | Status | Meaning | |---|---| | `draft` | Created, never started. Nothing has been dialled. | | `running` | A run is dialling, up to `concurrency_limit` calls at a time. | | `paused` | No new calls start. Calls already connected finish normally (and are charged). | | `stopped` | Ended by a stop request. No further calls start. | | `completed` | The run worked through every recipient. | | `failed` | The run ended in failure before finishing. | | `unknown` | The state could not be determined. Not a success. Open enum. | Every campaign carries `capabilities`, which says what is allowed **right now**. Check it instead of hard-coding the table: | Action | Allowed from | `capabilities` field | |---|---|---| | Start | `draft`, `paused`, `stopped`, `completed`, `failed` | `start` | | Pause | `running` | `pause` | | Resume | `paused` | `resume` | | Stop | `draft`, `running`, `paused` | `stop` | | Edit (PATCH) | every status except `running` | `update` | | Add recipients | `draft` only | `add_recipients` | A campaign can also be **paused automatically**, for example when its budget cap is reached, the workspace balance runs out, the telephony connection drops, too many consecutive calls fail, or a start was refused. The `campaign.paused` event's `data.reason` says why (`budget_cap_reached`, `insufficient_balance`, `spend_limit_reached`, `telephony_disconnected`, `failure_streak`, `start_refused`, `account_inactive`, `requested`, `unknown`). Fix the cause, then resume. ## Create and start a campaign Three steps: upsert the recipients as contacts, create the campaign with their ids, start it. **1. Upsert each recipient as a contact.** Send one request per person (this one adds the test number that completes a call): ```bash curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550100", "name": "Asha Rao" }), }); const contact = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/contacts/upsert", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", "name": "Asha Rao", }, timeout=30, ) contact = response.json() ``` Keep the `id` of each contact (`ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1` here). Add `"consent_granted": true` only when the person really agreed to be called. **2. Create the campaign** with the contact ids. It starts as a `draft`; nothing is dialled: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 2, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 2, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] }), }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns", headers={ "Authorization": "Bearer ", }, json={ "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 2, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", ], }, timeout=30, ) campaign = response.json() ``` Response `201`: ```json { "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "draft", "capabilities": { "start": true, "pause": false, "resume": false, "stop": true, "update": true, "add_recipients": true }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 0, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" } ``` **3. Start it**, using the `id` from that response: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` Response `200`: ```json { "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" } ``` If a job stops halfway, check what already exists before sending a step again: every request is executed, so resending a create makes a second campaign. The complete example below looks for its campaign by name first, creates it only if it is missing, and starts it only while it is still a `draft`. An optional `Idempotency-Key` per step, saved with your batch, makes a re-run replay the steps that already happened instead (see [Idempotency](/fundamentals/idempotency)). Complete runnable example: [cURL script](/examples/guides/running-campaigns/create-and-start.sh) · [JavaScript](/examples/guides/running-campaigns/create-and-start.mjs) · [Python](/examples/guides/running-campaigns/create-and-start.py) The calls in order: 1. [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) for each person: recipients are always existing contacts (see [Managing contacts](/guides/managing-contacts)). 2. [`POST /campaigns`](/api/campaigns#create-a-campaign) with `name`, `agent_id`, optional `concurrency_limit` (default 1; lowered at start to your plan's `limits.campaign_max_concurrency`), optional `budget_cap` and up to 1000 `contact_ids`. The answer is `201` with the campaign in `draft`. **Nothing is dialled yet.** 3. Optionally [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients) to add more contacts, up to 1000 per request and 10,000 per campaign, **only while the campaign is a draft**. Contacts already in the campaign are skipped, and the response says how many were `added`. 4. [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign). ### What the start does Before anything is dialled, the start checks the agent, the account, balance and spend limits, the campaign budget cap, calling hours and the telephony connection, then **screens every recipient** against the do-not-call list, consent and the per-number daily limit. Blocked recipients are left out of the run; if that leaves nobody, the start is refused with [`campaign_recipients_suppressed`](/errors/campaign_recipients_suppressed). After the start, `recipient_count` is the number of recipients this run calls. - `200`: The run started; the campaign is `running`. Follow it. - `202`: The start was sent but its confirmation has not arrived; the campaign shows `running` and is reconciled automatically. Read it again in a minute. **Do not start it again.** - `409` [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown): the start was sent and not confirmed; dialling may have begun. Wait `Retry-After`, then read the campaign: `running` means it started. Only if it did not, start it again (if you sent an idempotency key, use a new one: the same key returns this answer again). - `409` [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled): do-not-call screening was switched off for this campaign in the console (`dnc_screening: "disabled_in_console"`). The API never starts or resumes such a campaign. An admin switches the setting back on, or the campaign is started from the console. In live mode a start counts **every recipient** against the workspace's daily limit of calls placed through the API, before dialling, because the run may call each of them. A start that would go past the limit is refused whole with [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (`details.requested` and `details.remaining` say by how much), so a campaign larger than the daily limit cannot be started through the API. A resume never counts. Test mode has no daily limit. **When the do-not-call list is checked.** Recipients are screened when a run starts and again when it resumes, not before each call. A number added to the do-not-call list while the campaign is running is still called by that run if it has not been reached yet. To apply the new entry, pause and resume the campaign (the resume is then refused while that number is a remaining recipient, see below) or stop it. `dnc_screening` on the campaign is `enforced` for every campaign the API can start. ## Follow results Subscribe an endpoint to the `campaign.*` events (see [Receiving webhooks](/guides/receiving-webhooks)) and read the campaign and its recipients when they arrive: | Event | When | |---|---| | `campaign.started` | A run started dialling (also when a `202` start is confirmed) | | `campaign.paused` | Paused by a request or automatically; `data.reason` says why | | `campaign.resumed` | A paused run resumed | | `campaign.stopped` | Stopped; `data.reason` is `requested` | | `campaign.completed` | The run worked through every recipient. In live mode up to about 15 minutes after the last call ends. | | `campaign.failed` | The run ended in failure. In live mode up to about 15 minutes after it ends. | Each call a campaign places is an ordinary call with `source: "campaign"` and `campaign_id` set, so `call.ended` and `call.settled` arrive for each one, and [`GET /calls`](/api/calls#list-calls) with `campaign_id` lists them. Read the campaign: ```bash curl -X GET https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` And its recipients, 100 at a time (follow `next_cursor` as described in [Pagination](/fundamentals/pagination)): ```bash curl -X GET "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients?limit=100" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients?limit=100", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const page = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, }, timeout=30, ) page = response.json() ``` Complete runnable example: [cURL script](/examples/guides/running-campaigns/follow-results.sh) · [JavaScript](/examples/guides/running-campaigns/follow-results.mjs) · [Python](/examples/guides/running-campaigns/follow-results.py) A running campaign (`200`, with an `ETag` header): ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` `progress` counts the calls of the current or latest run (`calls`, `connected`, `no_answer`, `failed`, `in_progress`) and can lag by up to a minute. `spend` is what the run has cost so far (always zero in test mode). [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients) lists recipients in a stable order that does not change between pages (not by time), with what happened to each in the current or latest run: | `status` | Meaning | |---|---| | `pending` | Not called yet in this run | | `in_progress` | Being called | | `completed` | Called; see `outcome` (the call `outcome` values) and `last_call_id` | | `failed` | Every attempt failed | | `suppressed` | Left out by screening; `suppression_reason` is `dnc_listed`, `consent_required`, `contact_daily_limit_reached` or `unknown` | | `cancelled` | The run stopped before reaching them | | `unknown` | Not known. Open enum. | A page of recipients from a test-mode campaign (`200`): ```json { "object": "list", "data": [ { "id": "rcp_3Gh8Jk2Lz5Xc9Vb4Nm7Qw1", "object": "campaign_recipient", "livemode": false, "campaign_id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "phone_number": "+12025550100", "status": "completed", "attempts": 1, "last_call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "outcome": "completed", "suppression_reason": null }, { "id": "rcp_6Tr1Yu4Io7Pa2Sd5Fg8Hj3", "object": "campaign_recipient", "livemode": false, "campaign_id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "contact_id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "phone_number": "+12025550107", "status": "suppressed", "attempts": 0, "last_call_id": null, "outcome": null, "suppression_reason": "dnc_listed" } ], "has_more": false, "next_cursor": null } ``` Per-recipient results can be up to a minute behind the calls. One honest limit today: **in live mode a recipient left out by screening at start is not yet recorded per recipient**. It shows `pending` while the run is open and `unknown` once the run has ended (test mode records it as `suppressed`). The difference between the campaign's recipients and `recipient_count` after the start tells you how many were left out. ## Start, pause, resume and stop Read the campaign first and check `capabilities`. Then send the action. Pause: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/pause \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/pause", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/pause", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` Resume: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/resume \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/resume", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/resume", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` Stop: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/stop \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/stop", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/stop", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` Each answers `200` with the campaign in its new status. After a timeout or a `retry: "yes"` error, read the campaign before sending the action again; after a `retry: "after"` error, wait `Retry-After` and send it again. On `campaign_outcome_unknown`, do not resend: wait `Retry-After` and read the campaign. Complete runnable example: [cURL script](/examples/guides/running-campaigns/campaign-action.sh) · [JavaScript](/examples/guides/running-campaigns/campaign-action.mjs) · [Python](/examples/guides/running-campaigns/campaign-action.py) - **Pause** ([`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign), from `running`): no new calls start. Calls already connected finish normally and are charged. The campaign is reported `paused` only once the pause is confirmed; if it is not, you get [`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) and the campaign may still be dialling. Calls placed meanwhile are charged as usual. Read the campaign; if it is still running, send the pause again or stop it. - **Resume** ([`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign), from `paused`): continues the same run with the settings it began with. Before anything is sent it repeats the account, balance, budget, calling-hours and telephony checks and **screens every recipient the run could still call again**. A resume cannot drop recipients from a run, so if any remaining recipient is now blocked (for example added to the do-not-call list after the start), nothing is resumed, the campaign stays `paused`, and you get [`campaign_recipients_blocked`](/errors/campaign_recipients_blocked) with `details.blocked_total`, `details.by_reason` and up to 100 `details.contact_ids`. **Do not stop and start it again**: a new start calls every recipient of the previous run again, including people already reached, and charges for those calls. To continue without the blocked people: list the recipients with `status=pending` (not reached yet), create a new campaign with those contacts minus the ones in `details.contact_ids`, start it, then stop this campaign (stopping does not hang up calls in progress). Never work around the refusal by removing do-not-call entries or recording consent nobody gave. - **Stop** ([`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign), from `draft`, `running` or `paused`): no further calls start. **Calls already connected are not hung up**; they finish normally. Recipients the run had not reached become `cancelled`. - **Start again** from `paused`, `stopped`, `completed` or `failed` begins a **new run** that calls the previous run's recipients again, **including people already reached** (and charges for those calls): from `paused` it abandons the paused run (use resume to continue it instead). `run_number` increases. Use it when you really want to call everyone again; to reach only the people not called yet, create a new campaign with them. Every action is a POST. After a timeout read the campaign before resending (its `status` shows whether the action took effect); after a `retry: "after"` error wait and resend; stop on `retry: "no"`; and on `campaign_outcome_unknown` wait and read the campaign instead of resending. ## Edit safely [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign) changes `name`, `agent_id`, `concurrency_limit` or `budget_cap` (`null` removes the cap). Edits are allowed in every status except `running`, and **take effect at the next start**: a resume continues with the settings its run began with. Campaigns use optimistic concurrency. Every read returns an `ETag` header; send it back in `If-Match`. If the campaign changed since you read it (someone edited it in the console, or it was paused automatically), you get [`version_conflict`](/errors/version_conflict) (412): read it again, decide again, and send the new ETag. A PATCH without `If-Match` is [`precondition_required`](/errors/precondition_required) (428). See [Concurrency](/fundamentals/concurrency). Read the campaign and keep its `ETag` header, then send the change with it in `If-Match`: ```bash curl -X PATCH https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1 \ -H "Authorization: Bearer " \ -H 'If-Match: ' \ -H "Content-Type: application/json" \ -d '{ "name": "November renewals (second wave)", "concurrency_limit": 3 }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", { method: "PATCH", headers: { "Authorization": "Bearer ", "If-Match": "", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "November renewals (second wave)", "concurrency_limit": 3 }), }); const campaign = await response.json(); ``` ```python response = requests.patch( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", headers={ "Authorization": "Bearer ", "If-Match": "", }, json={ "name": "November renewals (second wave)", "concurrency_limit": 3, }, timeout=30, ) campaign = response.json() ``` Complete runnable example: [cURL script](/examples/guides/running-campaigns/edit-settings.sh) · [JavaScript](/examples/guides/running-campaigns/edit-settings.mjs) · [Python](/examples/guides/running-campaigns/edit-settings.py) ## Budget, pace and limits - `budget_cap`, as `{"amount": , "currency": "INR"}`, is the spend at which the campaign is paused (reason `budget_cap_reached`). It is not an exact ceiling: the check runs about every 15 minutes, so spend can exceed the cap by the calls placed in that time. Set it with that margin in mind; raise or remove it with PATCH and resume. A start or resume at the cap is refused with [`campaign_budget_reached`](/errors/campaign_budget_reached). - `concurrency_limit` is how many calls run at once. A higher value is lowered at start to `limits.campaign_max_concurrency` on [`GET /workspace`](/api/workspace#retrieve-the-workspace). - Starts and resumes use the `dial` rate-limit class (30 per minute per workspace); pause, stop and edits use `write`. - Calling hours apply to the whole campaign: a start or resume outside the calling window is refused with [`outside_calling_hours`](/errors/outside_calling_hours) (the window and the timezone it is evaluated in are in `details`). ## What can go wrong - [`campaign_state_conflict`](/errors/campaign_state_conflict) (409): The action is not allowed in the current status (`details.status`, `details.action`). Read the campaign and use `capabilities`. - [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress) (409): Another start or resume of this campaign is running. Wait `Retry-After`, then send it again. - [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409): A start or resume was sent but not confirmed. Wait, then read the campaign, and send the action again only if it did not start (with a new idempotency key if you use keys). If `details.needs_support` is true, contact support. - [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled) (409): Do-not-call screening is switched off for this campaign in the console. An admin switches it back on, or starts it from the console. - [`campaign_has_no_recipients`](/errors/campaign_has_no_recipients) (409): Starting a campaign with no recipients. Add recipients while it is a draft. - [`campaign_recipients_suppressed`](/errors/campaign_recipients_suppressed) (422): Every recipient is blocked by screening. Add callable recipients; review consent records. - [`campaign_recipients_blocked`](/errors/campaign_recipients_blocked) (409): Resume found newly blocked recipients. Create a new campaign with the pending recipients minus the blocked ones, then stop this one. - [`campaign_not_resumable`](/errors/campaign_not_resumable) (409): The paused run can no longer continue. A new start calls every previous recipient again; to reach only the people not called yet, create a new campaign with the pending recipients. - [`campaign_recipients_unavailable`](/errors/campaign_recipients_unavailable) (503): Recipients could not be read for screening; nothing was dialled. Wait `Retry-After` and send it again. If it keeps failing on a very large campaign, create a new campaign with the pending recipients instead. - [`campaign_too_large`](/errors/campaign_too_large) (422): Too many recipients for one run. Split the audience into several campaigns. - [`recipient_contacts_not_found`](/errors/recipient_contacts_not_found) (422): Some `contact_ids` are not contacts of this workspace and mode. Upsert them first; `details` lists them. - [`telephony_disconnected`](/errors/telephony_disconnected) (422): The telephony connection is not working; the campaign was paused. Reconnect it in the console, then start or resume. - [`agent_not_ready`](/errors/agent_not_ready), [`insufficient_balance`](/errors/insufficient_balance), [`spend_limit_reached`](/errors/spend_limit_reached), [`account_inactive`](/errors/account_inactive): Same checks as a single call. See [Making an outbound call](/guides/making-an-outbound-call). ## Check it works in test mode 1. Run the create-and-start example. The campaign becomes `running` with `recipient_count: 3`. 2. Follow it: within about a minute (with `concurrency_limit: 2`) the recipients are `completed` with outcomes `completed`, `no_answer` and `completed`, and the campaign becomes `completed`. 3. Start another one with `concurrency_limit: 1` and pause it while at least one recipient is still `pending`. Add that recipient's number to the do-not-call list and resume: you get `409 campaign_recipients_blocked` listing it. Create a new campaign with the remaining pending recipients minus that one, start it, and stop the first. ## Next steps [Managing contacts](/guides/managing-contacts). --- Source: /guides/managing-contacts # Managing contacts **Availability: Available** Contacts are the people your agents call. This guide shows how to create and update contacts keyed by phone number, store custom fields that agents can use as variables, and keep contacts in sync with your own records. Contacts matter for three reasons: campaign recipients are always contacts, a contact's name and custom fields become call variables automatically, and consent is recorded on the contact. ## Before you begin - A key with `contacts:read` and `contacts:write`. - Custom fields, if you use them, defined in the console. The API writes values for existing fields; it does not create field definitions. Phone numbers are unique within a workspace **and mode**: a test-mode contact and a live contact with the same number are two separate contacts, and test-mode contacts are never visible to live keys. ## Upsert by phone number [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) creates the contact with this phone number, or updates it if one exists, in one request. It answers `201` when it created a contact and `200` when it updated one. This is the recommended way to sync contacts from your own system: you never need to look a contact up first, and repeating the same upsert is harmless. ```bash curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "custom_fields": { "policy_number": "PN-55012" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "custom_fields": { "policy_number": "PN-55012" } }), }); const contact = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/contacts/upsert", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "custom_fields": { "policy_number": "PN-55012", }, }, timeout=30, ) contact = response.json() ``` Complete runnable example: [cURL script](/examples/guides/managing-contacts/upsert-contact.sh) · [JavaScript](/examples/guides/managing-contacts/upsert-contact.mjs) · [Python](/examples/guides/managing-contacts/upsert-contact.py) The response is the contact, with an `ETag` header: ```json { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ``` How upsert treats each field: | Field | On upsert | |---|---| | `phone_number` | Identifies the contact. Matched the same way the do-not-call check matches numbers (by digits, and by the last ten digits), so a contact entered in the console without `+` and country code is found too. Send E.164. | | `name`, `language`, `country_code` | Set when sent; `null` clears; omitted leaves the existing value | | `consent_granted` | `true` records that consent was given, `false` records a withdrawal, omitted leaves it unchanged. Send it only as your records really say. | | `custom_fields` | Merged: the names you send are set (`null` clears one), others are left unchanged | | `do_not_call` | Read-only here. Use the [do-not-call endpoints](/guides/consent-and-dnc). | To create a contact only when none exists, use [`POST /contacts`](/api/contacts#create-a-contact); a duplicate number is [`contact_already_exists`](/errors/contact_already_exists) with the existing contact's id in `details.contact_id`. ## Custom fields Custom fields hold the per-person facts an agent needs, for example a policy number or a due date. Define the fields in the console, then send values by field name: ```json { "phone_number": "+12025550100", "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" } } ``` - Values are strings up to 500 characters; up to 100 fields per request. Format dates and numbers the way you want the agent to read them. - An unknown field name is refused with [`custom_field_not_found`](/errors/custom_field_not_found) (`details.field` names it). Nothing is half-written. - When a call's `to` matches a contact, the contact's name and custom fields are added to the call's `variables` automatically, and an agent can use `{{policy_number}}` in its instructions. Values you send in `variables` on the call win. ## Updating safely Change one contact with [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact). Contacts use optimistic concurrency: read the contact, keep its `ETag` header, and send it back in `If-Match`. If the contact changed in between (an upsert from another job, an edit in the console), the update is refused with [`version_conflict`](/errors/version_conflict) (412) instead of silently overwriting it. Read it again, decide again, and retry. A PATCH without `If-Match` is [`precondition_required`](/errors/precondition_required) (428). PATCH does not need an Idempotency-Key. See [Concurrency](/fundamentals/concurrency). Read the contact; the response carries its version in the `ETag` header (for example `"c3f1a9e2b7d4"`): ```bash curl -X GET https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const contact = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", headers={ "Authorization": "Bearer ", }, timeout=30, ) contact = response.json() ``` Send the change with that value in `If-Match`: ```bash curl -X PATCH https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer " \ -H 'If-Match: ' \ -H "Content-Type: application/json" \ -d '{ "language": "hi" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", { method: "PATCH", headers: { "Authorization": "Bearer ", "If-Match": "", "Content-Type": "application/json", }, body: JSON.stringify({ "language": "hi" }), }); const contact = await response.json(); ``` ```python response = requests.patch( "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", headers={ "Authorization": "Bearer ", "If-Match": "", }, json={ "language": "hi", }, timeout=30, ) contact = response.json() ``` `200` returns the updated contact and its new `ETag`. `412 version_conflict` means it changed after your read: read it again, decide again, and send the new `ETag`. Complete runnable example: [cURL script](/examples/guides/managing-contacts/update-contact.sh) · [JavaScript](/examples/guides/managing-contacts/update-contact.mjs) · [Python](/examples/guides/managing-contacts/update-contact.py) Changing `phone_number` to a number another contact has is [`contact_already_exists`](/errors/contact_already_exists). To delete a contact use [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact). It permanently deletes the contact, its custom field values and its consent history, and removes it from campaign recipient lists (a campaign that has already started keeps calling the recipients its run began with). Its calls stay in the call list. A contact on the do-not-call list cannot be deleted ([`contact_on_dnc_list`](/errors/contact_on_dnc_list)), so a deletion never removes a do-not-call protection. ## Listing and filtering [`GET /contacts`](/api/contacts#list-contacts) lists contacts newest first, with cursor pagination. Filters: `phone_number` (matched like upsert), `consent_granted`, `do_not_call`, `created_after`, `created_before`. Pass `limit` (1 to 100) and, for each next page, `cursor` set to the previous `next_cursor` **with the same filters**: a cursor used with different filters is [`invalid_cursor`](/errors/invalid_cursor). ```bash curl -X GET "https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const page = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/contacts", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, "created_after": "2026-10-01T00:00:00.000Z", }, timeout=30, ) page = response.json() ``` Response `200`: ```json { "object": "list", "data": [ { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b" } ``` `has_more` is `true`, so ask for the next page: the same request with the same filters, plus `cursor` set to `next_cursor`. Repeat until `has_more` is `false`: ```bash curl -X GET "https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z&cursor=eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts?limit=100&created_after=2026-10-01T00:00:00.000Z&cursor=eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const page = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/contacts", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, "created_after": "2026-10-01T00:00:00.000Z", "cursor": "eyJ2IjoxLCJrIjoiY3RfOFJ3MlRxNUxtOVhjM1ZiN056NEtwMSJ9.9c1e4b", }, timeout=30, ) page = response.json() ``` Complete runnable example: [cURL script](/examples/guides/managing-contacts/list-contacts.sh) · [JavaScript](/examples/guides/managing-contacts/list-contacts.mjs) · [Python](/examples/guides/managing-contacts/list-contacts.py) Some contacts created in the console before creation times were recorded have `created_at: null`; they are listed after the contacts that have one. ## Keeping contacts in sync A reliable sync from your system: 1. Upsert on every change in your system (create, edit, consent change), with your record's phone number as the key. 2. Send consent and do-not-call changes in the same job, from your records (see [Consent and DNC](/guides/consent-and-dnc)). 3. Pace bulk syncs to the `write` rate limit (120 requests per minute per key, 240 per workspace) and retry `429` after `Retry-After`. The [nightly batch pattern](/guides/common-integration-patterns) shows a paced loop. 4. Keep the `ct_...` id next to your record for later updates and campaign recipients. ## What can go wrong - [`invalid_phone_number`](/errors/invalid_phone_number) (400): The number is not E.164. Send `+`, country code and number, no spaces. - [`validation_error`](/errors/validation_error) (400): A field is unknown or too long. Check `details.errors`. - [`custom_field_not_found`](/errors/custom_field_not_found) (422): A custom field name is not defined. Define it in the console or fix the name. - [`contact_already_exists`](/errors/contact_already_exists) (409): Create or number change to an existing number. Upsert, or update `details.contact_id`. - [`version_conflict`](/errors/version_conflict) (412): The contact changed since you read it. Read again and reapply. - [`contact_on_dnc_list`](/errors/contact_on_dnc_list) (409): Deleting a contact on the do-not-call list. Keep it, or remove the entry first if it really was a mistake. - [`contact_limit_reached`](/errors/contact_limit_reached) (409): The contact store is at its current capacity. Delete contacts you no longer need, or contact support. ## Next steps [Consent and DNC](/guides/consent-and-dnc). --- Source: /guides/consent-and-dnc # Consent and DNC **Availability: Available** RelationsAI enforces calling rules on every call, whether it is placed from the console or the API. This guide explains the do-not-call list, consent, calling hours and AI disclosure, how to manage DNC entries through the API and what a refused call looks like. These rules exist to protect the people you call, and breaking them can carry legal penalties for your business. No API key, scope or request option can switch them off, and the API refuses to start or resume a campaign whose do-not-call screening a person switched off in the console. For campaigns the timing matters: see [When the list takes effect](#when-the-list-takes-effect). ## Before you begin - `dnc:read`: Listing and looking up do-not-call entries - `dnc:write`: Adding and removing entries - `contacts:write`: Recording consent on contacts The workspace's compliance settings (whether consent is required, the calling window, the per-number daily limit) are set by an admin in the console. No API key can read or change them; that is deliberate. ## Rules that always apply Every single call (placed with [`POST /calls`](/api/calls#place-a-call) or from the console) passes these checks before anything is dialled, and every campaign applies them to all of its recipients when it starts and again when it resumes. Campaigns are **not** re-checked before each call: see [When the list takes effect](#when-the-list-takes-effect). The order of the checks is: 1. **Do-not-call list**: calls to the number are refused, and campaigns leave it out when they start or resume (always, in both modes). Refusal: [`dnc_listed`](/errors/dnc_listed). 2. **Consent**: when the workspace requires it, a contact without recorded consent is not called (when an admin switched it on). Refusal: [`consent_required`](/errors/consent_required). 3. **Calling hours**: calls only inside the workspace's calling window (when an admin set a window). Refusal: [`outside_calling_hours`](/errors/outside_calling_hours). 4. **Per-number daily limit**: at most N calls to one number in a rolling 24 hours (when an admin set a limit). Refusal: [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached). You see the first rule that blocks the call. A refused call is **not placed and not charged**. **AI disclosure**: an agent cannot be published unless its instructions tell the people it calls that they are speaking with an AI. This is checked when the agent is published; RelationsAI does not verify what is said on each call, so keep the disclosure at the start of your agent's instructions and check it in transcripts. Test mode enforces the same rules with test-mode data: a test-mode do-not-call entry or a test-mode contact without consent refuses a test call exactly as in live mode. Test-mode entries never affect live calls, and live entries never affect test calls. ## Managing the do-not-call list Add a number **as soon as someone asks not to be called**, from whichever channel they asked in (a call, an email, your website, an agent's note). [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list) adds a number. It is safe to repeat: adding a number that is already listed returns the existing entry with `200` instead of `201`. ```bash curl -X POST https://voice-api.relationsai.com/v1/dnc-entries \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550107" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/dnc-entries", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550107" }), }); const entry = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/dnc-entries", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550107", }, timeout=30, ) entry = response.json() ``` Complete runnable example: [cURL script](/examples/guides/consent-and-dnc/add-dnc-entry.sh) · [JavaScript](/examples/guides/consent-and-dnc/add-dnc-entry.mjs) · [Python](/examples/guides/consent-and-dnc/add-dnc-entry.py) The entry (`201`): ```json { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "phone_number": "+919000000003", "contact_id": "ct_5Qw8Er2Ty6Ui9Op3As7Df4", "created_at": null } ``` What adding a number does, immediately and in the key's mode: - Calls to it are refused with `dnc_listed`. - Campaigns leave it out at start, and a paused campaign that still holds it **cannot be resumed** (see [Running campaigns](/guides/running-campaigns)). - A campaign that is **already running** is not screened again: see below. - If a contact has this number, the contact is marked `do_not_call: true` and its consent is withdrawn. If no contact has it, a contact holding only the number is created, so the number is visible in the console. - `created_at` is recorded in test mode; in live mode it is `null` today because the time is not stored yet. ### When the list takes effect The list is checked at three moments, in both modes: | Moment | What happens to a listed number | |---|---| | Every [`POST /calls`](/api/calls#place-a-call) | Refused with `dnc_listed` | | Every campaign start | Left out of the run (`suppressed`) | | Every campaign resume | The resume is refused (`campaign_recipients_blocked`) while it is a remaining recipient | A campaign that is **already running** is not checked again before each call. If you add a number while a campaign is running and that person has not been called yet, the run still calls them. To apply the new entry at once, **pause the campaign and resume it** (the resume is refused while the number is a remaining recipient; then follow the remedy in [Running campaigns](/guides/running-campaigns)), or **stop it**. Test mode behaves the same way, so you can try this safely. Matching ignores formatting: a number matches by its digits, and by its last ten digits when both numbers have at least ten. So `+919000000003` also blocks a contact stored as `9000000003`. To check one number, list entries filtered by it: ```bash curl -X GET "https://voice-api.relationsai.com/v1/dnc-entries?phone_number=%2B12025550107" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/dnc-entries?phone_number=%2B12025550107", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const entries = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/dnc-entries", headers={ "Authorization": "Bearer ", }, params={ "phone_number": "+12025550107", }, timeout=30, ) entries = response.json() ``` A listed number comes back as one entry in `data`; an empty `data` means the number is not on the list. Complete runnable example: [cURL script](/examples/guides/consent-and-dnc/check-number.sh) · [JavaScript](/examples/guides/consent-and-dnc/check-number.mjs) · [Python](/examples/guides/consent-and-dnc/check-number.py) You do not need to check before calling: the API checks every call. Check when you want to show the status in your own system. To mirror your list into RelationsAI, page through [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries) (a stable order; live entries have no time added yet) and add what is missing on your side, and add every number your system marks as do-not-call. Keep the two lists in sync in both directions. ## Removing an entry [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list) removes a number from the list, so it can be called again. Removal should be rare and deliberate: do it only when the entry was added by mistake, or the person has clearly asked to be called again. Keep a record on your side of who asked and when. ```bash curl -X DELETE https://voice-api.relationsai.com/v1/dnc-entries/dnc_4Ty8Ui2Op6As3Df9Gh7Jk5 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/dnc-entries/dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", { method: "DELETE", headers: { "Authorization": "Bearer ", }, }); const deleted = await response.json(); ``` ```python response = requests.delete( "https://voice-api.relationsai.com/v1/dnc-entries/dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", headers={ "Authorization": "Bearer ", }, timeout=30, ) deleted = response.json() ``` Response `200`: ```json { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "deleted": true } ``` Complete runnable example: [cURL script](/examples/guides/consent-and-dnc/remove-dnc-entry.sh) · [JavaScript](/examples/guides/consent-and-dnc/remove-dnc-entry.mjs) · [Python](/examples/guides/consent-and-dnc/remove-dnc-entry.py) - The removal is recorded in the workspace audit log with the API key that made it. - **Consent is not restored.** If the workspace requires consent, record it again on the contact only if the person gives it. - A contact whose number is on the list cannot be deleted ([`contact_on_dnc_list`](/errors/contact_on_dnc_list)), so deleting a contact can never quietly remove a protection. ## Consent When an admin switches on the consent requirement, a call to a number that matches a contact whose `consent_granted` is `false` is refused with `consent_required`. A number that matches no contact is not blocked by this rule: there is no consent record to contradict. Record consent through the contacts API, from your own records of when and how the person agreed: ```json { "phone_number": "+12025550100", "consent_granted": true } ``` sent to [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) (or `PATCH` on the contact). `true` is recorded with the time and the API as its source; `false` records a withdrawal. Only set `true` when the person really consented. Setting consent to make a refusal go away is exactly what the rule exists to prevent. Consent is withdrawn automatically when a number is added to the do-not-call list. **The do-not-call list always wins**: while a number is listed, calls to it are refused whatever `consent_granted` says, and recording consent for it does not make it callable. Removing the entry later does not restore consent either. ## Calling hours and the daily limit - The **calling window** is evaluated in the timezone of the workspace's calling region, which is not necessarily the `timezone` shown on [`GET /workspace`](/api/workspace#retrieve-the-workspace), and never the contact's. A refusal's `details` gives the window and the timezone it was evaluated in, for example `{"window": "09:00-21:00", "timezone": "Asia/Kolkata"}`. If the window is switched on with no hours set, every call is refused (`details.window` is `null`). - Campaigns: a start or resume outside the window is refused as a whole. - The **per-number daily limit** counts calls to one number over the last 24 hours, from the console, the API and campaigns. It is separate from the workspace's daily limit of calls placed through the API ([`daily_call_limit_reached`](/errors/daily_call_limit_reached)). ## Refusals A refusal is a normal API error with `retry: "no"`: ```json { "error": { "code": "dnc_listed", "message": "This number is on your do-not-call list, so the call was not placed.", "details": {}, "request_id": "req_k3m7q2x9w4z8b5n6c1v0h2j7", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/dnc_listed" } } ``` What each refusal means and who can change it: - `dnc_listed`: The person asked not to be called. Do not call. Mark the record as do-not-call in your system. Who can change it: Nobody, unless the entry was a mistake. - `consent_required`: Consent is required and this contact has none. Get consent through your own process first. Who can change it: Your team, with the person. - `outside_calling_hours`: Outside the calling window. Retry inside the window in `details`. Who can change it: An admin can change the window in the console. - `contact_daily_limit_reached`: Called too often in 24 hours. Try again after the window. Who can change it: An admin can review the limit. - `campaign_recipients_suppressed`: Every campaign recipient is blocked. Choose callable recipients. Who can change it: Your team. - `campaign_recipients_blocked`: A resume found newly blocked recipients. Create a new campaign with the still-pending recipients minus the blocked ones, then stop this one (never stop and start it: that calls everyone again). Who can change it: Your team. - `campaign_dnc_override_enabled`: Do-not-call screening was switched off for this campaign in the console; the API will not start or resume it. Who can change it: A workspace admin, in the console. > **Never route around a refusal:** Do not retry a refused call with a different number format, from a different workspace, as a campaign instead of a single call, or after removing a do-not-call entry or setting consent you did not receive. Record the refusal in your system as the outcome for that person. The checks are the same on every path, and working around them puts your business, not the API, in breach. ## What can go wrong - **A number you added is still called by a running campaign**: Campaigns screen at start and at resume; calls already connected finish. Fix: Pause or stop the campaign; a resume is refused while the number is in its run. - **[`consent_required`](/errors/consent_required) for someone who agreed**: Consent recorded in your system but not in RelationsAI. Fix: Upsert the contact with `consent_granted: true` from your record. - **Consent was set but calls are still refused after a removal from the list**: Removing an entry does not restore consent. Fix: Record consent again only if the person gives it. - **[`contact_limit_reached`](/errors/contact_limit_reached) on add**: Adding an unknown number creates a contact. Fix: Delete contacts you no longer need, or contact support. - **[`insufficient_scope`](/errors/insufficient_scope)**: The key lacks `dnc:write`. Fix: Ask an admin for a key with it. ## Next steps [Receiving webhooks](/guides/receiving-webhooks). --- Source: /guides/receiving-webhooks # 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 " \ -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 ", "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 ", }, 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=,v1=`, 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 " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const delivery = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/test", headers={ "Authorization": "Bearer ", }, 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 " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries?limit=20", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const deliveries = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/deliveries", headers={ "Authorization": "Bearer ", }, 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). --- Source: /guides/verifying-webhook-signatures # Verifying webhook signatures **Availability: Preview** Every webhook request is signed with your endpoint's secret. Verify the signature before trusting an event. This guide explains the signature format, gives verification code and covers clock tolerance and secret rotation. Your webhook URL is public, so anyone can send it a request. The signature is what proves a request came from RelationsAI and was not changed on the way. Never act on an event whose signature you have not verified. ## Before you begin - The endpoint's signing secret (`whsec_...`), returned once when you created the endpoint or rotated its secret. Keep it in a secret manager and pass it to your server as configuration, never in source code. - Access to the **raw request body**, the exact bytes received, before any JSON parsing. Most frameworks parse JSON for you; turn that off for the webhook route or read the raw body first. ## The signature header ```text RelationsAI-Signature: t=1792575900,v1=ae0eab64988220d98b2bce1e1bc69ba13218cd445650f3ec6e57c66bb5e93c9b ``` | Part | Meaning | |---|---| | `t` | When the delivery was signed, in Unix seconds | | `v1` | Hex-encoded HMAC-SHA256 of the string `.`, keyed with your signing secret | Details that matter: - The HMAC key is the **whole secret string**, including the `whsec_` prefix, as UTF-8 bytes. Do not strip the prefix or decode it. - The signed message is the value of `t`, a full stop, then the raw body bytes exactly as received. - During a secret rotation the header carries **two** `v1=` values, one per secret, in any order: `t=...,v1=,v1=`. Accept the event if any of them matches. - Ignore parts you do not recognise; future signature schemes may add them. Requests also carry `RelationsAI-Event-Id`, `RelationsAI-Event-Type` and `RelationsAI-Delivery-Id`. They are convenient for logging and routing, but they are **not signed**: take the id and type you act on from the verified body. ## Verify step by step 1. Read the raw body as bytes. Do not parse and re-serialise it first: whitespace, key order and Unicode escaping would change and the signature would not match. 2. Parse the header: split on `,`, then each part on the first `=`. Take `t` and every `v1`. 3. Reject the request if there is no `t` or no `v1`. 4. Reject it if `t` is more than **5 minutes** (300 seconds) away from your clock, in either direction. This stops an attacker who captured a delivery from replaying it later. RelationsAI signs every delivery attempt with a fresh `t`, retries included, so legitimate retries pass. Keep your server clock synchronised. 5. Compute HMAC-SHA256 over `.` with your secret. 6. Compare it with each `v1` in **constant time** (`crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python). A normal string comparison can leak, through timing, how much of a guess was right. 7. Accept the request if any `v1` matches. Otherwise answer `400` and do not process it. Then deduplicate by the event id and process it, as in [Receiving webhooks](/guides/receiving-webhooks). Here is the verification as a function you can import, with no dependencies. Run directly, it checks a saved delivery from the command line, which helps when you are debugging a mismatch: ```js // rawBody: the request body exactly as received (a Buffer). secrets: the endpoint's whsec_... secret(s). function verifySignature(rawBody, header = "", secrets, toleranceSeconds = 300) { 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 === 0) return false; if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false; return secrets.some((secret) => { const expected = createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest(); return signatures.some((signature) => timingSafeEqual(signature, expected)); // any v1 may match }); } ``` ```python # raw_body: the request body exactly as received (bytes). secrets: the endpoint's whsec_... secret(s). def verify_signature(raw_body, header, secrets, tolerance_seconds=300): 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: return False if abs(time.time() - int(t.group(1))) > tolerance_seconds: return False signed = t.group(1).encode() + b"." + raw_body for secret in secrets: expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if any(hmac.compare_digest(expected, s) for s in signatures): # any v1 may match return True return False ``` Complete runnable example: [JavaScript](/examples/guides/verifying-webhook-signatures/verify.mjs) · [Python](/examples/guides/verifying-webhook-signatures/verify.py) Both versions are tested against the signature test vectors RelationsAI's own signer is checked with: a body with non-ASCII characters, a rotation header with two signatures, a tampered body, a wrong secret, a changed timestamp and a timestamp outside the tolerance. Check your own implementation with this one (the secret is a fictional test value): ```text secret whsec_test_vector_secret_0001_Ab3dEf6hIj9kLm2nOp5q t 1792575900 raw body {"id":"evt_test0000000000000001","object":"event","type":"webhook_endpoint.test","created_at":"2026-10-21T09:45:00.000Z","livemode":false,"api_version":"2026-10-21","data":{"object":{"id":"we_test0000000000000001","object":"webhook_endpoint"}}} expected header t=1792575900,v1=ae0eab64988220d98b2bce1e1bc69ba13218cd445650f3ec6e57c66bb5e93c9b ``` Verify it with `now` set to `1792575900`; with your real clock it is (correctly) rejected as too old. To check a delivery you saved (body in a file, header copied from your logs), put the endpoint's secret in place of `` and the saved header in place of ``, raise `toleranceSeconds` above the delivery's age, and run `node verify.mjs < saved-body.json`. Raise the tolerance only for this offline check, never in your server. ## Using a framework The rule is the same in every framework: give the verifier the raw bytes. For example, with Express (optional; the examples above need no framework): ```js router.post("/webhooks/relationsai", express.raw({ type: "application/json" }), (req, res) => { const check = verifySignature(req.body, req.get("RelationsAI-Signature"), [""]); if (!check.valid) return res.status(400).send("invalid signature"); // record the event by id, answer 200, process later res.sendStatus(200); }); ``` With Flask (optional), use `request.get_data()` for the raw bytes, not `request.json`. Register the raw-body handling only on the webhook route so the rest of your app keeps its usual JSON parsing. ## During secret rotation Rotate the secret on a schedule, whenever someone who knew it leaves, and immediately if it may have leaked. [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret) issues a new secret, returned **once**, and keeps the old one signing for a grace period you choose: `grace_period_hours` of `0` (retire the old secret immediately), `1`, `24` or `168`. ```bash curl -X POST https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "grace_period_hours": 24 }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "grace_period_hours": 24 }), }); const endpoint = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/webhook-endpoints/we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1/rotate-secret", headers={ "Authorization": "Bearer ", }, json={ "grace_period_hours": 24, }, timeout=30, ) endpoint = response.json() ``` The response is the endpoint with the new `secret` (shown once; `null` only if you sent an idempotency key and this is a replay of an earlier request with it). Complete runnable example: [cURL script](/examples/guides/verifying-webhook-signatures/rotate-secret.sh) · [JavaScript](/examples/guides/verifying-webhook-signatures/rotate-secret.mjs) · [Python](/examples/guides/verifying-webhook-signatures/rotate-secret.py) During the grace period every delivery carries two `v1` signatures, one made with each secret. A safe rollout: 1. Rotate with a grace period long enough to deploy (for example 24 hours) and store the new secret. 2. Deploy the new secret to your servers. Old servers (with the old secret) and new servers (with the new one) both verify successfully, because each finds its own signature among the two `v1` values. 3. After the grace period only the new secret signs. Remove the old one from your configuration. Rules when you rotate more than once: - **`grace_period_hours: 0` is always allowed**, even while an earlier grace period runs, and retires **every** previous secret at once. Use it when a secret may have leaked, and deploy the new secret immediately: deliveries fail verification until it is live and are retried, so nothing is lost if you deploy within the retry window. - **A rotation with a grace period while another grace period is still running** keeps the **oldest** secret that is still valid (the one your receivers most likely still use) and discards the intermediate one. At most two secrets ever sign: that oldest one and the newest. - **The new secret is shown only in the first response.** A retry of the same request with the same optional idempotency key returns the endpoint with `secret: null`, because the secret is stored only in encrypted form. If you lost the response, **rotate again** and use that secret. With a grace period, your current secret keeps working meanwhile (the lost intermediate one is discarded). The verifier accepts a list of secrets, so you can also configure both during your own rollout. Remove the old one afterwards. ## Test your verifier - **Without RelationsAI**: sign events yourself and post them to your local receiver. These commands sign with your own secret using `openssl`: ```bash body='{"id":"evt_local0001","object":"event","type":"webhook_endpoint.test","created_at":"2026-10-21T09:45:00.000Z","livemode":false,"api_version":"2026-10-21","data":{"object":{"id":"we_local","object":"webhook_endpoint"}}}' t=$(date +%s) v1=$(printf '%s.%s' "$t" "$body" | openssl dgst -sha256 -hmac "whsec_..." | sed 's/^.*= *//') curl -X POST http://localhost:8000/webhooks/relationsai \ -H "Content-Type: application/json" \ -H "RelationsAI-Signature: t=$t,v1=$v1" \ -d "$body" ``` Complete runnable example: [cURL script](/examples/guides/verifying-webhook-signatures/sign-and-send.sh) - **With RelationsAI**: send a `webhook_endpoint.test` event with [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event) (or from the console) and check in the delivery log that it `succeeded` (see [Receiving webhooks](/guides/receiving-webhooks#test-and-inspect-deliveries)). - **Negative tests**: change one byte of the body, use a wrong secret, or send a `t` ten minutes old. Each must be rejected with `400`. ## What can go wrong - **Every signature fails**: The body was parsed and re-serialised, or decoded and re-encoded, before verifying. Fix: Verify the raw bytes exactly as received. - **Every signature fails**: The `whsec_` prefix was stripped, or the secret was decoded. Fix: Use the whole secret string as the key. - **Every signature fails after a deploy**: The server has the secret of another endpoint or the other mode. Fix: Each endpoint has its own secret; check `secret_last4`. - **Some signatures fail with "timestamp outside the tolerance"**: The server clock is off, or deliveries are queued on your side before verification. Fix: Synchronise the clock; verify on receipt. - **Failures right after a rotation**: The old secret was retired (`grace_period_hours: 0`) before the new one was deployed. Fix: Deploy the new secret; failed deliveries are retried. - **Signature valid but the same event processed twice**: Duplicate deliveries are normal. Fix: Deduplicate by the event id. Failed verifications show in the delivery log as `http_error` with `response_status: 400` (the status your receiver answered). ## Next steps [Retry and reliability practices](/guides/retry-and-reliability). --- Source: /guides/retry-and-reliability # Retry and reliability practices **Availability: Available** Networks fail and services get busy. This guide shows how to read the `retry` hint in errors, how to back off, and how to retry a write without doing it twice: by checking state before you resend, and optionally by sending an idempotency key. The goal is simple to state: **every call you intend to place is placed at most once, and you always find out whether it was.** Placing calls and starting campaigns cost money in live mode and reach real people, so "probably did not happen" is never good enough. ## Before you begin Read [Error handling](/fundamentals/error-handling). [Idempotency](/fundamentals/idempotency) explains the optional `Idempotency-Key` header used in one section below. ## Use the retry hint Every error carries `error.retry`, which tells you what to do without a table of codes: | `retry` | Meaning | What to do | |---|---|---| | `no` | The request cannot succeed as sent | Do not retry. Fix the request, the configuration, or accept the refusal (for example `dnc_listed`). | | `yes` | A transient failure | Retry with exponential backoff and jitter, after checking state for a write that may have happened | | `after` | Wait first | Retry after the number of seconds in the `Retry-After` header | Each code's page in the [error-code reference](/fundamentals/error-codes) also says whether work **may have started** before the error (its "execution" fact). For example [`dnc_listed`](/errors/dnc_listed) is "not started": nothing happened, and sending the request again after fixing the cause is safe. [`internal_error`](/errors/internal_error) (500) is "may have started": for a write, the API does not know whether the change was applied. Never assume a failed request had no side effects unless its code says so. ## Every request runs The API does not require an idempotency key. **Without one, every request you send is executed.** If a `POST /calls` times out after the API accepted it and you simply send it again, you place a second call. So the rule for any write whose outcome you did not see: 1. **Put your own id in the request** where you can find it again: `metadata` on a call (for example your record id and the attempt), the phone number on a contact, the campaign `name`. 2. **After a timeout, a dropped connection or a `5xx`, check the state first**: read or list the resource and look for your id. 3. **Send the request again only if it did not happen.** Errors that say nothing happened (`retry: "no"` refusals before any work, `429 rate_limited`, `429 calling_capacity_reached`, `503 service_unavailable`) can be resent without a check. ## A call that can be retried safely The whole rule for [`POST /calls`](/api/calls#place-a-call), as a sequence of requests. The complete runnable example below implements it with backoff, jitter and the `retry` hint. **1. Place the call with your own id in `metadata`:** ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "metadata": { "logical_id": "renewal-L-1042-2026-11" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "metadata": { "logical_id": "renewal-L-1042-2026-11" } }), }); const call = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "metadata": { "logical_id": "renewal-L-1042-2026-11", }, }, timeout=30, ) call = response.json() ``` **2. If it times out, fails to connect or answers `5xx`,** look for the call before sending again: list calls to that number since just before your first attempt, and match your `metadata`: ```bash curl -X GET "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09:20:00.000Z" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09:20:00.000Z", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const calls = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, params={ "to": "+12025550100", "created_after": "2026-10-21T09:20:00.000Z", }, timeout=30, ) calls = response.json() ``` If a call with your `logical_id` is in `data`, it was placed: store its id and stop. Calls appear in the list as soon as they are accepted, but allow a few seconds after a timeout before you conclude it is not there. Only if it is not, send the request from step 1 again. **3. If it answers `429` or `503`,** nothing was done: wait `Retry-After` and send it again. Complete runnable example: [cURL script](/examples/guides/retry-and-reliability/create-call-reliably.sh) · [JavaScript](/examples/guides/retry-and-reliability/create-call-reliably.mjs) · [Python](/examples/guides/retry-and-reliability/create-call-reliably.py) The same pattern applies to campaign starts and resumes (read the campaign: `running` means it started), contact upserts (repeating one is harmless) and every other write. ## Optional: an idempotency key For extra safety you can send an `Idempotency-Key` header (any unique value, a UUID is ideal) on a POST or DELETE, and the same key on every retry of that request. Then a retry of a request that already succeeded returns the original answer with `Idempotent-Replayed: true` instead of running again, for 24 hours. Create the key once per action and save it **before** the first attempt, so a retry after a crash reuses it. The rules, including the 409 answers for a key reused with a different request, a first attempt still running, or an interrupted one, are in [Idempotency](/fundamentals/idempotency). A key does not replace the state check after a `500`: a `500` is not stored under the key, so a retry with the same key runs the request again. PATCH requests are protected by `If-Match` instead (see [Concurrency](/fundamentals/concurrency)): repeating the same PATCH with a fresh ETag is harmless. ## Backoff - **Exponential with jitter.** Wait a random time between 0 and `min(cap, base × 2^attempt)`. Jitter spreads retries from many workers so they do not hit the API in synchronised waves. The complete example uses a base of 1 second and a cap of 30. - **Honour `Retry-After`** on `429` and `503` (and on any error with `retry: "after"`); it is the earliest time the request can succeed. - **Cap the attempts** (for example 5 or 6) and the total time. Then park the action, check its state later, or alert a person. - **Retry only what can succeed.** Never retry `retry: "no"`. Do not retry `400`, `401`, `403`, `404` or `422` in a loop. ## Rate limits and the daily call cap Limits are per key and per workspace, separately in test and live mode, in three classes. Each operation's class is in the API reference (`x-rate-limit-class`): | Class | Operations | Per key per minute | Per workspace per minute | |---|---|---|---| | `read` | Every GET | 600 | 1200 | | `write` | Other writes (contacts, DNC, campaign create, pause, stop, edits, webhooks) | 120 | 240 | | `dial` | `POST /calls`, campaign start and resume | (workspace limit) | 30 | Every response carries `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds until the window resets). Over the limit you get [`rate_limited`](/errors/rate_limited) (429) with `Retry-After`. Read the headers and slow down before you hit the limit: a batch job should pace itself (see [Common integration patterns](/guides/common-integration-patterns)). Two more limits concern calls, and neither is a rate limit: - [`calling_capacity_reached`](/errors/calling_capacity_reached) (429): call starts are paced across the telephony network. The request was valid; nothing was dialled. Wait `Retry-After` and send it again, or use a campaign for volume. - [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429): live mode caps calls placed through the API per workspace per UTC day, as protection against runaway scripts and retry storms (`limits.daily_call_limit` on [`GET /workspace`](/api/workspace#retrieve-the-workspace)). It resets at 00:00 UTC; `Retry-After` and `details.resets_at` say when. A campaign start counts every recipient and is refused whole when they do not all fit (`details.requested`, `details.remaining`); a resume never counts. If your expected volume is higher, write to support@relationsai.com. Do not "retry until it works". When the API is unavailable it fails closed with [`service_unavailable`](/errors/service_unavailable) (503): nothing was done; send the request again after `Retry-After`. ## When the outcome is unknown Some failures leave you not knowing whether the action happened. Treat them as **"may have happened"** until you have checked. - **Timeout or connection error on a POST**: the request may have been processed. Check the state (step 2 above) before sending it again. If you sent an idempotency key, sending the same request with the same key is also safe: a replay tells you it happened. - **[`internal_error`](/errors/internal_error) (500)**: the write may have been applied. Read the resource first; send it again only if it did not happen. - **`202` with the call `queued`**: accepted; the start is still being confirmed. Do nothing new. Follow the call: it proceeds, or ends with `end_reason: not_confirmed` (the start was never confirmed; not charged). - **`202` from a campaign start**: started, confirmation pending. Read the campaign in a minute; do not start it again. - **[`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409)**: a start or resume was sent and not confirmed; dialling may have begun. Wait `Retry-After`, then read the campaign: `running` means it started. Only if it did not, send the action again (the campaign refuses to dial the same run twice). If you sent an idempotency key, the same key returns this answer again. If `details.needs_support` is true, contact support first. - **[`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) (409)**: the pause may not have taken effect; the campaign may still be dialling. Read the campaign; if it is still running, pause again or stop it. - **[`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) (409)**: only with an idempotency key: the first attempt with that key was interrupted mid-way. Check whether it happened before sending anything; only if it did not, send it again with a **new** key or none. If the state is still ambiguous after a few minutes, write to support@relationsai.com with the `request_id`. ## What can go wrong - **Resending a call after a timeout without checking**: a second call. Instead: list calls by `to` and `created_after`, match your `metadata`, then decide. - **No id of your own in the request**: you cannot tell your call from others after a timeout. Instead: put your record id in `metadata`. - **A new Idempotency-Key per retry** (if you use keys): no protection at all. Instead: one key per action, saved before the first attempt. - **The same key for a different request**: [`idempotency_key_reused`](/errors/idempotency_key_reused). Instead: a new key for each new action. - **Retrying `retry: "no"` errors**: wasted requests, possible rate limiting. Instead: fix the cause or accept the refusal. - **Retrying in a tight loop**: [`rate_limited`](/errors/rate_limited), then `daily_call_limit_reached`. Instead: backoff with jitter and a cap. ## Next steps [Going live](/guides/going-live). --- Source: /guides/integrating-with-crm-systems # Integrating with CRM systems **Availability: Preview** The most common integration connects RelationsAI to a CRM: a new or updated lead triggers a call, and the call's outcome, summary and transcript flow back to the lead record. This guide describes that pattern end to end. It works with any CRM that can send an HTTP request (or be polled) when a record changes, and that you can write to through its own API. Nothing here depends on a particular CRM product. ## Before you begin | You need | For | |---|---| | A live key and a test key, each with `contacts:write`, `calls:write`, `calls:read`, `analysis:read`, `dnc:write` | Your integration service (one key per mode) | | `transcripts:read` (optional) | Copying transcripts to the record | | A webhook endpoint subscribed to `call.ended` and `call.settled` | Writing results back ([Receiving webhooks](/guides/receiving-webhooks)) | | A ready agent | The calls ([Creating and preparing agents](/guides/creating-and-preparing-agents)) | Keep the API key in your integration service, never in the CRM's browser-side scripts: live keys are refused from browsers. ## The architecture ```text CRM record changes ──▶ your integration service 1. compliance first: do-not-call flag ─▶ POST /dnc-entries, stop 2. POST /contacts/upsert (name, consent, custom fields) 3. POST /calls (metadata: crm_record_id, crm_event_id; skipped if this CRM event id was handled) RelationsAI ──webhook──▶ your integration service 4. call.ended ─▶ status on the record ("No answer", "Busy") 5. call.settled ─▶ GET /calls/{id}/analysis ─▶ summary + fields to the record Every hour ─▶ 6. reconcile: GET /calls?created_after=... ─▶ anything the webhooks missed ``` Your integration service is the only component that holds RelationsAI keys and the CRM's credentials. It records which CRM events it has handled and which calls it has written back, so every step can safely run twice. ## Sync the contact Upsert the contact by phone number with the fields the agent needs ([`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number)). Map CRM fields to the contact's `name`, `language` and [custom fields](/guides/managing-contacts#custom-fields) (defined in the console), so the agent can use them as `{{field_name}}`. Send `consent_granted` only as your CRM records it. ## Trigger the call Place the call with the CRM record id and the CRM event id in `metadata`. CRM automations commonly fire twice for one change, and webhook deliveries from your CRM can be retried too, so **record each CRM event id you handled and skip one you have already seen**. Every request to RelationsAI is executed, so this check is what prevents a second call. You can also ask RelationsAI: list the calls to that number from the last day ([`GET /calls`](/api/calls#list-calls) with `to` and `created_after`) and look for one whose `metadata.crm_event_id` matches, as the complete example does. For extra safety you can also send an optional `Idempotency-Key` derived from the CRM event, for example `crm--call`: a duplicate then replays the first answer (see [Idempotency](/fundamentals/idempotency)). One CRM event becomes this sequence of requests (compliance, upsert, call): **1. Upsert the contact** from the CRM record. Send `consent_granted` only as the CRM records it: ```bash curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550100", "name": "Asha" }), }); const contact = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/contacts/upsert", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", "name": "Asha", }, timeout=30, ) contact = response.json() ``` **2. Place the call** with the CRM ids in `metadata`. Handle each CRM event id once; an optional `Idempotency-Key` such as `crm-E-9001-call` adds a second guard: ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha" }, "metadata": { "crm_record_id": "L-1042", "crm_event_id": "E-9001" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha" }, "metadata": { "crm_record_id": "L-1042", "crm_event_id": "E-9001" } }), }); const call = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "variables": { "first_name": "Asha", }, "metadata": { "crm_record_id": "L-1042", "crm_event_id": "E-9001", }, }, timeout=30, ) call = response.json() ``` If the CRM marks the person do-not-call, send this instead of steps 1 and 2, and place no call: ```bash curl -X POST https://voice-api.relationsai.com/v1/dnc-entries \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/dnc-entries", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550100" }), }); const entry = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/dnc-entries", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", }, timeout=30, ) entry = response.json() ``` Complete runnable example: [cURL script](/examples/guides/integrating-with-crm-systems/crm-event-to-call.sh) · [JavaScript](/examples/guides/integrating-with-crm-systems/crm-event-to-call.mjs) · [Python](/examples/guides/integrating-with-crm-systems/crm-event-to-call.py) Decisions in this flow: - **Compliance first.** If the CRM marks the person do-not-call, add the number to the do-not-call list and stop. Never call to "confirm" an opt-out. - **A refusal is an outcome, not an error to retry.** `retry: "no"` errors (for example `dnc_listed`, `consent_required`, `outside_calling_hours`) are written to the CRM record as the result of the attempt. Retry only `retry: "yes"` or `"after"` errors, and after a timeout check whether the call exists (by `metadata`) before resending. - **A `202` is not a result.** Write "call queued" to the record if you like; the outcome comes from the webhooks. For many records at once (a nightly list, a segment), use a campaign instead of a loop of single calls: see [Common integration patterns](/guides/common-integration-patterns). ## Write the outcome back On `call.ended`, write the `outcome` early if your users want to see "No answer" or "Busy" quickly. On `call.settled`, everything is final: 1. Read `data.object.metadata.crm_record_id` from the event. No lookup table needed. 2. Fetch [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis), and the transcript if you copy it. 3. Write to the record: outcome, duration, the summary, each `ready` extracted field mapped by name, `goal_met`, and the call id (so a person can trace the values back to the conversation). 4. Record the call id as written back. A duplicate `call.settled` then changes nothing. The [webhook receiver](/guides/receiving-webhooks#a-complete-receiver) does steps 1 and 2 on `call.settled`; replace its log line with your CRM write. Put the CRM write in the background step, never before answering the webhook. What to write where is your choice, but some advice: - Store transcripts and recordings only if your users need them and your data-protection obligations allow it. A link back to your own copy is often better than pasting a whole transcript into a note. - Never store the recording `url`: it expires in about 15 minutes and works without a key. Download the audio instead. - Write `ready` analysis values only; do not overwrite CRM data with `absent` (`null`) values. ## Keep compliance in sync Do-not-call and consent must be the same in both systems, in both directions: | Change | Direction | Do | |---|---|---| | The person opts out in the CRM | CRM to RelationsAI | [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list) immediately, before any further call | | Consent recorded or withdrawn in the CRM | CRM to RelationsAI | Upsert the contact with `consent_granted: true` or `false` | | A call is refused with `dnc_listed` | RelationsAI to CRM | Mark the record do-not-call | | Someone is added to the list in the console | RelationsAI to CRM | Periodically list [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries) and mark new numbers in the CRM | Never remove a do-not-call entry because the CRM still shows the person as callable. A removal is a deliberate act with an audit trail (see [Consent and DNC](/guides/consent-and-dnc)). ## Reconcile Webhooks can be missed: your endpoint was down for longer than the retry window, a deploy broke verification, an event was acknowledged but not processed. Run a scheduled job that lists recent calls ([`GET /calls`](/api/calls#list-calls) with `created_after` and `status=ended`), and writes back any settled call that is not in your written-back table. The [reconciliation example](/guides/common-integration-patterns#reconciliation) does exactly that. ## Test to live 1. Build and test everything with a **test key** and a **test-mode** webhook endpoint. Test contacts, calls and endpoints never mix with live ones. 2. Use the test numbers to exercise each branch: `+12025550100` (completed, with analysis), `+12025550101` (no answer), `+12025550103` (failed), and a number you add to the test-mode do-not-call list (refused). 3. Create a live key with the same scopes and a live webhook endpoint (it has its own secret). Switch your service's configuration, not its code. 4. Start with a small set of real records and watch the first results in both systems. See [Going live](/guides/going-live). Ids are mode-specific: a `ct_...` or `call_...` id from test mode does not exist in live mode. Do not copy stored ids from your test setup into production data. ## What can go wrong - **Two calls for one CRM change**: The same CRM event handled twice, or a blind retry after a timeout. Fix: Record handled CRM event ids; check for the call by `metadata` before resending; optionally send a key derived from the CRM event id. - **[`idempotency_key_reused`](/errors/idempotency_key_reused)** (only if you send keys): One key used for two different requests (for example two steps sharing one key). Fix: Add the step to the key (`crm--contact`, `crm--call`). - **Results written to the wrong record**: Matching by phone number instead of `metadata`. Fix: Put the record id in `metadata` and match on it. - **Analysis fields missing in the CRM**: Written on `call.ended` before they were ready. Fix: Write analysis on `call.settled`. - **The CRM shows a person callable who opted out by phone**: Refusals not written back. Fix: Mark `dnc_listed` refusals and new do-not-call entries in the CRM. - **[`rate_limited`](/errors/rate_limited) during a bulk sync**: Unpaced loops. Fix: Pace writes and honour `Retry-After`. ## Next steps [Common integration patterns](/guides/common-integration-patterns). --- Source: /guides/common-integration-patterns # Common integration patterns **Availability: Preview** This page collects integration designs that work well with the RelationsAI API, with the trade-offs of each, so you can pick a starting point for your own system. Every pattern here uses the same building blocks: idempotent writes, webhooks for results, and the list endpoints for reconciliation. ## Before you begin | Pattern | Scopes | |---|---| | Event-triggered single calls | `contacts:write`, `calls:write`, `calls:read`, `analysis:read` | | Nightly campaign | `contacts:write`, `campaigns:write`, `campaigns:read` | | Reconciliation | `calls:read` (and `campaigns:read` for campaigns) | | Receiving results (all patterns) | `webhooks:write` once, to register the endpoint | See [API keys](/getting-started/api-keys). Give each component its own key with only the scopes it needs. ## Event-triggered single calls Your system places one call per business event (a new lead, a missed payment, an appointment tomorrow) and reacts to webhooks. ```text business event ─▶ queue ─▶ worker: upsert contact, POST /calls (key = event id) call.ended / call.settled ─▶ webhook receiver ─▶ inbox ─▶ worker: update your record ``` - Put events on a queue and place calls from a worker, not from the request that produced the event. The worker can then retry with backoff without blocking anything. - Record each event id you handled and skip a redelivered one, so it never places a second call; after a timeout, check for the call by `metadata` before resending. An optional `Idempotency-Key` derived from the event (`-call`) adds a second guard. See [Retry and reliability practices](/guides/retry-and-reliability). - Put your ids in `metadata`; the webhook handler needs nothing else to find the record. - Respect the `dial` rate limit (30 call starts per minute per workspace). Bursts above it are answered `429`; let the queue absorb them. The [CRM integration guide](/guides/integrating-with-crm-systems) shows this pattern in full, with a runnable example. **Trade-offs:** lowest latency (the call starts seconds after the event) and the simplest mental model. But pacing, calling-hours timing and per-recipient bookkeeping are yours to build, so it suits tens of calls per hour, not thousands at once. ## Nightly campaign from a data warehouse A scheduled job builds a campaign from a query, starts it and collects per-recipient outcomes. **1. Upsert each row** as a contact, pacing the requests (for example one every 600 ms) and keeping each `id`: ```bash curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "phone_number": "+12025550100", "name": "Asha Rao" }), }); const contact = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/contacts/upsert", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", "name": "Asha Rao", }, timeout=30, ) contact = response.json() ``` **2. Create the campaign** with up to 1000 of those ids: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "name": "Nightly renewals 2026-10-21", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "name": "Nightly renewals 2026-10-21", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] }), }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns", headers={ "Authorization": "Bearer ", }, json={ "name": "Nightly renewals 2026-10-21", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR", }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", ], }, timeout=30, ) campaign = response.json() ``` **3. Add the rest** in chunks of up to 1000, while it is still a draft: ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "contact_ids": [ "ct_5Qw8Er2Ty6Ui9Op3As7Df4" ] }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients", { method: "POST", headers: { "Authorization": "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ "contact_ids": [ "ct_5Qw8Er2Ty6Ui9Op3As7Df4" ] }), }); const added = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients", headers={ "Authorization": "Bearer ", }, json={ "contact_ids": [ "ct_5Qw8Er2Ty6Ui9Op3As7Df4", ], }, timeout=30, ) added = response.json() ``` **4. Start it:** ```bash curl -X POST https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start", { method: "POST", headers: { "Authorization": "Bearer ", }, }); const campaign = await response.json(); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start", headers={ "Authorization": "Bearer ", }, timeout=30, ) campaign = response.json() ``` Make a re-run safe: before each step, check whether it already happened (a campaign named `Nightly renewals 2026-10-21` already exists, it is already `running`). Every request is executed, so a blind re-run creates a second campaign. An optional `Idempotency-Key` per step derived from the batch date (for example `nightly-2026-10-21-start`) makes a re-run replay the steps that already happened. Complete runnable example: [JavaScript](/examples/guides/common-integration-patterns/nightly-batch.mjs) · [Python](/examples/guides/common-integration-patterns/nightly-batch.py) How the job is built: 1. **Query** the rows for tonight (people to remind, renew, follow up), with phone number, name, consent and do-not-call status from your records. 2. **Upsert contacts**, paced. Space the writes 600 ms apart (100 per minute, inside the `write` limit of 120 per key per minute) and resends after a `429` once `Retry-After` has passed (a `429` means nothing was done). For large lists, watch `RateLimit-Remaining` and slow down before you reach zero. 3. **Create one campaign** with up to 1000 `contact_ids`, then add the rest in chunks of 1000 with [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients) while it is still a draft (up to 10,000 per campaign). Once started, its recipients are fixed: tomorrow's list is a new campaign. 4. **Start it.** Before re-running the job the same night, look for tonight's campaign first (or send optional idempotency keys derived from the batch date), so a re-run never creates a second campaign. 5. **Collect outcomes** from `campaign.completed` (or poll the campaign) and page through [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients). Schedule the job so that the campaign starts inside your workspace's calling window: a start outside it is refused with [`outside_calling_hours`](/errors/outside_calling_hours) (`details` gives the window and the timezone it is evaluated in). Set a `budget_cap` on the campaign to limit what a wrong query can spend; the cap pauses the campaign, but the check runs about every 15 minutes, so spend can go past it by the calls placed in that time. In live mode a start counts every recipient against the workspace's daily limit of calls placed through the API and is refused whole if they do not all fit: a campaign larger than `limits.daily_call_limit` cannot be started through the API. Split very large audiences across days. **Trade-offs:** campaigns handle pacing (`concurrency_limit`), compliance screening of every recipient at start and resume, and per-recipient results for you, and one object tells you how the night went. But results arrive over the run and per-recipient data can lag by a minute, and recipients cannot be added after the start. ## Reconciliation Webhooks are the fast path; reconciliation is the safety net. A scheduled job (for example hourly) lists recent calls and syncs anything your webhook handler missed, using the list endpoints and their cursors: ```bash curl -X GET "https://voice-api.relationsai.com/v1/calls?limit=100&status=ended&created_after=2026-10-20T09:00:00.000Z" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=100&status=ended&created_after=2026-10-20T09:00:00.000Z", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const page = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, "status": "ended", "created_after": "2026-10-20T09:00:00.000Z", }, timeout=30, ) page = response.json() ``` For each call in `data` whose `settled` is `true` and that your system has not written back, write it back now (match it by `metadata`). While `has_more` is `true`, send the same request with `cursor` set to `next_cursor`. Complete runnable example: [cURL script](/examples/guides/common-integration-patterns/reconcile-calls.sh) · [JavaScript](/examples/guides/common-integration-patterns/reconcile-calls.mjs) · [Python](/examples/guides/common-integration-patterns/reconcile-calls.py) Rules for reliable reconciliation: - **Overlap the windows.** Use `created_after` a little earlier than the last run (calls created just before the last run may have settled since). Your "already synced" table makes the overlap harmless. - **Keep the filters fixed while paging.** A cursor is bound to the filters that produced it; changing them mid-way is [`invalid_cursor`](/errors/invalid_cursor). When you filter by `status`, a page can hold fewer than `limit` calls while `has_more` is true: keep following `next_cursor` until it is `null`. - **Only sync what is final.** Skip calls whose `settled` is still `false`; the next run picks them up. - **Use `metadata` to match.** The call carries your record id. - For campaigns, list [`GET /campaigns`](/api/campaigns#list-campaigns) filtered by `status` and page through each campaign's recipients. ## Rate-limit budgeting Limits are per key **and** per workspace, per mode. If several components share a workspace, they share the workspace limit: | Class | Per key | Per workspace | |---|---|---| | `read` | 600 / min | 1200 / min | | `write` | 120 / min | 240 / min | | `dial` | | 30 / min | Plan for it: - Give each component its own key, and budget its share of the workspace limit (for example: the nightly job 100 writes per minute, the event worker 60, leaving headroom for the console and retries). - Prefer one list request over many single reads, and webhooks over polling. - Read `RateLimit-Remaining` and `RateLimit-Reset` on every response and slow down early. Treat a `429` as a signal to back off, not as an error to retry immediately. ## No-code and automation tools Any HTTP-capable automation tool that can send requests with custom headers and receive webhooks can drive the API directly. Make sure the tool can: - **Set headers**: `Authorization: Bearer ...` and `Content-Type: application/json`. If the tool retries failed steps on its own, also send an optional `Idempotency-Key` built from the trigger (the record id plus a step name), so its retries replay instead of repeating the action. - **Store the key as a secret**, not in a step anyone can read or export. - **Receive webhooks with the raw body available**, so you can verify the signature. If the tool cannot verify HMAC signatures on the raw body, put a small verifying receiver in front of it (adapt the [receiver](/guides/receiving-webhooks#a-complete-receiver) to forward verified events to the tool) rather than accepting unsigned requests. - **Branch on `error.code` and `error.retry`**, not on the HTTP status alone. Avoid tools that retry every failed step automatically with a new request id, and never paste a live key into a browser-based step. ## Test-to-live migration 1. Build and test with a test key, a test-mode webhook endpoint and the test numbers. 2. Create a live key per component with the same scopes, and a live webhook endpoint (its own secret). Keep test and live configuration side by side and switch by configuration. 3. Do not migrate stored ids: contacts, calls, campaigns, endpoints and events are separate in each mode, and test ids are `404` with a live key. 4. Re-create any test-mode do-not-call entries you rely on as live entries from your real records, not by copying the test list. 5. Go live with a small slice of real records first. See [Going live](/guides/going-live). ## Choosing a pattern | Need | Use | |---|---| | React within seconds to one event | Event-triggered single calls | | Call a list (tens to thousands) for one purpose | A campaign | | Call a list on a schedule | A nightly (or hourly) campaign job | | Make sure nothing is missed | Reconciliation, alongside either | | No servers of your own | An automation tool, with a small verifying receiver for webhooks | Prefer campaigns for batches: pacing, compliance screening and calling hours are handled for you. Use single calls for real-time triggers. ## What can go wrong | Anti-pattern | Why it hurts | Instead | |---|---|---| | Polling every call in a tight loop | Burns the `read` limit and still lags | Webhooks, plus a list-based reconciliation | | A loop of single calls for a list | Hits the `dial` limit, no screening summary, no pacing | A campaign | | One shared key for every component | One noisy job starves the others, and a leak exposes everything | One key per component, least scopes | | Blind retries after timeouts | Duplicate calls or campaigns | Check state by your `metadata` or names first; optionally keys derived from your event or batch ids | | Adding recipients to a started campaign | Refused: recipients are fixed at start | A new campaign for the new audience | ## Next steps [Going live](/guides/going-live). --- Source: /guides/going-live # Going live **Availability: Available** Before switching to a live key, check that your integration is secure, handles errors and retries correctly, and verifies webhooks. This checklist covers what to confirm before you place real, billed calls. In live mode calls reach real people and are charged to your workspace balance. Everything below can be tested in test mode first; nothing on this list should be discovered in production. ## Before you begin - Your integration works end to end in test mode: calls placed, webhooks received and verified, results written to your system, refusals handled. - **API access for live use.** The API is in private preview and must be enabled for your workspace by RelationsAI. If any request answers [`api_access_not_enabled`](/errors/api_access_not_enabled), write to support@relationsai.com with your workspace name. Webhook delivery is a separate preview: check `capabilities.webhooks` on [`GET /workspace`](/api/workspace#retrieve-the-workspace). - Your agent is published and ready in live mode, with a live outbound number, and the workspace has calling set up and a balance ([Creating and preparing agents](/guides/creating-and-preparing-agents)). Run the preflight check with the exact key your server will use. It reports the key's mode, scopes and expiry, the limits that apply, the agent's readiness and your webhook endpoints, and exits with status 1 if a check fails: Ask the API what the key can do: ```bash curl -X GET https://voice-api.relationsai.com/v1/workspace \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/workspace", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const workspace = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/workspace", headers={ "Authorization": "Bearer ", }, timeout=30, ) workspace = response.json() ``` Response `200` (a live key): ```json { "id": "ws_7Hk2Lm9QxR4tB8vN3cZp6W", "object": "workspace", "livemode": true, "name": "Acme Lending", "timezone": "Asia/Kolkata", "currency": "INR", "api_key": { "id": "key_9Bv2Cx7Zl4Kj8Hg3Fd6Sa5", "name": "CRM sync", "environment": "live", "scopes": [ "workspace:read", "calls:read", "calls:write" ], "created_at": "2026-10-21T06:00:00.000Z", "expires_at": "2027-01-19T06:00:00.000Z" }, "capabilities": { "campaign_pause": true, "campaign_resume": true, "call_ringing_status": false, "live_transcript": false, "sentiment_analysis": false, "end_call": false, "agent_editing": false, "inbound_calls": false, "webhooks": false }, "limits": { "rate_limits": { "read": { "per_key_per_minute": 600, "per_workspace_per_minute": 1200 }, "write": { "per_key_per_minute": 120, "per_workspace_per_minute": 240 }, "dial": { "per_key_per_minute": null, "per_workspace_per_minute": 30 } }, "daily_call_limit": 500, "campaign_max_concurrency": 10 } } ``` Check `api_key.environment`, `api_key.scopes` (only what this component needs), `api_key.expires_at`, `limits` and `capabilities.webhooks`. Then check the agent and your endpoints: ```bash curl -X GET https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const readiness = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/agents/agent_1ZltiWzxrXmMprGD6q63/readiness", headers={ "Authorization": "Bearer ", }, timeout=30, ) readiness = response.json() ``` ```bash curl -X GET "https://voice-api.relationsai.com/v1/webhook-endpoints?limit=100" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/webhook-endpoints?limit=100", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const endpoints = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/webhook-endpoints", headers={ "Authorization": "Bearer ", }, params={ "limit": 100, }, timeout=30, ) endpoints = response.json() ``` Complete runnable example: [cURL script](/examples/guides/going-live/preflight.sh) · [JavaScript](/examples/guides/going-live/preflight.mjs) · [Python](/examples/guides/going-live/preflight.py) ## Keys and scopes - [ ] **A live key per component**, created by a workspace admin in the platform (**Settings → API Keys**), with only the scopes that component needs. A component that only reads results has no `:write` scope. Remember a key never does more than its creator's current role allows: create it from an account that will keep its role. - [ ] **An expiry and an owner.** Live keys expire (30, 90, 180 or 365 days). Put the expiry date in your calendar and rotate before it: create a new key, deploy it, and let the old one expire after a short grace period. - [ ] **Server-side only.** Store the key in a secret manager or your platform's secret configuration, never in source code, a mobile app, browser code, a URL, a log or a ticket. Live keys are refused from browsers with [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser). - [ ] **The IP allowlist**, if your workspace uses one, includes your servers' egress addresses (otherwise [`ip_not_allowed`](/errors/ip_not_allowed)). - [ ] **A leak plan.** If a key leaks, an admin revokes it in the console immediately; requests with it then fail with [`api_key_revoked`](/errors/api_key_revoked). See [Security practices](/fundamentals/security-practices). ## Error handling and retries - [ ] Every response is checked for `res.ok`; errors branch on `error.code` and `error.retry`, never on `error.message`. - [ ] **No blind retries of writes**: your own id in `metadata`, and after a timeout or `5xx` a state check before resending; optionally an `Idempotency-Key` per action, saved before the first attempt and reused on retries ([Retry and reliability practices](/guides/retry-and-reliability)). - [ ] Retries use exponential backoff with jitter and a cap, honour `Retry-After`, and never retry `retry: "no"`. - [ ] "May have started" errors (`500 internal_error`, timeouts, `campaign_outcome_unknown`, `idempotency_outcome_unknown`) lead to a state check, not to a fresh request. - [ ] Refusals (`dnc_listed`, `consent_required`, `outside_calling_hours`, `agent_not_ready`, `insufficient_balance`, ...) are recorded as outcomes and surfaced to the people who can fix them, not retried. - [ ] Open enums: unknown statuses, outcomes, event types and error codes are handled as "other", never as success. - [ ] `PATCH` uses `If-Match` and handles `412 version_conflict` by re-reading. ## Webhooks - [ ] A **live** webhook endpoint (its own secret, separate from the test one) on a public HTTPS URL, port 443, with a valid certificate. - [ ] **Signature verification** on the raw body, constant-time compare, 5-minute tolerance, accepting any of several `v1` values ([Verifying webhook signatures](/guides/verifying-webhook-signatures)). - [ ] **Deduplication** by event id in durable storage, before answering. - [ ] **Answer `2xx` within 10 seconds**, process in the background, and sweep for acknowledged but unprocessed events. - [ ] Out-of-order events cannot overwrite newer state. - [ ] A reconciliation job lists recent calls (and campaigns) to catch anything missed ([Common integration patterns](/guides/common-integration-patterns#reconciliation)). - [ ] A test event to the live endpoint `succeeded` in the delivery log. ## Compliance - [ ] An admin has reviewed the workspace's compliance settings in the console: consent requirement, calling window and per-number daily limit, for the countries you call. - [ ] Opt-outs from every channel reach the do-not-call list **immediately**, and do-not-call status is synchronised in both directions with your systems ([Consent and DNC](/guides/consent-and-dnc)). - [ ] Consent is recorded through the API only from real records of consent. - [ ] Nobody can route around a refusal: no retries with another number format, no automatic do-not-call removals, no consent set to unblock a call. - [ ] Transcripts, recordings and analysis are stored and shared according to your data-protection obligations; recording links are never stored or logged ([Retrieving recordings and transcripts](/guides/retrieving-recordings-and-transcripts)). ## Limits and spending - [ ] You know the limits that apply (from [`GET /workspace`](/api/workspace#retrieve-the-workspace)): rate limits per class, `limits.daily_call_limit` for calls placed through the API per UTC day, and `limits.campaign_max_concurrency`. If your volume needs a higher daily limit, ask support before launch, not on the day. - [ ] Batch jobs pace themselves under the `write` and `dial` limits and read `RateLimit-Remaining`. - [ ] The workspace balance covers your expected volume, and someone is responsible for topping it up. Calls are refused with [`insufficient_balance`](/errors/insufficient_balance) when it runs low, and running campaigns pause. - [ ] Campaigns have a `budget_cap` (it pauses the campaign; the check runs about every 15 minutes, so allow for overshoot), and an admin has set a monthly spend limit if you want one ([`spend_limit_reached`](/errors/spend_limit_reached)). - [ ] Campaign sizes fit the daily limit: a live start counts every recipient and is refused whole when they do not all fit. - [ ] You handle automatic campaign pauses (`campaign.paused` with `data.reason`) and resume once the cause is fixed. ## Monitoring - [ ] **Log `request_id`** (the `X-Request-Id` header, also in every error body) with every failed request, together with the operation and the time. Support needs it to find your request. - [ ] Alert on: the rate of `4xx` and `5xx` per code, `429`s, webhook deliveries that are `failed` or `retrying` (from the delivery log or your own receiver), your inbox of unprocessed events, and calls without `call.settled` after an hour. - [ ] Track business outcomes as well: refusals by code, `no_answer` and `failed` rates, and spend per day. - [ ] Dashboards distinguish test from live (`livemode` on every object and event). ## Launch 1. Switch configuration to the live key and the live webhook secret. No code changes should be needed. 2. Start with a small set of real records, ideally people who expect the call. 3. Check the first results in both RelationsAI and your system: outcomes, analysis written to the right records, refusals recorded, charges as expected. 4. Increase volume gradually, watching the alerts above. ## What can go wrong - **[`api_access_not_enabled`](/errors/api_access_not_enabled) (403)**: The workspace is not enabled for the API. Fix: Write to support@relationsai.com. - **[`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser) (403)**: A live key used from browser code (a request with an `Origin` header). Fix: Move the call to your server. - **[`resource_not_found`](/errors/resource_not_found) (404) for ids that worked yesterday**: Test-mode ids used with the live key. Fix: Use live ids; nothing is shared between modes except agents and numbers. - **[`agent_not_ready`](/errors/agent_not_ready) (422)**: The live agent is not published, or has no outbound number. Fix: Fix it in the console and check readiness. - **[`insufficient_balance`](/errors/insufficient_balance) (402)**: No balance. Fix: An admin tops up. - **[`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429)**: Volume above the daily limit, or a retry storm. Fix: Check for loops; ask support about the limit. - **No live webhooks arrive**: The endpoint was created with the test key. Fix: Create a live endpoint with the live key. ## Next steps - [Security practices](/fundamentals/security-practices) - [Troubleshooting](/resources/troubleshooting) - [Support](/resources/support) --- Source: /fundamentals/base-url-and-versioning # Base URL and versioning All requests go to one base URL, and the major version is part of the path. This page explains the base URL, what counts as a breaking change, how additive changes are introduced and how deprecations are announced. ## Base URL ```text https://voice-api.relationsai.com/v1 ``` - **HTTPS only.** Send every request over TLS. - **One URL for both modes.** The key selects test or live mode; there is no separate test host. See [Environments](/getting-started/environments). - **Paths are case sensitive**, resource names are plural and kebab-case (`/phone-numbers`, `/webhook-endpoints`), and every path starts with `/v1`. A path that does not exist answers [`route_not_found`](/errors/route_not_found); an existing path with an unsupported method answers [`method_not_allowed`](/errors/method_not_allowed), with the supported methods in the `Allow` header. Every example in these docs writes this URL out in full. In your own code, keep it in one configuration value so you can point the same code at another server without changing it. ## Versions The major version is in the path: `/v1`. The contract also carries a date version, `2026-10-21` today, shown in the [API reference](/api) and in every webhook event as `api_version`. ## What v1 guarantees Within v1, RelationsAI does not make breaking changes. These never happen in v1: - Removing or renaming an operation, a field, a query parameter, an event type or an error code. - Changing a field's type or format, or the meaning of an existing value. - Making an optional request field required, or adding a new required request field. - Changing the HTTP status of an error code, or tightening validation of values that are documented as valid today. - Changing the authentication scheme, the id prefixes or the error envelope. A breaking change needs a new major version (`/v2`) and at least **six months' notice** before `/v1` changes, announced in the [changelog](/resources/changelog). ## Additive changes These can ship at any time, without advance notice, and are listed in the [changelog](/resources/changelog): - New operations and new optional request fields or query parameters. - New fields in responses and events. - New values in enums (statuses, outcomes, reasons, artifact states). - New event types and new error codes. - New response headers. Write your client so that none of these can break it: - **Ignore fields you do not know.** Do not validate responses against a closed schema that rejects extra fields. - **Treat enums as open.** Have a default branch for an unknown value, and never treat an unknown value as success. For example, an unknown call `outcome` is not `completed`. - **Handle unknown error codes by HTTP status and `retry`.** A new code still comes with a documented status and retry hint; see [Error handling](/fundamentals/error-handling). - **Ignore event types you did not subscribe to or do not know**, and still answer `2xx`. - **Do not depend on field order, on the exact text of `message`, or on the format of ids.** ## Event versions Every event carries `api_version`, the date version of its payload format (today `2026-10-21`). Additive changes do not change it. If a future payload format is incompatible, it gets a new `api_version`, announced in the changelog, so your receiver can tell which shape it is reading. ## Deprecation When something is going to be retired, RelationsAI announces it in the [changelog](/resources/changelog) with the date it stops working, and documents the replacement. Breaking removals come with at least six months' notice and a new major version. Older `sk_live_` keys are an example: they keep working where they worked before and are not accepted by v1 (see [API keys](/getting-started/api-keys#older-keys)). During the private preview, operations labelled **Availability: Preview** may still change; see [API availability](/getting-started/api-availability). --- Source: /fundamentals/authentication # Authentication This reference page specifies how requests are authenticated: the header, key formats, how scopes are checked and every authentication and authorisation error. For a task-oriented introduction, see [Authentication](/getting-started/authentication) in Getting started. ## Header format ```http Authorization: Bearer rai_test_... Authorization: Bearer rai_live_... ``` - The scheme is `Bearer`, followed by one space and the key. No quotes, no trailing characters. - A key is `rai_test_` or `rai_live_` followed by 40 letters and digits. The prefix decides the mode. - Send the header on every request. There are no sessions, cookies or query-string keys. - The only operation that takes no key is [`GET /recordings/{recording_token}`](/api/recordings#download-recording-audio): its URL carries a short-lived token instead, and an `Authorization` header sent to it is ignored. Only RelationsAI API keys are accepted. Console sign-in tokens and any other credentials answer [`invalid_api_key`](/errors/invalid_api_key); older `sk_live_` keys answer [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported). ## What a key is bound to | Binding | Effect | | --- | --- | | One workspace | Every request acts in that workspace. Another workspace's ids answer [`resource_not_found`](/errors/resource_not_found). | | One mode | `rai_test_` keys see test-mode data only, `rai_live_` keys live data only. The other mode's ids answer [`resource_not_found`](/errors/resource_not_found). | | Its scopes | Chosen when the key is created; see [API keys](/getting-started/api-keys#scopes). | | Its creator | The key never does more than the member who created it may do now. | | An expiry | Live keys always expire; test keys may not. | ## Scope checks Each operation requires one scope, listed in the API reference (as `x-required-scopes` in the OpenAPI document). Every key also has `workspace:read`. A request is allowed when the operation's scope is among the key's **effective scopes**: the scopes the key was created with, limited to what its creator's current role allows. Effective scopes are worked out on every request, and [`GET /workspace`](/api/workspace#retrieve-the-workspace) returns them as `api_key.scopes`. - The key was never given the scope: [`insufficient_scope`](/errors/insufficient_scope). - The key has the scope but its creator's role no longer allows it, or the creator left the workspace: [`insufficient_permissions`](/errors/insufficient_permissions). Both are `403` and carry `details.required_scopes`: ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": ["calls:write"] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Some problems are reported before the key is checked: an unknown path or method (`404`, `405`) and a body that is not JSON, is too large or has the wrong content type (`400`, `413`, `415`). The fields of the body are validated only after authentication and permission checks, so a key without the scope gets `403` even for a body with invalid fields. ## Workspace and network checks After the key itself, each request is checked against the workspace: - **API access.** During the private preview the workspace must be enabled for the API, else [`api_access_not_enabled`](/errors/api_access_not_enabled). - **IP allowlist.** If the workspace has an IP allowlist in its security settings, requests from other addresses answer [`ip_not_allowed`](/errors/ip_not_allowed). - **Browsers.** A request with a live key and an `Origin` header (which browsers send) answers [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser). This is decided before the key is looked up, so browser code cannot even learn whether a live key is valid. Test keys may be used from browser-based tools; browsers can only read responses from origins the API allows. ## Fails closed If the API cannot complete one of these checks (for example, it cannot look up the key or read the workspace's IP allowlist), it refuses the request with [`service_unavailable`](/errors/service_unavailable) and a `Retry-After` header. It never lets a request through because a check could not run. Nothing was done; retry later with the same `Idempotency-Key`. ## Errors - [`authentication_required`](/errors/authentication_required) (401): No `Authorization` header, a scheme other than `Bearer`, or an empty token. - [`invalid_api_key`](/errors/invalid_api_key) (401): Not a valid RelationsAI API key. - [`api_key_expired`](/errors/api_key_expired) (401): The key expired, or its rotation grace period ended. - [`api_key_revoked`](/errors/api_key_revoked) (401): The key was revoked. - [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported) (401): An `sk_live_` key. - [`insufficient_scope`](/errors/insufficient_scope) (403): The key lacks the operation's scope. - [`insufficient_permissions`](/errors/insufficient_permissions) (403): The key's creator no longer holds the permission, or left the workspace. - [`api_access_not_enabled`](/errors/api_access_not_enabled) (403): The workspace is not enabled for the API. - [`ip_not_allowed`](/errors/ip_not_allowed) (403): The request's address is not on the workspace IP allowlist. - [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser) (403): A live key sent from a browser. - [`service_unavailable`](/errors/service_unavailable) (503): A check could not run; retry after `Retry-After`. Every one of these means the request did nothing: nothing was created, dialled or charged, and nothing was stored under an `Idempotency-Key` you sent, so you can send the same key again once the cause is fixed. All `401` and `403` codes have `retry: "no"`: retrying unchanged fails the same way. --- Source: /fundamentals/requests-and-responses # Requests and responses Every request and response follows the same conventions. This page specifies content types, naming, the shape of single objects and lists, request ids and the headers returned on every response. ## JSON only - Request and response bodies are JSON, UTF-8. Send `Content-Type: application/json` with every body; any other type answers [`unsupported_media_type`](/errors/unsupported_media_type), and a body that does not parse answers [`invalid_json`](/errors/invalid_json). - An operation that documents no request body (for example pausing a campaign) ignores a body sent with it: it is not read, so it is never refused as `415`, `413` or `invalid_json`. - `HEAD` is accepted wherever `GET` is and answers the same status and headers with no body. - Field names, query parameters and enum values are `snake_case`. - **Request bodies are strict.** A field the operation does not accept is [`validation_error`](/errors/validation_error), never silently ignored, so a typo cannot go unnoticed. Unknown query parameters are refused the same way, and each query parameter may be given once. - Request bodies are limited to 1 MB ([`payload_too_large`](/errors/payload_too_large)). Some fields have their own limits, documented in the API reference. - `GET` requests have no body. Build JSON with a JSON library, not by concatenating strings. `validation_error` lists every problem it found, not just the first: ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [{ "field": "agent_id", "issue": "required", "message": "agent_id is required." }] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` ## Request headers | Header | When | | --- | --- | | `Authorization: Bearer ` | Every request. See [Authentication](/fundamentals/authentication). | | `Content-Type: application/json` | Every request with a body. | | `Idempotency-Key` | Optional on `POST` and `DELETE`: makes a retry safe. See [Idempotency](/fundamentals/idempotency). | | `If-Match` | Required on `PATCH` of campaigns and contacts. See [Concurrency](/fundamentals/concurrency). | | `Range` | Optional, when downloading recording audio. | ## Objects A single object is returned as itself, never wrapped. Every object has: - `id`: an opaque, prefixed id such as `call_6Vb3Nm8Qw2Er5Ty9Ui4Op7`; - `object`: its type, such as `call`, `contact` or `campaign`; - `livemode`: `true` for live mode, `false` for test mode. Fields are always present in responses: a value that is not known or not set is `null`, not missing. A delete returns a small confirmation object: ```json { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "deleted": true } ``` ## Lists Every list operation returns the same envelope: ```json { "object": "list", "data": [{ "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false }], "has_more": false, "next_cursor": null } ``` `data` holds full objects (a call in a list is the same as from [`GET /calls/{call_id}`](/api/calls#retrieve-a-call)). See [Pagination](/fundamentals/pagination) and [Filtering](/fundamentals/filtering). ## Status codes | Status | Meaning | | --- | --- | | `200 OK` | Success. | | `201 Created` | A new object was created (for example a contact or a campaign). | | `202 Accepted` | Accepted; the work continues after the response (placing a call, sending a test event, retrying a delivery). Follow the object's status. | | `206 Partial Content` | A byte range of recording audio. | | `400`, `413`, `415`, `422` | The request cannot be done as sent; fix it. | | `401`, `403` | Authentication or permission problem. | | `402` | Balance or spend limit. | | `404` | No such object for this key, or no such route. | | `405` | Method not supported on this path. | | `409` | Conflict with the current state, or an idempotency conflict. | | `410` | A recording link has expired. | | `412`, `428` | `If-Match` stale or missing. | | `429` | Rate limit, daily call limit or calling capacity. | | `500`, `503` | A problem on our side. | Every error has the same envelope and a stable `code`; the status of each code never changes. See [Error handling](/fundamentals/error-handling). ## Request ids Every response, success or error, has an `X-Request-Id` header: ```http X-Request-Id: req_4k7m2qat5xvz3c6vqnwpbdfh ``` Error bodies repeat it as `error.request_id` (a replayed answer, marked `Idempotent-Replayed: true`, can carry the original request's id in the body; the header is always this request's). The id is generated by RelationsAI for each request; a value you send in an `X-Request-Id` request header is ignored. Log it with your own correlation id for every request, and quote it when you write to [support](/resources/support): it is how we find your request. ## Response headers | Header | On | Meaning | | --- | --- | --- | | `X-Request-Id` | Every response | The request id. | | `RateLimit-Limit` | Every response | Requests allowed in the current one-minute window. | | `RateLimit-Remaining` | Every response | Requests left in the window. | | `RateLimit-Reset` | Every response | Seconds until the window resets. | | `Retry-After` | `429`, `503` and errors whose `retry` is `after` | Seconds to wait before retrying. | | `Idempotent-Replayed` | Replayed `POST` and `DELETE` responses | `true` when the response is the stored result of an earlier request with the same key. | | `ETag` | Campaigns and contacts | The version to send as `If-Match`. | | `Allow` | `405` | The methods the path supports. | See [Rate limits](/fundamentals/rate-limits). Browser code (from origins the API allows) can read `X-Request-Id`, the `RateLimit-*` headers, `Retry-After`, `Idempotent-Replayed` and `ETag`; the other headers are for server-side clients. ## Asynchronous operations Calls and campaigns run after the request returns. `POST /v1/calls` answers `202` with the call `queued`. Starting a campaign answers `200` when the run has started, or `202` when the start was sent and is still being confirmed. Follow progress with [webhooks](/fundamentals/webhooks) or by reading the object, and treat the object's own status as the truth. --- Source: /fundamentals/error-handling # Error handling Every error response uses one envelope and a stable code. Handle errors by their `code`, use the `retry` hint to decide whether to try again, check whether the work may already have started, and log the `request_id`. ## The error envelope Every non-2xx response has this shape: ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": ["agent_not_published"] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` | Field | Type | Meaning | | --- | --- | --- | | `code` | string | Stable, machine-readable error code. **Branch on this.** Codes are never renamed in v1; new codes can be added. | | `message` | string | A sentence for people, safe to show an operator. It may be reworded at any time: never parse it. | | `details` | object | Extra facts for this code, such as `required_scopes` or `errors[]`, documented on each code's page. Always an object; `{}` when the code has none. | | `request_id` | string | The request id. Log it and quote it to support. Also log the `X-Request-Id` header of the response you received: for a replayed answer (`Idempotent-Replayed: true`), the body can carry the id of the original request. | | `retry` | string | What to do next: `no`, `yes` or `after` (below). | | `doc_url` | string | The code's page in the [error-code reference](/fundamentals/error-codes), with causes and fixes. | Each code always comes with the same HTTP status, so you can rely on the status class (`4xx`, `5xx`) as a fallback, but the `code` is what distinguishes, for example, the several kinds of `409`. Error messages never include internal details, and no error is ever returned with a `2xx` status. ## The retry hint | `retry` | Meaning | What to do | | --- | --- | --- | | `no` | Retrying the same request will fail the same way. | Change the request or the configuration first. | | `yes` | A temporary problem. | Retry with exponential backoff and jitter. For a `POST` or `DELETE`, reuse the **same** `Idempotency-Key` if you sent one. | | `after` | Wait first. | Wait the number of seconds in the `Retry-After` header, then retry (with the same `Idempotency-Key`, if you sent one). | "The same key" on this page means the `Idempotency-Key` you sent with the original request. It is optional; without one, a retried `POST` or `DELETE` runs again, so read the state before retrying a write whose outcome you do not know. Never retry `no` errors in a loop. Cap retries for `yes` and `after` (for example at five attempts) and give up loudly. A very long `Retry-After`, such as the hours until the daily call limit resets, is a signal to schedule the work for later, not to sleep. ## Did anything happen? Most errors mean the request was refused **before** it did anything: nothing was created, dialled or charged. A few can happen **after** work may have begun, for example when the start of a call or a campaign was sent but its confirmation was lost. Never assume an error means no side effects. Each code's page states which kind it is, as **Execution**: | Execution | Meaning | | --- | --- | | Not started | Nothing was done. Fix the cause (or wait) and send the request again. | | May have started | The work may have happened, partly or fully. Read the resource before doing anything else; with a key, an identical retry with the **same** key is also safe. | | Not applicable | Reads, where nothing is changed either way. | These codes may have started work: - [`internal_error`](/errors/internal_error) (500, `retry: yes`): A `500` is not stored under an `Idempotency-Key`: a retry with the same key runs the request again from the start; it does not replay an earlier result. A read can simply be retried. For a write, the API does not know whether the change was applied before the error, so read the resource first (for a call, list calls filtered by `to` and `created_after`; for a campaign or a contact, retrieve it) and retry with the same key only if the change is not there. - [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress) (409, `retry: after`): The first request with this key is still running. Wait `Retry-After`, then send the identical request with the same key to get its result. - [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) (409, `retry: no`): The first request with this key was interrupted and its outcome is unknown. Read the resource (for a call, list calls by `to` and `created_after`). Only if it did not happen, send it again with a **new** key. - [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409, `retry: after`): A start or resume was sent but not confirmed, and dialling may have begun. This answer is stored under the key: a retry with the same key returns it again and sends nothing. Wait, then retrieve the campaign: `running` means it started. Only if it is not running and you still want to try, send the action with a **new** key; the campaign refuses a second dial of the same run. - [`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) (409, `retry: yes`): The pause was not confirmed and dialling may continue. Retry the pause with the same key, then retrieve the campaign; if it is still running, stop it. A timeout or a dropped connection, where you get no response at all, is the same situation: the request may or may not have run. See [After a timeout](/fundamentals/idempotency#after-a-timeout). One more case: `POST /v1/calls` answers `202` even when the start of the call cannot be confirmed straight away. The call is then resolved within about two minutes: it either proceeds, or ends with `outcome: failed` and `end_reason: not_confirmed`, and nothing is charged. ## Handling errors by class - **Fix the request**: A bug or bad input. Fix it and send again. A refused request is not stored, so an `Idempotency-Key` you sent may be reused. Examples: [`validation_error`](/errors/validation_error), [`invalid_json`](/errors/invalid_json), [`invalid_phone_number`](/errors/invalid_phone_number), [`unsupported_media_type`](/errors/unsupported_media_type), [`payload_too_large`](/errors/payload_too_large), [`invalid_idempotency_key`](/errors/invalid_idempotency_key), [`invalid_cursor`](/errors/invalid_cursor). - **Fix credentials or access**: Stop and alert a person: an admin usually has to change a key or a setting. Examples: [`invalid_api_key`](/errors/invalid_api_key), [`api_key_expired`](/errors/api_key_expired), [`insufficient_scope`](/errors/insufficient_scope), [`insufficient_permissions`](/errors/insufficient_permissions), [`api_access_not_enabled`](/errors/api_access_not_enabled), [`ip_not_allowed`](/errors/ip_not_allowed). - **Fix the configuration**: Something in the console needs attention. Alert an operator; retry after it is fixed. Examples: [`agent_not_ready`](/errors/agent_not_ready), [`calling_not_configured`](/errors/calling_not_configured), [`phone_number_not_usable`](/errors/phone_number_not_usable), [`insufficient_balance`](/errors/insufficient_balance), [`spend_limit_reached`](/errors/spend_limit_reached), [`account_inactive`](/errors/account_inactive), [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled). - **Respect a rule**: Expected business outcomes, not failures. Record them; do not retry until the situation changes (consent recorded, calling hours open, the next day). Examples: [`dnc_listed`](/errors/dnc_listed), [`consent_required`](/errors/consent_required), [`outside_calling_hours`](/errors/outside_calling_hours), [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached). - **Check the id**: Check the id, its prefix and the key's mode. A test-mode id is not found with a live key. Examples: [`resource_not_found`](/errors/resource_not_found), [`route_not_found`](/errors/route_not_found). - **Check state first**: Read the current state and decide again. For `idempotency_key_reused`, use a new key for a new operation. Examples: [`version_conflict`](/errors/version_conflict), [`campaign_state_conflict`](/errors/campaign_state_conflict), [`contact_already_exists`](/errors/contact_already_exists), [`idempotency_key_reused`](/errors/idempotency_key_reused). - **Wait for Retry-After**: Nothing was done. Wait `Retry-After` seconds and retry with the same key. Examples: [`rate_limited`](/errors/rate_limited), [`calling_capacity_reached`](/errors/calling_capacity_reached), [`service_unavailable`](/errors/service_unavailable), [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress). - **Schedule for later**: Nothing was done, and the wait is hours, not seconds. Queue the work for after `details.resets_at` (or start a smaller campaign that fits in `details.remaining`). Example: [`daily_call_limit_reached`](/errors/daily_call_limit_reached). - **Retry with backoff (reads) or check state first (writes)**: For a read, retry with exponential backoff. For a write, the `500` is not stored, so a same-key retry runs it again: read the resource first and retry with the same key only if the change is not there. Example: [`internal_error`](/errors/internal_error). The [error-code reference](/fundamentals/error-codes) lists every code with its status, retry hint and fix. ## Handling errors in code Read the envelope once, then decide from `retry` and, where it matters, from `code`. For example, this call uses one of your own agents that is not published yet: ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+12025550100" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ agent_id: "agent_3hT9kQ2mVx7RbN4pLc8Wz1", to: "+12025550100", }), }); console.log(response.status, await response.json()); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", }, json={ "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+12025550100", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` `retry` is `no`, so sending the same request again would fail the same way. `details.reasons` says what to fix: publish the agent in the console, then send the request again. If you sent an `Idempotency-Key`, the refusal was not stored under it, so you may reuse it. Decide the same way for every error: 1. `code` is [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) or [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown): read the resource before anything else. 2. `code` is [`internal_error`](/errors/internal_error) on a write: read the resource first and retry with the same key only if the change is not there. On a read, retry with backoff. 3. `retry` is `after`: wait the number of seconds in `Retry-After`, then send the same request with the same key. 4. `retry` is `yes`: retry with exponential backoff and the same key. 5. `retry` is `no`: fix the request or the configuration (`details` says what), then send it again. Complete, runnable retry helpers are on [Rate limits](/fundamentals/rate-limits#handling-429) (backoff with `Retry-After`) and [Idempotency](/fundamentals/idempotency#retrying-safely) (retries that can never place a call twice). When your server gets no HTTP response at all (a timeout, a reset connection or a proxy error page that is not JSON), the request may or may not have run. With an `Idempotency-Key`, retry with the same key, then check state if the retries run out. Without one, check state first (see [Idempotency](/fundamentals/idempotency#without-a-key)): a blind retry can repeat the action. ## Log what support needs For every failed request, log at least: the time in UTC, the method and path, the HTTP status, `error.code` and `error.request_id`. Never log the `Authorization` header or the key. With the `request_id`, [support](/resources/support) can find exactly what happened. ## All error codes See the [error-code reference](/fundamentals/error-codes): every code, its HTTP status, what it means, how to fix it and whether to retry. Each code also has its own page at `/errors/`, which is the `doc_url` in the error. --- Source: /fundamentals/error-codes # Error-code reference Every error response carries a stable `error.code`. Search or filter the table, then open a code for its causes, fixes, retry guidance and an example response. The envelope itself is described in [Error handling](/fundamentals/error-handling). | Error code | HTTP status | Meaning | How to fix | Retry? | | --- | --- | --- | --- | --- | | [`invalid_cursor`](/errors/invalid_cursor) | 400 | The pagination cursor is invalid or does not match these filters. | Send `next_cursor` exactly as returned, with the same filters. To change filters, start again without a cursor. | No | | [`invalid_idempotency_key`](/errors/invalid_idempotency_key) | 400 | The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces. | Use a UUID v4 or another URL-safe token of at most 255 characters. | No | | [`invalid_json`](/errors/invalid_json) | 400 | The request body is not valid JSON. | Serialize the body with a JSON library rather than by string concatenation. | No | | [`invalid_phone_number`](/errors/invalid_phone_number) | 400 | The phone number is not a valid E.164 number. | Normalize numbers to E.164 before sending them. | No | | [`validation_error`](/errors/validation_error) | 400 | One or more fields are invalid. | Read `details.errors[].field` and `details.errors[].issue`, correct each field and resend. | No | | [`api_key_expired`](/errors/api_key_expired) | 401 | This API key has expired. | Switch to the replacement key, or ask a workspace admin to create a new key in the console. | No | | [`api_key_revoked`](/errors/api_key_revoked) | 401 | This API key has been revoked. | Ask a workspace admin for a new key. | No | | [`authentication_required`](/errors/authentication_required) | 401 | Send your API key as a Bearer token in the Authorization header. | Send `Authorization: Bearer rai_test_...` (test mode) or `Authorization: Bearer rai_live_...` (live mode). | No | | [`invalid_api_key`](/errors/invalid_api_key) | 401 | The API key is not valid. | Copy the key again from where you stored it when it was created. Keys are shown only once; if it is lost, create a new key in the console under API & Developers. | No | | [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported) | 401 | Legacy API keys cannot be used with the v1 API. Create a scoped key in the console. | Create a scoped key in the console under API & Developers and use it instead. | No | | [`campaign_budget_reached`](/errors/campaign_budget_reached) | 402 | This campaign has reached its budget cap. | Raise or clear `budget_cap` with PATCH /v1/campaigns/{campaign_id}, then start or resume. | No | | [`insufficient_balance`](/errors/insufficient_balance) | 402 | The workspace balance is too low to start this call. Top up to continue. | A workspace admin tops up the balance in the console (Billing), then retry. | No | | [`spend_limit_reached`](/errors/spend_limit_reached) | 402 | This workspace has reached the monthly spend limit set by its admin. | A workspace admin raises or removes the limit in the console settings. | No | | [`account_inactive`](/errors/account_inactive) | 403 | This workspace cannot place calls because its account is not active. | A workspace admin can upgrade the plan in the console, or contact support about a suspension. | No | | [`api_access_not_enabled`](/errors/api_access_not_enabled) | 403 | API access is not enabled for this workspace. | Write to support@relationsai.com to request access for the workspace. | No | | [`insufficient_permissions`](/errors/insufficient_permissions) | 403 | The member who created this API key no longer has permission for this operation. | Ask a workspace admin to create a new key from an account that holds the permission. | No | | [`insufficient_scope`](/errors/insufficient_scope) | 403 | This API key does not have the scope this operation requires. | Ask a workspace admin to create a key that includes the scopes in `details.required_scopes`. | No | | [`ip_not_allowed`](/errors/ip_not_allowed) | 403 | Requests from this IP address are not allowed for this workspace. | Ask a workspace admin to add the server's egress address to the allowlist in the console security settings. | No | | [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser) | 403 | Live API keys cannot be used from a browser. Use a test key, or call the API from your server. | Move live calls to your server. | No | | [`webhooks_not_enabled`](/errors/webhooks_not_enabled) | 403 | Webhook delivery is not available yet. | Write to support@relationsai.com to ask when webhook delivery will be available. | No | | [`resource_not_found`](/errors/resource_not_found) | 404 | No such resource. | Check the id and the key mode. List the resource to find valid ids. | No | | [`route_not_found`](/errors/route_not_found) | 404 | No such route. | Compare the path with the API reference. | No | | [`method_not_allowed`](/errors/method_not_allowed) | 405 | This route does not support that HTTP method. | Use a method listed in the `Allow` header. | No | | [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled) | 409 | This campaign is set in the console to also call contacts on the do-not-call list, so it cannot be started or resumed through the API. | Ask a workspace admin to switch the setting off in the console, then start or resume it through the API. | No | | [`campaign_has_no_recipients`](/errors/campaign_has_no_recipients) | 409 | This campaign has no recipients to call. | Add recipients with POST /v1/campaigns/{campaign_id}/recipients, then start it. | No | | [`campaign_not_resumable`](/errors/campaign_not_resumable) | 409 | This campaign cannot be resumed. Start it again to begin a new run. | To call only the people not reached yet, list the recipients whose status is still `pending`, create a new campaign with those contacts, and start it. | No | | [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress) | 409 | This campaign is being started or resumed by another request. Retry shortly. | Wait `Retry-After` seconds, retrieve the campaign, and act on its new status. | After Retry-After | | [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) | 409 | The request was sent but its outcome is not confirmed yet. Check the campaign before trying again. | Wait `Retry-After` seconds, then retrieve the campaign: a `running` status means it started. Do not start or resume it again before you have checked. | After Retry-After | | [`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) | 409 | The pause was requested but not confirmed. The campaign may still be dialling. | Retry the pause (with the same Idempotency-Key if you sent one; a repeated pause is harmless), then retrieve the campaign. If it is still running, use stop. | Yes, with backoff | | [`campaign_recipients_blocked`](/errors/campaign_recipients_blocked) | 409 | Some recipients of this campaign are now blocked by compliance rules, so it was not resumed. | List the campaign's recipients with `status=pending` (the people not reached yet), create a new campaign with those contacts minus the blocked ones in `details.contact_ids`, and start it. | No | | [`campaign_state_conflict`](/errors/campaign_state_conflict) | 409 | This action is not allowed in the campaign's current status. | Retrieve the campaign, check `status` and `capabilities`, and choose an allowed action. | No | | [`contact_already_exists`](/errors/contact_already_exists) | 409 | A contact with this phone number already exists. | Update the existing contact, or use the upsert operation. | No | | [`contact_limit_reached`](/errors/contact_limit_reached) | 409 | No more contacts can be created right now. | Delete contacts you no longer need, or contact support. | No | | [`contact_on_dnc_list`](/errors/contact_on_dnc_list) | 409 | This contact is on the do-not-call list. Remove its do-not-call entry before deleting it. | Keep the contact: it is what keeps the number from being called. Do not retry: the same request is refused every time. | No | | [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress) | 409 | A request with this Idempotency-Key is still being processed. | Wait for `Retry-After` seconds and send the identical request again with the same key. | After Retry-After | | [`idempotency_key_reused`](/errors/idempotency_key_reused) | 409 | This Idempotency-Key was already used with a different request. | Use a new key for a new operation. For a retry, send exactly the original request. | No | | [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown) | 409 | An earlier request with this Idempotency-Key did not finish, and its outcome is unknown. | List or retrieve the resource (for a call, list calls filtered by `to` and `created_after`) to see whether the operation took effect. | No | | [`webhook_delivery_not_retryable`](/errors/webhook_delivery_not_retryable) | 409 | This delivery cannot be retried in its current status. | Retrieve the delivery list and retry only `failed` or `retrying` deliveries; for `attempt_in_progress`, wait a few seconds and read the delivery again. | No | | [`webhook_endpoint_disabled`](/errors/webhook_endpoint_disabled) | 409 | This webhook endpoint is disabled. | Enable it with PATCH /v1/webhook-endpoints/{webhook_endpoint_id} and `status: enabled`. | No | | [`webhook_endpoint_limit_reached`](/errors/webhook_endpoint_limit_reached) | 409 | This workspace already has the maximum number of webhook endpoints for this mode. | Delete an endpoint you no longer use, or subscribe one endpoint to more event types. | No | | [`media_link_expired`](/errors/media_link_expired) | 410 | This recording link has expired. Retrieve the recording again for a new link. | Call GET /v1/calls/{call_id}/recording again and use the new `url` promptly. | No | | [`version_conflict`](/errors/version_conflict) | 412 | The resource changed since you read it. | Retrieve the resource again, re-apply your change to the new version, and resend with the new `ETag`. | No | | [`payload_too_large`](/errors/payload_too_large) | 413 | The request body is too large. | Split the request into smaller batches. | No | | [`unsupported_media_type`](/errors/unsupported_media_type) | 415 | Request bodies must be sent as application/json. | Send `Content-Type: application/json` with a JSON body. | No | | [`agent_not_ready`](/errors/agent_not_ready) | 422 | This agent cannot place calls right now. | Call GET /v1/agents/{agent_id}/readiness to see every reason. | No | | [`call_not_accepted`](/errors/call_not_accepted) | 422 | The call could not be placed. Nothing was dialled and nothing was charged. | Check the destination number and the agent's outbound number. If it keeps happening, contact support with the request_id. | No | | [`calling_not_configured`](/errors/calling_not_configured) | 422 | Calling is not set up for this workspace yet. | Finish calling setup in the console (Numbers), then retry. | No | | [`campaign_recipients_suppressed`](/errors/campaign_recipients_suppressed) | 422 | Every recipient of this campaign is blocked by your compliance settings. Nothing was dialled. | This is a compliance outcome: do not retry; an unchanged start is refused again. | No | | [`campaign_too_large`](/errors/campaign_too_large) | 422 | This campaign has too many recipients to start in one run. | Start: split recipients across several campaigns. | No | | [`consent_required`](/errors/consent_required) | 422 | This contact has no recorded consent, and consent is required for calls in this workspace. | Do not retry: the same request is refused every time until consent is recorded. | No | | [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached) | 422 | This number has already been called the maximum number of times allowed in 24 hours. | Do not retry now: the same request is refused until the oldest of those calls is more than 24 hours old. | No | | [`custom_field_not_found`](/errors/custom_field_not_found) | 422 | This custom field does not exist in the workspace. | Create the field in the console, or correct the key named in `details.field`. | No | | [`dnc_listed`](/errors/dnc_listed) | 422 | This number is on your do-not-call list, so the call was not placed. | Do not call this number, and do not retry: the same request is refused every time. | No | | [`outside_calling_hours`](/errors/outside_calling_hours) | 422 | This is outside the permitted calling window for this workspace. Nothing was dialled. | Do not retry now: the same request is refused until the window opens. Send it again inside the window in `details` (a new request; the refusal was not stored). | No | | [`phone_number_not_usable`](/errors/phone_number_not_usable) | 422 | This phone number cannot place outbound calls for this agent. | Pick a number whose `capabilities.outbound` is true, or omit `phone_number_id` to use the agent's outbound number. | No | | [`phone_number_required`](/errors/phone_number_required) | 422 | This agent has more than one outbound number. Choose one with phone_number_id. | Send `phone_number_id`, or assign the agent to a single outbound number in the console. | No | | [`recipient_contacts_not_found`](/errors/recipient_contacts_not_found) | 422 | Some contact ids do not name contacts in this workspace. | Create the contacts first (POST /v1/contacts/upsert), then add their ids. | No | | [`telephony_disconnected`](/errors/telephony_disconnected) | 422 | The telephony connection for this workspace is not working, so the campaign was paused. | Reconnect the telephony account in the console, then start or resume the campaign again. | No | | [`webhook_url_not_allowed`](/errors/webhook_url_not_allowed) | 422 | This URL cannot receive webhooks. | Use a public HTTPS URL on port 443 that resolves only to public addresses. Authenticate deliveries by verifying the signature instead of putting credentials in the URL. | No | | [`precondition_required`](/errors/precondition_required) | 428 | This update needs an If-Match header with the ETag of the version you read. | Retrieve the resource, copy its `ETag` response header into `If-Match`, and resend. | No | | [`calling_capacity_reached`](/errors/calling_capacity_reached) | 429 | Too many calls are being started right now. Retry after the number of seconds in the Retry-After header. | Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). Spread call starts out, or use a campaign for volume. | After Retry-After | | [`daily_call_limit_reached`](/errors/daily_call_limit_reached) | 429 | This workspace has reached its daily limit of calls placed through the API. | Wait until `Retry-After` (the next UTC midnight). | After Retry-After | | [`rate_limited`](/errors/rate_limited) | 429 | Too many requests. Retry after the number of seconds in the Retry-After header. | Wait `Retry-After` seconds. Spread requests out and back off exponentially. | After Retry-After | | [`internal_error`](/errors/internal_error) | 500 | Something went wrong on our side. | For a read: retry with exponential backoff. | Yes, with backoff | | [`campaign_recipients_unavailable`](/errors/campaign_recipients_unavailable) | 503 | The campaign's recipients could not be read for screening right now, so nothing was dialled. | Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). | After Retry-After | | [`service_unavailable`](/errors/service_unavailable) | 503 | The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header. | Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). | After Retry-After | --- Source: /fundamentals/idempotency # Idempotency `Idempotency-Key` is an **optional** header for `POST` and `DELETE`. When you send one, a retry of the same request returns the original answer instead of running the action again. When you do not, every request runs. This page explains both, and how to retry safely either way. ## When to send a key No request requires an `Idempotency-Key`. Without one, the API runs every request it receives: if a request times out and you send it again, the action can happen twice, including placing a **second call** or adding a contact twice. Send a key when a retry must not repeat the action, typically when placing calls, starting or resuming campaigns, and in any code that retries automatically. - The value is 1 to 255 printable ASCII characters, no spaces. A UUID v4 is ideal. An empty, too long or non-ASCII value answers [`invalid_idempotency_key`](/errors/invalid_idempotency_key). - Use one key per **intended operation** (one call to place, one contact to delete), not one per HTTP attempt, and store it with your own record before the first attempt so a restarted worker retries with the same key. - `GET` never needs a key (reads change nothing), and `PATCH` uses `If-Match` instead (see [PATCH](#patch)). ## Scope and retention - A key is scoped to your **workspace and mode**: the same key in test mode and live mode, or in two workspaces, never collides. - A key identifies one request: the same operation, the same path and the same body. - Keys are kept for **24 hours** after the first request with the key completes. After that, the same key is treated as new. - **What is stored:** successful (`2xx`) results, and a few answers whose outcome is uncertain because work may have started, such as [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown). A retry with the same key returns the stored answer for 24 hours. - **What is not stored:** refusals (for example [`validation_error`](/errors/validation_error) or [`agent_not_ready`](/errors/agent_not_ready)) and `500` [`internal_error`](/errors/internal_error). After a refusal, fix the cause and send the request again with the same key. After an `internal_error`, a retry with the same key runs the request again, so read the resource first and retry only if the change is not there. ## Replays Place a call with a key: ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer ", "Idempotency-Key": "", "Content-Type": "application/json", }, body: JSON.stringify({ agent_id: "agent_1ZltiWzxrXmMprGD6q63", to: "+12025550100", metadata: { crm_lead_id: "L-20931", }, }), }); console.log(response.status, await response.json()); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", "Idempotency-Key": "", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931", }, }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` If the response is lost (a timeout, a dropped connection), send **exactly the same request with the same key**. The API answers with the original call (the same `call_...` id) and marks the answer as a replay; no second call is placed: ```http HTTP/1.1 202 Accepted Idempotent-Replayed: true X-Request-Id: req_7d3kqf2mzv6xw4tb5nsc8hpa { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` The replay has the same status and data (the JSON may not be byte-for-byte identical). A replayed campaign or contact answer carries the `ETag` stored with it, which may be out of date: read the object again before you update it. ## Conflicts The same key with a different body (here, another number) is refused, and nothing runs: ```bash curl -X POST https://voice-api.relationsai.com/v1/calls \ -H "Authorization: Bearer " \ -H "Idempotency-Key: " \ -H "Content-Type: application/json" \ -d '{ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550101", "metadata": { "crm_lead_id": "L-20931" } }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls", { method: "POST", headers: { Authorization: "Bearer ", "Idempotency-Key": "", "Content-Type": "application/json", }, body: JSON.stringify({ agent_id: "agent_1ZltiWzxrXmMprGD6q63", to: "+12025550101", metadata: { crm_lead_id: "L-20931", }, }), }); console.log(response.status, await response.json()); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/calls", headers={ "Authorization": "Bearer ", "Idempotency-Key": "", }, json={ "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550101", "metadata": { "crm_lead_id": "L-20931", }, }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` - **Same key, different request:** [`idempotency_key_reused`](/errors/idempotency_key_reused). A new operation needs a new key; a retry must be exactly the original request. - **Same key while the first request is still running:** [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress). Wait `Retry-After` seconds and send the same request again. - **Same key after the first request was interrupted:** [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown). Check whether the action happened; only if it did not, send it with a **new** key. **Never generate a new key to retry.** That is exactly how an action runs twice. ## Without a key Without a key, the API cannot tell a retry from a new request. After a timeout or a dropped connection, do not send the request again blindly: first check whether it already ran. For a call, list calls to the same number created since just before your attempt, and look for the `metadata` you sent: ```bash curl "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09%3A29%3A00.000Z" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09%3A29%3A00.000Z", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&created_after=2026-10-21T09%3A29%3A00.000Z", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` If the call is in the list, it was placed: do not send the request again. If it is not, send it again. The same applies to other actions: read the contact, campaign or entry before repeating a create or a delete. ## After a timeout With a key, recovering from a lost response is simpler: 1. Send the **identical** request with the **same** key, with backoff. If the first attempt finished, you get its stored result (`Idempotent-Replayed: true`). If it is still running, you get `idempotency_key_in_progress`: wait `Retry-After` and try again. 2. If you get `idempotency_outcome_unknown` or `internal_error`, check the state as described in [Without a key](#without-a-key). 3. Only if the action did not happen, send it again: with a new key after `idempotency_outcome_unknown`, with the same key after `internal_error`. 4. Retry within 24 hours. After that the key is forgotten and a retry runs the action again, so check state first. ## Retrying safely A retry loop for a `POST` with a key decides each step from the answer: 1. No response at all, or `retry: after` / `retry: yes` (other than `internal_error`): wait (backoff or `Retry-After`) and send the same request with the same key. 2. [`internal_error`](/errors/internal_error): check the state; if the action is not there, send the same request with the same key. 3. [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown): check the state; if the action is not there, send the request with a **new** key. 4. Anything with `retry: no`: stop and fix the cause. The complete runnable example demonstrates this optional feature, so it sends an `Idempotency-Key` on purpose. It places a test call with one key, retries with that key, checks for the call after a `500` or an unknown outcome, and repeats the request to show that the same call comes back. Complete runnable example: [cURL script](/examples/fundamentals/idempotent-retry.sh) · [JavaScript](/examples/fundamentals/idempotent-retry.mjs) · [Python](/examples/fundamentals/idempotent-retry.py) ## PATCH `PATCH` requests do not take an `Idempotency-Key`. Updates to campaigns and contacts are protected by `If-Match` instead: a lost response followed by a retry answers [`version_conflict`](/errors/version_conflict) if the first attempt already applied, so read the object again and check whether your change is there. See [Concurrency](/fundamentals/concurrency). ## Webhooks Idempotency works the other way round for events: RelationsAI may deliver the same event more than once, and your receiver discards duplicates by the event `id`. See [Webhooks](/fundamentals/webhooks). --- Source: /fundamentals/pagination # Pagination List operations return results in pages. This page explains the `limit` and `cursor` parameters, the list envelope and how cursors behave. ## Parameters | Parameter | Type | Meaning | | --- | --- | --- | | `limit` | integer, 1 to 100 | Most objects to return. Default `25`. Outside the range: [`validation_error`](/errors/validation_error). | | `cursor` | string | The `next_cursor` of the previous page, exactly as returned. Omit it for the first page. | Every list response has the same envelope: ```json { "object": "list", "data": [ { "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false, "status": "ended" }, { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiJdfQ.c2lnbmF0dXJl" } ``` | Field | Meaning | | --- | --- | | `data` | The objects on this page (full objects, as from the single-object read). | | `has_more` | `true` when more objects exist after this page. | | `next_cursor` | Pass as `cursor` to get the next page. `null` on the last page. | (Objects in this example are shortened, and the cursor value is illustrative.) ## Order Most lists are **newest first**: by `created_at` descending, then by id. A few lists have a different, stable order: - [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers): by number. - [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients): in a stable order that is not by time and has no other meaning. - [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries): in test mode newest entry first; in live mode by contact, newest first. Objects created while you page through a newest-first list appear at the start, so they are not on later pages: start a new listing (or use `created_after`) to pick them up. ## Iterating Request pages until `has_more` is `false`, passing each page's `next_cursor` as `cursor` and keeping every filter the same. A page can hold fewer than `limit` objects while `has_more` is still `true` (this happens when you filter calls by `status`), so always use `has_more`, never the page size, to decide when to stop. The first page (here one call per page, to keep the example short): ```bash curl "https://voice-api.relationsai.com/v1/calls?limit=1" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=1", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls?limit=1", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "list", "data": [ { "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false, "status": "ended", "outcome": "no_answer", "end_reason": "no_answer", "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550101", "metadata": {}, "created_at": "2026-10-21T09:35:00.000Z", "ended_at": "2026-10-21T09:35:18.000Z", "duration_seconds": 0, "billable_minutes": 0, "cost": { "amount": 0, "currency": "INR" }, "artifacts": { "transcript": { "state": "absent" }, "recording": { "state": "absent" }, "analysis": { "state": "absent" } }, "settled": true, "settled_at": "2026-10-21T09:35:30.000Z" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw" } ``` `has_more` is `true`, so request the next page with `cursor` set to `next_cursor`, exactly as returned, and the same filters (`limit` may change): ```bash curl "https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls?limit=1&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiIsImNhbGxfNlZiM05tOFF3MkVyNVR5OVVpNE9wNyJdfQ.Qm9vbGVhbk1hYw", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```json { "object": "list", "data": [ { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ], "has_more": false, "next_cursor": null } ``` `has_more` is `false` and `next_cursor` is `null`: this was the last page. (The cursor in these examples is illustrative; real cursors are opaque.) Each page request counts against the `read` [rate limit](/fundamentals/rate-limits). Use `limit=100` and filters to read fewer pages. The complete runnable example reads every page of ended calls from the last 24 hours, with the same filters on every page, and counts them by outcome. Complete runnable example: [cURL script](/examples/fundamentals/pagination.sh) · [JavaScript](/examples/fundamentals/pagination.mjs) · [Python](/examples/fundamentals/pagination.py) ## Cursor rules - Cursors are **opaque**: do not decode, edit, build or shorten them. They are URL-safe strings of up to 512 characters. - A cursor is **bound to the list and the filters** that produced it, and to your workspace and mode. Send it only to the same list (for recipients and deliveries, the same campaign or endpoint id in the path) with exactly the same filters; `limit` may change between pages. - A cursor that was modified or is sent with different filters answers [`invalid_cursor`](/errors/invalid_cursor) (400, `retry: no`); one longer than 512 characters answers [`validation_error`](/errors/validation_error). Start again without a cursor. - Use a cursor to continue the listing you are reading now, not as a long-lived bookmark. To resume a sync later, store the `created_at` of the newest object you processed and list again with `created_after`. To change filters, start a new listing from the first page. ## Errors | Code | Cause | Fix | | --- | --- | --- | | [`invalid_cursor`](/errors/invalid_cursor) | Edited or truncated cursor, a cursor from another list, or filters changed between pages | Send `next_cursor` exactly as returned with the same filters, or start again without a cursor. | | [`validation_error`](/errors/validation_error) | `limit` below 1 or above 100, an unknown query parameter, or a parameter given twice | Fix the query string. `details.errors` names the parameter. | --- Source: /fundamentals/filtering # Filtering List operations accept filters so you fetch only what you need. This page covers the common date filters, the filters of each list and how filters combine with pagination. ## How filters work - Filters are query parameters, for example `GET /v1/calls?status=ended&agent_id=agent_...`. - Every filter narrows the result: several filters together return only objects that match **all** of them. - Each filter may be given once. An unknown parameter, a repeated one or an invalid value answers [`validation_error`](/errors/validation_error), with the parameter named in `details.errors`; an invalid phone number (`to`, `phone_number`) answers [`invalid_phone_number`](/errors/invalid_phone_number). - Values are matched exactly, with the formats below. There is no free-text search. - **URL-encode values.** In particular, the `+` of an E.164 number must be sent as `%2B` (`to=%2B12025550100`); a bare `+` in a query string means a space. `URLSearchParams` in JavaScript, `params=` in Python `requests` and `curl -G --data-urlencode` do this for you. ## Date filters | Parameter | Meaning | | --- | --- | | `created_after` | Only objects created **at or after** this time. | | `created_before` | Only objects created **before** this time. | Both take an RFC 3339 timestamp, for example `2026-10-21T00:00:00.000Z`; use UTC. Together they select a half-open window, so consecutive windows (`created_after=2026-10-20T00:00:00.000Z&created_before=2026-10-21T00:00:00.000Z`, then the next day) never overlap or miss an object. For calls, `created_at` is when the call was accepted. ## Resource filters - [`GET /calls`](/api/calls#list-calls): `created_after`, `created_before`; `agent_id`; `campaign_id`; `to` (E.164); `status` (`queued`, `ringing`, `in_progress`, `ended`, `unknown`); `outcome` (`completed`, `no_response`, `no_answer`, `busy`, `failed`, `unknown`; matches ended calls only) - [`GET /campaigns`](/api/campaigns#list-campaigns): `created_after`, `created_before`; `status` (`draft`, `running`, `paused`, `stopped`, `completed`, `failed`, `unknown`) - [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients): `status` (`pending`, `in_progress`, `completed`, `failed`, `suppressed`, `cancelled`, `unknown`) - [`GET /contacts`](/api/contacts#list-contacts): `created_after`, `created_before`; `phone_number` (E.164, matched the same way as the do-not-call check); `consent_granted` (`true` or `false`); `do_not_call` (`true` or `false`) - [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries): `phone_number` (E.164): one entry if the number is on the list, none if it is not; formatting is ignored - [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries): `status` (`pending`, `retrying`, `succeeded`, `failed`) - [`GET /agents`](/api/agents#list-agents), [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers), [`GET /webhook-endpoints`](/api/webhook-endpoints#list-webhook-endpoints): None (only `limit` and `cursor`) Every list also takes `limit` and `cursor` ([Pagination](/fundamentals/pagination)). All lists only ever return objects of your key's workspace and mode. Some useful combinations: - A call you may have placed after a timeout: `GET /v1/calls?to=%2B12025550100&created_after=` and match your `metadata` - Yesterday's answered calls of one agent: `GET /v1/calls?agent_id=agent_...&outcome=completed&created_after=...&created_before=...` - Whether a number is on the do-not-call list: `GET /v1/dnc-entries?phone_number=%2B12025550100` - Contacts that still need consent: `GET /v1/contacts?consent_granted=false` - Recipients a campaign left out: `GET /v1/campaigns/cmp_.../recipients?status=suppressed` - Failed webhook deliveries to retry: `GET /v1/webhook-endpoints/we_.../deliveries?status=failed` ## Example Ended calls to `+12025550100` created since 21 October 2026, 100 per page. The `+` is sent as `%2B` and the colons of the timestamp as `%3A`: ```bash curl "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&status=ended&created_after=2026-10-21T00%3A00%3A00.000Z&limit=100" \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&status=ended&created_after=2026-10-21T00%3A00%3A00.000Z&limit=100", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/calls?to=%2B12025550100&status=ended&created_after=2026-10-21T00%3A00%3A00.000Z&limit=100", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ## Filters and cursors A cursor is bound to the filters of the request that produced it. Keep exactly the same filters on every page of one listing; a cursor sent with different filters answers [`invalid_cursor`](/errors/invalid_cursor). To change filters, start again without a cursor. Filtered lists can return pages with fewer objects than `limit` while `has_more` is still `true`, especially when filtering calls by `status`. Keep following `next_cursor` until `has_more` is `false`. Status and outcome are refreshed as calls and campaigns progress, typically within seconds and occasionally a minute or more behind. A call filtered by `status=ended` today may not have matched a minute ago: for exports, filter on `created_before` a few minutes in the past, or wait for `settled`. --- Source: /fundamentals/concurrency # Concurrency When two processes update the same object, one can silently overwrite the other: your CRM sync and a colleague in the console both edit a contact, and the slower write wins. Campaigns and contacts use ETags to prevent that. This page explains the headers, the errors you can get and the read-modify-write loop that handles them. ## ETags Responses that return a campaign or a contact include an `ETag` header identifying that version: ```http ETag: "c3f1a9e2b7d4" ``` | Returns an `ETag` | Operations | | --- | --- | | Contacts | [`GET /contacts/{contact_id}`](/api/contacts#retrieve-a-contact), [`POST /contacts`](/api/contacts#create-a-contact), [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number), [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact) | | Campaigns | [`GET /campaigns/{campaign_id}`](/api/campaigns#retrieve-a-campaign), [`POST /campaigns`](/api/campaigns#create-a-campaign), [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign), and the start, pause, resume and stop actions | The value is opaque: store and send it exactly as received, including the double quotes. It changes whenever the object changes, including changes made in the console and, for a campaign, changes of state while it runs. Only the `ETag` of a single campaign or contact is meant for `If-Match`: read the single object before you update it, and do not use an `ETag` header from any other response (for example a list). A replayed answer (`Idempotent-Replayed: true`) carries the `ETag` stored with it, which may be out of date. ## If-Match on PATCH [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact) and [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign) require `If-Match` with the `ETag` from your latest read. First read the contact: ```bash curl https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` ```http HTTP/1.1 200 OK ETag: "c3f1a9e2b7d4" { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ``` Then send the change with that `ETag` in `If-Match` (shown as ``; send the value exactly as received, quotes included): ```bash curl -X PATCH https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 \ -H "Authorization: Bearer " \ -H "If-Match: " \ -H "Content-Type: application/json" \ -d '{ "name": "Asha R. Rao" }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", { method: "PATCH", headers: { Authorization: "Bearer ", "If-Match": "", "Content-Type": "application/json", }, body: JSON.stringify({ name: "Asha R. Rao", }), }); console.log(response.status, await response.json()); ``` ```python response = requests.patch( "https://voice-api.relationsai.com/v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", headers={ "Authorization": "Bearer ", "If-Match": "", }, json={ "name": "Asha R. Rao", }, timeout=30, ) print(response.status_code, response.json()) ``` ```http HTTP/1.1 200 OK ETag: "9b2e47d1a6f0" { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha R. Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ``` | Situation | Response | | --- | --- | | `If-Match` matches the current version | `200 OK` with the updated object and its new `ETag`. | | `If-Match` missing | [`precondition_required`](/errors/precondition_required) (428). Nothing changed. | | The object changed since you read it | [`version_conflict`](/errors/version_conflict) (412). Nothing changed. | Both errors have `retry: "no"`: resending the same request fails the same way. `PATCH` does not take an `Idempotency-Key`. (`PATCH` of a webhook endpoint takes neither header.) ### `If-Match: *` `If-Match: *` matches any current version: the update is applied **without** the concurrency check, so it can overwrite a change you have not seen. It still satisfies the header requirement (no `428`). Use it only where last-write-wins is what you want, for example a one-off correction by an operator; in automated syncs, send the real `ETag`. ### Other conditional headers Only campaigns and contacts carry an `ETag`, and only `If-Match` on their `PATCH` is checked. No other response has an automatic `ETag`, and conditional GET headers (`If-None-Match`, `If-Modified-Since`) are ignored: a response is never `304 Not Modified`. Campaigns have one more rule: they can be edited in every status except `running` (pause first; editing a running campaign answers [`campaign_state_conflict`](/errors/campaign_state_conflict)), and edits take effect at the next start. ## Resolving a conflict If the contact changed between your read and your `PATCH` (a colleague edited it in the console, for example), the update is refused and nothing changes: ```json { "error": { "code": "version_conflict", "message": "The resource changed since you read it.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/version_conflict" } } ``` Do not resend your old body with a new `ETag`: that would overwrite the change you have not seen, which is exactly what the check is there to prevent. Instead: 1. Read the object again (the same `GET` as above). Note its new `ETag`. 2. Work out your change again **from what you just read**. It may now be unnecessary. 3. Send the `PATCH` with the new `If-Match`. 4. Repeat a small number of times (three is plenty); if it keeps conflicting, something else is writing to the object constantly, so stop and report it. The same loop also handles a lost response: if a `PATCH` times out, start again from the read. If the first attempt had applied, the read shows your change is already there and nothing more is sent. The complete runnable example creates a test contact, then reads, changes and updates it with `If-Match`, reading again after a `412`. Complete runnable example: [cURL script](/examples/fundamentals/etag-update.sh) · [JavaScript](/examples/fundamentals/etag-update.mjs) · [Python](/examples/fundamentals/etag-update.py) ## Concurrency elsewhere - **Creates and actions** (`POST`, `DELETE`) are made safe to retry by [idempotency keys](/fundamentals/idempotency), not ETags. - **Campaign actions** are serialised: only one start or resume runs per campaign at a time, and a second one answers [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress) with `Retry-After`. - **Contacts by phone number**: two creates for the same number answer [`contact_already_exists`](/errors/contact_already_exists) for the second one. Use [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number) to create or update in one request. - **Events** can arrive out of order. Use the object's own status and timestamps, not the arrival order, to decide which is newer. --- Source: /fundamentals/rate-limits # Rate limits Rate limits protect your workspace and the service from runaway scripts and bursts. This page lists the limits, the headers that report them and how to respond to a `429`. ## Limits Every operation belongs to one rate-limit class, shown in the API reference (as `x-rate-limit-class`). Each class is limited per key **and** per workspace, per minute, and the two modes are counted separately: test traffic never uses up your live budget. | Class | Operations | Per key | Per workspace | | --- | --- | --- | --- | | `read` | Every `GET` | 600 per minute | 1,200 per minute | | `write` | Creates, updates, deletes and other actions | 120 per minute | 240 per minute | | `dial` | Placing a call (`POST /v1/calls`), starting and resuming a campaign | none | 30 per minute | The per-workspace limit is shared by all keys of the workspace in that mode, so several integrations with their own keys can still reach it together. Your workspace's current limits are in [`GET /workspace`](/api/workspace#retrieve-the-workspace) under `limits.rate_limits`; read them rather than hard-coding the numbers. Requests that are refused with `429` do not count against the limit. ### Requests that never reach an operation Requests that are refused before they reach an operation (an invalid or missing key, an unknown path or method, a refused workspace, a body that is not JSON or is too large) do not count against your key's or workspace's limits. They are limited per source IP address instead: **300 per minute**. Once an address has used that up, every request from it, valid or not, answers [`rate_limited`](/errors/rate_limited) (429) with `Retry-After` until the minute is over. Their responses carry `RateLimit-Limit: 300`, and the `rate_limited` error's `details.limit_class` is `read`. A misconfigured client that keeps sending a bad key can therefore block other clients that share its outbound address: fix authentication errors instead of retrying them. ## Daily call limit In **live mode**, a workspace can place a limited number of calls through the API per UTC day, as a guard against a loop or a retry storm calling people again and again. The limit that applies is `limits.daily_call_limit` in `GET /v1/workspace` (500 by default; `null` in test mode, which has no daily limit). It resets at 00:00 UTC. The count is kept durably (a restart does not reset it) and is checked as the request is accepted; if it cannot be checked, the request is refused with [`service_unavailable`](/errors/service_unavailable) rather than let through. | Request | Counts | | --- | --- | | `POST /v1/calls` | One call. A replay with the same `Idempotency-Key`, if you send one, does not count again. | | Starting a campaign | **One per recipient**, counted before any dialling. If they do not all fit in what is left today, the start is refused whole and nothing is dialled. | | Resuming a campaign | Nothing: its start already counted. A resume is never refused for the daily limit. | A campaign with more recipients than the daily limit (more than 500 on the default limit) therefore cannot be started through the API in one day: split it into smaller campaigns, or start it from the console. When a request does not fit, it answers [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429). `details` say the limit, how many calls the request needed, how many are left and when the limit resets, and `Retry-After` is the number of seconds until then: ```json { "error": { "code": "daily_call_limit_reached", "message": "This workspace has reached its daily limit of calls placed through the API.", "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached" } } ``` Nothing was dialled. Schedule the work for after `resets_at` rather than sleeping, or (for a campaign) start a smaller one that fits in `remaining`. If your expected volume is higher, write to [support@relationsai.com](mailto:support@relationsai.com) to discuss it. ## Calling capacity Separately from your rate limits, call starts are paced across the phone network. When no capacity is free right now, `POST /v1/calls` answers [`calling_capacity_reached`](/errors/calling_capacity_reached) (429) with `Retry-After`. The request was valid and nothing was dialled: wait and send it again. For large volumes, use a campaign, which paces calls for you. ## Headers Every response, successful or not, includes: | Header | Meaning | | --- | --- | | `RateLimit-Limit` | Requests allowed in the current one-minute window by the tightest limit that applied to this request: the key's or the workspace's limit for the operation's class, or the per-address limit (300) for a request that never reached an operation. | | `RateLimit-Remaining` | Requests left in the window. | | `RateLimit-Reset` | Seconds until the window resets. | `429` and `503` responses also include `Retry-After`, in seconds. ```http HTTP/1.1 200 OK X-Request-Id: req_4k7m2qat5xvz3c6vqnwpbdfh RateLimit-Limit: 600 RateLimit-Remaining: 597 RateLimit-Reset: 42 ``` Use them to slow down before you hit the limit: when `RateLimit-Remaining` reaches `0`, wait `RateLimit-Reset` seconds before the next request. ## Handling 429 [`rate_limited`](/errors/rate_limited) means nothing was done. For example, a contact sync that sends too many writes in one minute: ```bash curl -X POST https://voice-api.relationsai.com/v1/contacts/upsert \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ "phone_number": "+12025550100", "name": "Asha Rao", "consent_granted": true }' ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/contacts/upsert", { method: "POST", headers: { Authorization: "Bearer ", "Content-Type": "application/json", }, body: JSON.stringify({ phone_number: "+12025550100", name: "Asha Rao", consent_granted: true, }), }); console.log(response.status, await response.json()); ``` ```python response = requests.post( "https://voice-api.relationsai.com/v1/contacts/upsert", headers={ "Authorization": "Bearer ", }, json={ "phone_number": "+12025550100", "name": "Asha Rao", "consent_granted": True, }, timeout=30, ) print(response.status_code, response.json()) ``` ```http HTTP/1.1 429 Too Many Requests Retry-After: 30 RateLimit-Limit: 120 RateLimit-Remaining: 0 RateLimit-Reset: 30 { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` `details.limit_class` says which class (`read`, `write` or `dial`) was exceeded. Wait 30 seconds (the `Retry-After` value) plus a little random jitter, then send **the same request** again (with the same `Idempotency-Key`, if you sent one); nothing was done the first time. Then: 1. Spread the remaining requests out: when `RateLimit-Remaining` reaches `0`, wait `RateLimit-Reset` seconds before the next one. 2. For errors with `retry: "yes"` on reads, and for requests that got no response at all, use **exponential backoff with full jitter**: wait a random time between 0 and `min(cap, base × 2^attempt)`, for example a base of 1 second, doubling, with a cap of 30 seconds. For a `500` [`internal_error`](/errors/internal_error) on a write, check whether the change was applied before retrying: a `500` is not stored under the key. 3. Cap the number of attempts (for example five), and hand very long waits, such as the daily call limit, back to your scheduler. 4. If you hit `429` regularly, use fewer and larger pages (`limit=100`), prefer webhooks to polling, and use campaigns for bulk calling. When the API cannot check rate limits because of a temporary problem on its side, it refuses the request with [`service_unavailable`](/errors/service_unavailable) (503) and `Retry-After`, rather than letting it through. Handle it the same way. The complete runnable example is one request helper that waits for `Retry-After`, backs off with jitter, slows down when `RateLimit-Remaining` reaches `0`, and retries a write (sent without an `Idempotency-Key`) only after a `429` or `503`, which mean nothing was done; after any other uncertain answer it stops so you can check the state first. Complete runnable example: [cURL script](/examples/fundamentals/rate-limit-backoff.sh) · [JavaScript](/examples/fundamentals/rate-limit-backoff.mjs) · [Python](/examples/fundamentals/rate-limit-backoff.py) ## Polling budget If you poll calls instead of using webhooks, poll no more than every few seconds per call, and stop when `settled` is `true`. With the `read` limit of 600 per minute per key, polling every 3 seconds supports about 30 calls at a time per key; beyond that, use [webhooks](/fundamentals/webhooks). --- Source: /fundamentals/data-types # Data types The API uses a few consistent formats. This page specifies them so you can parse and store values correctly. ## Identifiers Ids are strings with a type prefix, at most 64 characters, made of letters, digits, `_` and `-` (URL-safe). They are case sensitive. | Prefix | Object | Prefix | Object | | --- | --- | --- | --- | | `ws_` | Workspace | `rcp_` | Campaign recipient | | `key_` | API key | `ct_` | Contact | | `agent_` | Agent | `dnc_` | Do-not-call entry | | `pn_` | Phone number | `we_` | Webhook endpoint | | `call_` | Call | `evt_` | Event | | `cmp_` | Campaign | `wd_` | Webhook delivery | | `req_` | Request (the `X-Request-Id` header) | | | - Treat ids as opaque. Store them as strings (up to 64 characters); do not parse them, derive meaning from them or build them yourself. - An id that is malformed, has the wrong prefix for the path, belongs to another workspace or to the other mode answers [`resource_not_found`](/errors/resource_not_found): the same answer as an id that never existed. - Ids are unique within their type. Keep the mode next to stored ids: a test-mode id means nothing to a live key. ## Timestamps RFC 3339 in UTC with milliseconds, always ending in `Z`: ```text 2026-10-21T09:30:00.000Z ``` Send UTC timestamps in filters such as `created_after`. Durations are plain integers with the unit in the name: `duration_seconds` on a call, `start_ms` and `end_ms` (milliseconds from the start of the call) on transcript turns. Time zones appear as IANA names such as `Asia/Kolkata`: the workspace's `timezone`, and `details.timezone` of [`outside_calling_hours`](/errors/outside_calling_hours), which is the zone the calling window was evaluated in. Timestamps themselves are always UTC. ## Money Amounts are objects with an integer in the currency's **minor unit** and an ISO 4217 code: ```json { "amount": 1250, "currency": "INR" } ``` - For INR the minor unit is the paisa: `1250` is ₹12.50. Never use floating-point numbers for amounts. - Amounts in responses (a call's `cost`, a campaign's `spend`) are what your workspace is charged, never negative, and always `0` in test mode. - Amounts you send (a campaign's `budget_cap`) must be at least 1 and in your workspace's currency, today `INR`. - `currency` is an open value: today always `INR`, matching the workspace `currency`. A call's `cost` is `null` until it is known and final once the call is `settled`. ## Phone numbers E.164 strings: a `+`, the country code and the number, digits only, 8 to 15 digits in total: ```text +12025550100 +12025550150 ``` (Both are fictional test numbers. Use fictional numbers in your own tests and fixtures too.) - An invalid number in a request answers [`invalid_phone_number`](/errors/invalid_phone_number) (400). - Remember to URL-encode the `+` as `%2B` in query strings (see [Filtering](/fundamentals/filtering)). - Numbers are returned in full, as stored, to the workspace that owns them. - The do-not-call check and the contact lookups match numbers ignoring formatting. ## Booleans and null Booleans are JSON `true` and `false`; in query strings, `true` and `false`. Every documented field is present in every response; a value that is not set or not known yet is `null` (for example `outcome` before a call ends). ## Metadata Calls accept `metadata`, your own key-value data stored with the object and returned as sent: - Up to 16 keys; keys up to 40 characters of letters, digits, `_`, `-` and `.`; values are strings up to 500 characters. - Never sent to the person called and never used by the agent. - Do not put secrets or sensitive personal data in it. Use it for your own ids (`{"crm_lead_id": "L-20931"}`), so you can match calls, events and webhook payloads back to your records. ## Variables and custom fields - **Call variables** (`variables` on `POST /v1/calls`): values the agent can use in the conversation. Up to 50 keys; keys are lowercase letters, digits and `_`, starting with a letter, up to 64 characters; values are strings up to 500 characters. - **Custom fields** on contacts (`custom_fields`): values for fields defined in the console. An unknown field name answers [`custom_field_not_found`](/errors/custom_field_not_found). Values are strings up to 500 characters; on update, `null` clears a field and omitted fields are left unchanged. ## Enums Enum values are lowercase `snake_case` strings: call `status`, `outcome` and `end_reason`, campaign and recipient `status`, artifact `state`, readiness reason `code`, delivery `status` and so on. **Enums are open**: new values can be added at any time without a new API version. Your code must: - accept values it does not know, instead of failing to parse the response; - treat an unknown value as "other", never as success (an unknown call `outcome` is not `completed`); - log unknown values so you notice them and can update your handling. Several enums include `unknown` today, meaning RelationsAI could not determine the value. It is never a success. --- Source: /fundamentals/webhooks # 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 `.`: 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). --- Source: /fundamentals/security-practices # Security practices This page collects the practices that keep your integration and your customers' data safe: key handling, least privilege, network restrictions, webhook verification and data handling. A live key can place real calls to real people and spend your workspace's balance, so treat it like a payment credential. ## Keys - **Server-side only.** Never put an API key in a web page, mobile app, desktop app, browser extension or anything else you ship. The API refuses live keys sent from browsers ([`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser)), but a key in shipped code is exposed whether or not the API accepts it from there. - **Least privilege.** Give each key only the scopes its system needs (see [API keys](/getting-started/api-keys#scopes)). A reporting job does not need `calls:write`; a contact sync does not need `transcripts:read`. - **One key per system and environment.** Then you can rotate or revoke one without touching the others, and the console's **Last used** and request counts tell you which system did what. - **Always an expiry.** Live keys always expire; give test keys an expiry too unless they are only on your own machine. Track `api_key.expires_at` from `GET /v1/workspace` and rotate before it. - **Store in a secret manager** or deployment-provided environment variables. Never in source control, container images, logs, crash reports, analytics, URLs or tickets. - **Never log credentials.** Redact the `Authorization` header in your HTTP client's logging and in proxies. Log the key id (`key_...`) and the request id instead. - **Keys follow people.** A key stops working when its creator leaves the workspace, and loses scopes when the creator's role is reduced. Create production keys from an account that will stay, and replace keys before someone leaves. ## If a key is exposed 1. In the console, **rotate** the key with the grace period **Immediately**, or **revoke** it. Either takes effect at once. 2. Deploy the replacement key. 3. Review what the key did: its **Last used** time and address in the console, and the calls, campaigns and contacts changed in that mode around that time. 4. If live calls may have been placed by someone else, write to [support@relationsai.com](mailto:support@relationsai.com) with the key id (never the key itself) and the time window. ## Restrict where keys work If your servers have fixed outbound addresses, ask a workspace admin to set an **IP allowlist** in the console security settings. API requests from other addresses are then refused with [`ip_not_allowed`](/errors/ip_not_allowed), so a leaked key is useless elsewhere. If the allowlist cannot be checked, the API refuses the request rather than letting it through. ## Test before live - Develop, test and run CI with test keys. Test mode never dials anyone or charges anything. - Keep live keys out of development machines, CI and staging. - At startup, check `livemode` from `GET /v1/workspace` against the environment you expect, and refuse to run if they differ. - Use the fictional test numbers (`+12025550100` to `+12025550106`) in tests and fixtures, never real people's numbers. ## Webhooks - **Verify every signature** with the endpoint's secret, over the raw body, in constant time, and reject events whose `t` is more than 5 minutes off. See [Verifying webhook signatures](/guides/verifying-webhook-signatures). - **Deduplicate** by event id; deliveries are at least once. - **Check `livemode`** and only act on events of the mode the endpoint is meant for. - **Treat the payload as a notification.** For actions that matter (billing, follow-ups), read the object from the API before acting. - **Keep the secret secret**: store it like an API key, and rotate it if it may have been exposed: with `grace_period_hours: 0` the old secret stops signing at once. A replayed create or rotate response shows `secret: null`, so the first response is the only copy; if you lose it, rotate again. - **Answer fast and process later**, so slow work cannot cause timeouts and redelivery storms. ## Call data Transcripts, recordings, analysis and contact data contain personal data about the people your agents call. The API returns them in full to your workspace, so protecting them in your systems is your responsibility. - **Restrict access** to the systems and people that need it, and only grant `transcripts:read` and `recordings:read` to keys that need them. - **Recording links are credentials.** The `url` from [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) works without an API key until it expires (about 15 minutes). Do not log, store or forward it; ask for a fresh link when you need one. An expired link answers [`media_link_expired`](/errors/media_link_expired). It also stops working (`404`) as soon as the API key that obtained it is revoked or expires, loses `recordings:read`, or its creator leaves the workspace. Revoking a leaked key therefore also cuts off the recording links it obtained. - **Keep data only as long as you need it**, and delete it from your systems according to your retention policy. - **No secrets or sensitive data in `metadata`.** It is stored and returned as sent; use your own ids instead. - **Keep consent and the do-not-call list accurate.** Record consent only when the person really gave it, record withdrawals promptly, and add do-not-call requests as soon as you receive them. See [Consent and do-not-call](/guides/consent-and-dnc). ## Your own endpoints and logs - Serve your webhook endpoint over HTTPS with a valid certificate. - Do not echo request bodies or headers into error pages. - When you write to support, follow [Support](/resources/support): never send keys, full `Authorization` headers, webhook secrets, customer data or transcripts. ## Reporting a problem Report a suspected security issue in the API or these docs to [support@relationsai.com](mailto:support@relationsai.com) with "Security" in the subject. Describe what you found and how to reproduce it, without including real customer data or live credentials. --- Source: /api/agents # Agents AI voice agents. Agents are created, configured and published in the console; the API reads them and tells you whether one is ready to place calls. Agent editing through the API is planned. ## List agents `GET /agents` Lists the workspace's agents, newest first. Agents are created, configured and published in the console; the API reads them. Deleted agents are not listed. `published` is checked live for each page, so it can be null when that check is not available at that moment. Use the readiness endpoint before placing calls with an agent. - Operation ID: `listAgents` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | Response 200: ```json { "object": "list", "data": [ { "id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "object": "agent", "livemode": false, "name": "Loan renewal reminder", "description": "Reminds customers that their loan is due for renewal and books a callback.", "status": "active", "published": true, "published_version": 4, "is_default": true, "sample": false, "created_at": "2026-09-02T10:12:44.000Z", "updated_at": "2026-10-18T14:03:10.000Z" }, { "id": "agent_1ZltiWzxrXmMprGD6q63", "object": "agent", "livemode": false, "name": "Sample agent", "description": "A built-in agent for trying the API in test mode.", "status": "active", "published": true, "published_version": 1, "is_default": false, "sample": true, "created_at": "2026-10-21T00:00:00.000Z", "updated_at": null } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve an agent `GET /agents/{agent_id}` Returns one agent. An agent that was deleted, belongs to another workspace, or is the test-mode Sample agent read with a live key is `404 resource_not_found`. - Operation ID: `getAgent` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `agent_id` | path | yes | The agent id (`agent_...`). | Response 200: ```json { "id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "object": "agent", "livemode": true, "name": "Loan renewal reminder", "description": "Reminds customers that their loan is due for renewal and books a callback.", "status": "active", "published": true, "published_version": 4, "is_default": true, "sample": false, "created_at": "2026-09-02T10:12:44.000Z", "updated_at": "2026-10-18T14:03:10.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Check whether an agent can place calls `GET /agents/{agent_id}/readiness` Checks, right now, everything about the agent and the workspace that would stop a call being placed, and returns every problem found (not just the first). An agent is ready when it is switched on, fully set up and published, has an outbound phone number, and the workspace has calling set up, an active account, spend headroom and enough balance. Readiness does not check per-destination rules (the do-not-call list, consent, calling hours and the per-number daily limit); those are applied to each call. A ready agent can therefore still be refused for a particular number, and readiness can change at any time. - Operation ID: `getAgentReadiness` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `agent_id` | path | yes | The agent id (`agent_...`). | Response 200: ```json { "object": "agent_readiness", "livemode": true, "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "ready": true, "reasons": [], "checked_at": "2026-10-21T09:30:00.000Z" } ``` Response 200: ```json { "object": "agent_readiness", "livemode": true, "agent_id": "agent_7Yx2Wq9Lp4Mn8Bv3Kc6Hd1", "ready": false, "reasons": [ { "code": "agent_not_published", "message": "Publish the agent in the console before placing calls." }, { "code": "no_outbound_number", "message": "Assign a phone number to this agent for outbound calls in the console." } ], "checked_at": "2026-10-21T09:30:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/analysis # Analysis ## Retrieve a call analysis `GET /calls/{call_id}/analysis` Returns the call summary, the fields the agent extracts from the conversation (configured in the console), and whether the call met its goal. Analysis is produced after the call ends, usually within a minute; each part has its own `state`. Sentiment is not offered. - Operation ID: `getCallAnalysis` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `call_id` | path | yes | The call id (`call_...`). | Response 200: ```json { "object": "call_analysis", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "summary": { "state": "ready", "text": "The customer confirmed the renewal and asked for a callback on Friday afternoon." }, "extracted_fields": [ { "name": "wants_renewal", "type": "boolean", "state": "ready", "value": true }, { "name": "callback_day", "type": "string", "state": "ready", "value": "Friday" } ], "goal_met": true } ``` Response 200: ```json { "object": "call_analysis", "livemode": true, "call_id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "state": "pending", "simulated": false, "summary": { "state": "pending", "text": null }, "extracted_fields": [], "goal_met": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/calls # Calls Place outbound calls and read their status, outcome, transcript, recording and analysis. ## List calls `GET /calls` Lists calls, newest first, including calls placed from the console and by campaigns. Filter by agent, campaign, destination, status, outcome and creation time. List entries are the full call object without the transcript; read artifacts from their own endpoints. Calls appear here as soon as they are accepted. Status and outcome are refreshed as the call progresses, typically within seconds, occasionally a minute or more behind; `settled` marks a call whose details are final. When you filter by `status`, a page can hold fewer than `limit` calls while `has_more` is true; keep following `next_cursor` until it is null. - Operation ID: `listCalls` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `created_after` | query | no | Only objects created at or after this time (RFC 3339, UTC). | | `created_before` | query | no | Only objects created before this time (RFC 3339, UTC). | | `agent_id` | query | no | Only calls handled by this agent. | | `campaign_id` | query | no | Only calls placed by this campaign. | | `to` | query | no | Only calls to this number (E.164). | | `status` | query | no | Only calls in this status. | | `outcome` | query | no | Only ended calls with this outcome. | Response 200: ```json { "object": "list", "data": [ { "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false, "status": "ended", "outcome": "no_answer", "end_reason": "no_answer", "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550101", "metadata": {}, "created_at": "2026-10-21T09:35:00.000Z", "ended_at": "2026-10-21T09:35:18.000Z", "duration_seconds": 0, "billable_minutes": 0, "cost": { "amount": 0, "currency": "INR" }, "artifacts": { "transcript": { "state": "absent" }, "recording": { "state": "absent" }, "analysis": { "state": "absent" } }, "settled": true, "settled_at": "2026-10-21T09:35:30.000Z" }, { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Place a call `POST /calls` Places one outbound call from an agent to a phone number. The call runs after the request returns: the response is `202 Accepted` with the call in `queued` status. **What 202 means.** The call passed every check (agent readiness, account, balance and spend limits, the do-not-call list, consent, calling hours and the per-number daily limit) and is being started. It does not mean anyone has answered. Follow the call with the `call.ended` and `call.settled` webhooks, or poll `GET /v1/calls/{call_id}`. In rare cases the start is never confirmed; the call then ends with `outcome: failed` and `end_reason: not_confirmed` within about two minutes and is not charged. **Retries.** `Idempotency-Key` is optional. With a key, a retry with the same key returns the original response and never places a second call, even if the first response was lost. Without one, a retry places another call: if a response was lost, list calls by `to` and `created_after` before retrying. **Caller number.** Without `phone_number_id`, the agent's outbound number is used; when the agent has several, send `phone_number_id`. **Variables.** `variables` are passed to the agent for this call. When `to` matches a contact, the contact's name and custom fields are added as variables too (your values win). **Charges.** Live calls are charged per started minute of talk time at your workspace rate, from the workspace balance. A call that is not answered is not charged. - Operation ID: `createCall` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "to": "+12025550100" } ``` Response 202: ```json { "id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "object": "call", "livemode": false, "status": "queued", "outcome": null, "end_reason": null, "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550100", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": null, "duration_seconds": null, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 402 (`insufficient_balance`): ```json { "error": { "code": "insufficient_balance", "message": "The workspace balance is too low to start this call. Top up to continue.", "details": { "balance": { "amount": 250, "currency": "INR" }, "required": { "amount": 600, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_balance" } } ``` Response 402 (`spend_limit_reached`): ```json { "error": { "code": "spend_limit_reached", "message": "This workspace has reached the monthly spend limit set by its admin.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/spend_limit_reached" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`account_inactive`): ```json { "error": { "code": "account_inactive", "message": "This workspace cannot place calls because its account is not active.", "details": { "reason": "trial_expired" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/account_inactive" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`agent_not_ready`): ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` Response 422 (`calling_not_configured`): ```json { "error": { "code": "calling_not_configured", "message": "Calling is not set up for this workspace yet.", "details": { "reason": "no_outbound_assignment" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/calling_not_configured" } } ``` Response 422 (`outside_calling_hours`): ```json { "error": { "code": "outside_calling_hours", "message": "This is outside the permitted calling window for this workspace. Nothing was dialled.", "details": { "window": "09:00-21:00", "timezone": "Asia/Kolkata" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/outside_calling_hours" } } ``` Response 422 (`phone_number_not_usable`): ```json { "error": { "code": "phone_number_not_usable", "message": "This phone number cannot place outbound calls for this agent.", "details": { "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_not_usable" } } ``` Response 422 (`phone_number_required`): ```json { "error": { "code": "phone_number_required", "message": "This agent has more than one outbound number. Choose one with phone_number_id.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_required" } } ``` Response 422 (`dnc_listed`): ```json { "error": { "code": "dnc_listed", "message": "This number is on your do-not-call list, so the call was not placed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/dnc_listed" } } ``` Response 422 (`consent_required`): ```json { "error": { "code": "consent_required", "message": "This contact has no recorded consent, and consent is required for calls in this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/consent_required" } } ``` Response 422 (`contact_daily_limit_reached`): ```json { "error": { "code": "contact_daily_limit_reached", "message": "This number has already been called the maximum number of times allowed in 24 hours.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_daily_limit_reached" } } ``` Response 422 (`call_not_accepted`): ```json { "error": { "code": "call_not_accepted", "message": "The call could not be placed. Nothing was dialled and nothing was charged.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/call_not_accepted" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 429 (`daily_call_limit_reached`): ```json { "error": { "code": "daily_call_limit_reached", "message": "This workspace has reached its daily limit of calls placed through the API.", "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached" } } ``` Response 429 (`calling_capacity_reached`): ```json { "error": { "code": "calling_capacity_reached", "message": "Too many calls are being started right now. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/calling_capacity_reached" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve a call `GET /calls/{call_id}` Returns one call with its status, outcome, timing, cost and the readiness of its artifacts. Poll it (no more than every few seconds) or, better, use webhooks. `outcome`, `duration_seconds`, `billable_minutes` and `cost` can still change until `settled` is true. - Operation ID: `getCall` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `call_id` | path | yes | The call id (`call_...`). | Response 200: ```json { "id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "object": "call", "livemode": true, "status": "ended", "outcome": "completed", "end_reason": "hangup", "direction": "outbound", "source": "api", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "campaign_id": null, "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "from": "+918000000001", "to": "+919000000001", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": "2026-10-21T09:32:14.000Z", "duration_seconds": 118, "billable_minutes": 2, "cost": { "amount": 600, "currency": "INR" }, "artifacts": { "transcript": { "state": "ready" }, "recording": { "state": "ready" }, "analysis": { "state": "ready" } }, "settled": true, "settled_at": "2026-10-21T09:41:02.000Z" } ``` Response 200: ```json { "id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "object": "call", "livemode": false, "status": "ended", "outcome": "no_answer", "end_reason": "no_answer", "direction": "outbound", "source": "api", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "campaign_id": null, "phone_number_id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "from": "+12025550150", "to": "+12025550101", "metadata": {}, "created_at": "2026-10-21T09:35:00.000Z", "ended_at": "2026-10-21T09:35:18.000Z", "duration_seconds": 0, "billable_minutes": 0, "cost": { "amount": 0, "currency": "INR" }, "artifacts": { "transcript": { "state": "absent" }, "recording": { "state": "absent" }, "analysis": { "state": "absent" } }, "settled": true, "settled_at": "2026-10-21T09:35:30.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/campaigns # Campaigns Call a list of contacts with one agent, with pause, resume and stop. ## List campaigns `GET /campaigns` Lists campaigns, newest first. Filter by `status`. Status and progress are refreshed in the background and can be up to a minute behind; a terminal status reached by the run itself (`completed`, `failed`) can take longer to appear. - Operation ID: `listCampaigns` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `created_after` | query | no | Only objects created at or after this time (RFC 3339, UTC). | | `created_before` | query | no | Only objects created before this time (RFC 3339, UTC). | | `status` | query | no | Only campaigns in this status. | Response 200: ```json { "object": "list", "data": [ { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Create a campaign `POST /campaigns` Creates a campaign in `draft` status. Nothing is dialled until you start it. You can add recipients now (`contact_ids`) or later with the recipients endpoint, while the campaign is a draft. Recipients are contacts: create them first, for example with the contact upsert. Do-not-call numbers are left out when a campaign starts and block a resume; there is no API option to include them. A campaign whose screening was switched off in the console is refused by start and resume (`409 campaign_dnc_override_enabled`). - Operation ID: `createCampaign` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] } ``` Response 201: ```json { "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "draft", "capabilities": { "start": true, "pause": false, "resume": false, "stop": true, "update": true, "add_recipients": true }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 0, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`recipient_contacts_not_found`): ```json { "error": { "code": "recipient_contacts_not_found", "message": "Some contact ids do not name contacts in this workspace.", "details": { "contact_ids": [ "ct_9Zx4Vb7Nm2Qw5Er8Ty1Ui3" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/recipient_contacts_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve a campaign `GET /campaigns/{campaign_id}` Returns one campaign with its status, allowed actions (`capabilities`), progress and spend. The `ETag` header is the version to send as `If-Match` when you update it. - Operation ID: `getCampaign` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | Response 200: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Update a campaign `PATCH /campaigns/{campaign_id}` Changes a campaign's name, agent, concurrency or budget cap. Allowed in every status except `running` (pause it first). Changes take effect at the **next start**: a resume continues the paused run with the settings it began with. Requires `If-Match` with the campaign's current `ETag`. - Operation ID: `updateCampaign` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `If-Match` | header | yes | The `ETag` from your most recent read of this resource. The update is refused if the resource has changed since. `*` matches any current version: the update is applied without the concurrency check. | Example request body: ```json { "budget_cap": { "amount": 750000, "currency": "INR" } } ``` Response 200: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "paused", "capabilities": { "start": true, "pause": false, "resume": true, "stop": true, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 412 (`version_conflict`): ```json { "error": { "code": "version_conflict", "message": "The resource changed since you read it.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/version_conflict" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 428 (`precondition_required`): ```json { "error": { "code": "precondition_required", "message": "This update needs an If-Match header with the ETag of the version you read.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/precondition_required" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## List recipients `GET /campaigns/{campaign_id}/recipients` Lists a campaign's recipients with what happened to each in the current or latest run: status, attempts, the last call and its outcome, or why the recipient was left out. Recipients are listed in a stable order (not by time). Use the cursor to page; do not rely on the order meaning anything else. Per-recipient results are refreshed in the background and can be up to a minute behind the calls themselves. - Operation ID: `listCampaignRecipients` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `status` | query | no | Only recipients in this status. | Response 200: ```json { "object": "list", "data": [ { "id": "rcp_3Gh8Jk2Lz5Xc9Vb4Nm7Qw1", "object": "campaign_recipient", "livemode": true, "campaign_id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "phone_number": "+919000000001", "status": "completed", "attempts": 1, "last_call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "outcome": "completed", "suppression_reason": null }, { "id": "rcp_6Tr1Yu4Io7Pa2Sd5Fg8Hj3", "object": "campaign_recipient", "livemode": true, "campaign_id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "contact_id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "phone_number": "+919000000002", "status": "suppressed", "attempts": 0, "last_call_id": null, "outcome": null, "suppression_reason": "dnc_listed" } ], "has_more": true, "next_cursor": "eyJ2IjoxLCJrIjoicmNwXzZUcjFZdTRJbzdQYTJTZDVGZzhIajMifQ.4f7a2c" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Add recipients `POST /campaigns/{campaign_id}/recipients` Adds contacts to a `draft` campaign. Contacts already in the campaign are skipped. Once a campaign has started, its recipients are fixed: a later start calls the previous run's recipients again. To call a different audience, create a new campaign. Up to 1000 contacts per request and 10,000 per campaign. - Operation ID: `addCampaignRecipients` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ] } ``` Response 200: ```json { "object": "campaign_recipients_added", "livemode": false, "campaign_id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "added": 1, "recipient_count": 3 } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`recipient_contacts_not_found`): ```json { "error": { "code": "recipient_contacts_not_found", "message": "Some contact ids do not name contacts in this workspace.", "details": { "contact_ids": [ "ct_9Zx4Vb7Nm2Qw5Er8Ty1Ui3" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/recipient_contacts_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Start a campaign `POST /campaigns/{campaign_id}/start` Starts a run: from `draft` it calls the recipients you added; from `stopped`, `completed` or `paused` it begins a new run with the previous run's recipients (a paused run is abandoned; use resume to continue it instead). Before anything is dialled the API checks the agent, the account, balance and spend limits, the campaign budget cap, calling hours and the telephony connection, and screens every recipient against the do-not-call list, consent and the per-number daily limit. Blocked recipients are left out (see the recipients list); if that leaves nobody, the start is refused. Screening happens at the start: a number added to the do-not-call list while the run is going is still called by it (pause and resume to re-screen, or stop). A campaign whose do-not-call screening was switched off in the console is refused (`409 campaign_dnc_override_enabled`). **Daily call limit.** In live mode a start counts every recipient of the campaign against the workspace's daily limit of calls placed through the API (see `limits.daily_call_limit` on the workspace), before anything is dialled, because the run may call each of them. A start that would go past the limit is refused whole with `429 daily_call_limit_reached`, and the message says how many calls it needed. A start refused for any other reason before dialling does not count. Test mode has no limit. **What 200 means.** The run was started and the campaign is `running`; calls are placed at up to `concurrency_limit` at a time. **202** means the start was sent but its confirmation has not arrived yet; the campaign shows `running` and is reconciled automatically, so retrieve it in a minute rather than starting it again. - Operation ID: `startCampaign` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1", "object": "campaign", "livemode": false, "name": "November renewals", "agent_id": "agent_1ZltiWzxrXmMprGD6q63", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 2, "progress": { "calls": 0, "connected": 0, "no_answer": 0, "failed": 0, "in_progress": 0 }, "spend": { "amount": 0, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-21T08:00:00.000Z" } ``` Response 202: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 402 (`insufficient_balance`): ```json { "error": { "code": "insufficient_balance", "message": "The workspace balance is too low to start this call. Top up to continue.", "details": { "balance": { "amount": 250, "currency": "INR" }, "required": { "amount": 600, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_balance" } } ``` Response 402 (`spend_limit_reached`): ```json { "error": { "code": "spend_limit_reached", "message": "This workspace has reached the monthly spend limit set by its admin.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/spend_limit_reached" } } ``` Response 402 (`campaign_budget_reached`): ```json { "error": { "code": "campaign_budget_reached", "message": "This campaign has reached its budget cap.", "details": { "budget_cap": { "amount": 500000, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_budget_reached" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`account_inactive`): ```json { "error": { "code": "account_inactive", "message": "This workspace cannot place calls because its account is not active.", "details": { "reason": "trial_expired" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/account_inactive" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 409 (`campaign_operation_in_progress`): ```json { "error": { "code": "campaign_operation_in_progress", "message": "This campaign is being started or resumed by another request. Retry shortly.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_operation_in_progress" } } ``` Response 409 (`campaign_outcome_unknown`): ```json { "error": { "code": "campaign_outcome_unknown", "message": "The request was sent but its outcome is not confirmed yet. Check the campaign before trying again.", "details": { "action": "start", "needs_support": false }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_outcome_unknown" } } ``` Response 409 (`campaign_has_no_recipients`): ```json { "error": { "code": "campaign_has_no_recipients", "message": "This campaign has no recipients to call.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_has_no_recipients" } } ``` Response 409 (`campaign_dnc_override_enabled`): ```json { "error": { "code": "campaign_dnc_override_enabled", "message": "This campaign is set in the console to also call contacts on the do-not-call list, so it cannot be started or resumed through the API.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_dnc_override_enabled" } } ``` Response 422 (`agent_not_ready`): ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` Response 422 (`calling_not_configured`): ```json { "error": { "code": "calling_not_configured", "message": "Calling is not set up for this workspace yet.", "details": { "reason": "no_outbound_assignment" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/calling_not_configured" } } ``` Response 422 (`outside_calling_hours`): ```json { "error": { "code": "outside_calling_hours", "message": "This is outside the permitted calling window for this workspace. Nothing was dialled.", "details": { "window": "09:00-21:00", "timezone": "Asia/Kolkata" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/outside_calling_hours" } } ``` Response 422 (`campaign_recipients_suppressed`): ```json { "error": { "code": "campaign_recipients_suppressed", "message": "Every recipient of this campaign is blocked by your compliance settings. Nothing was dialled.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_suppressed" } } ``` Response 422 (`campaign_too_large`): ```json { "error": { "code": "campaign_too_large", "message": "This campaign has too many recipients to start in one run.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_too_large" } } ``` Response 422 (`phone_number_required`): ```json { "error": { "code": "phone_number_required", "message": "This agent has more than one outbound number. Choose one with phone_number_id.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_required" } } ``` Response 422 (`telephony_disconnected`): ```json { "error": { "code": "telephony_disconnected", "message": "The telephony connection for this workspace is not working, so the campaign was paused.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/telephony_disconnected" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 429 (`daily_call_limit_reached`): ```json { "error": { "code": "daily_call_limit_reached", "message": "This workspace has reached its daily limit of calls placed through the API.", "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` Response 503 (`campaign_recipients_unavailable`): ```json { "error": { "code": "campaign_recipients_unavailable", "message": "The campaign's recipients could not be read for screening right now, so nothing was dialled.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_unavailable" } } ``` ## Pause a campaign `POST /campaigns/{campaign_id}/pause` Pauses a `running` campaign: no new calls are started. Calls already connected finish normally and are charged as usual. The campaign is reported `paused` only once the pause is confirmed; if it is not confirmed you get `409 campaign_pause_not_confirmed` and the campaign may still be dialling. - Operation ID: `pauseCampaign` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "paused", "capabilities": { "start": true, "pause": false, "resume": true, "stop": true, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 409 (`campaign_pause_not_confirmed`): ```json { "error": { "code": "campaign_pause_not_confirmed", "message": "The pause was requested but not confirmed. The campaign may still be dialling.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/campaign_pause_not_confirmed" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Resume a campaign `POST /campaigns/{campaign_id}/resume` Continues a `paused` campaign's run from where it stopped. Before anything is sent, the API checks the agent, the account, balance and spend limits, the budget cap, calling hours and the telephony connection, and screens **every recipient the run could still call** against the do-not-call list, consent and the per-number daily limit. A resume cannot drop recipients from a run. So if any remaining recipient is now blocked (for example added to the do-not-call list after the campaign started), nothing is resumed, the campaign stays `paused`, and you get `409 campaign_recipients_blocked` with the blocked contacts. To continue without them, list the recipients still `pending`, create a new campaign with those contacts minus the blocked ones, start it, and stop this one (stopping does not hang up calls in progress). Do not stop and start this campaign: a new start calls every recipient of the previous run again, including people already reached, and charges for those calls. If too many recipients remain to re-screen, the resume is refused with `422 campaign_too_large`. A resume continues a run its start already counted against the daily call limit, so it counts nothing again. - Operation ID: `resumeCampaign` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 640, "connected": 410, "no_answer": 190, "failed": 35, "in_progress": 5 }, "spend": { "amount": 246000, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 402 (`insufficient_balance`): ```json { "error": { "code": "insufficient_balance", "message": "The workspace balance is too low to start this call. Top up to continue.", "details": { "balance": { "amount": 250, "currency": "INR" }, "required": { "amount": 600, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_balance" } } ``` Response 402 (`spend_limit_reached`): ```json { "error": { "code": "spend_limit_reached", "message": "This workspace has reached the monthly spend limit set by its admin.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/spend_limit_reached" } } ``` Response 402 (`campaign_budget_reached`): ```json { "error": { "code": "campaign_budget_reached", "message": "This campaign has reached its budget cap.", "details": { "budget_cap": { "amount": 500000, "currency": "INR" } }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_budget_reached" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`account_inactive`): ```json { "error": { "code": "account_inactive", "message": "This workspace cannot place calls because its account is not active.", "details": { "reason": "trial_expired" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/account_inactive" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 409 (`campaign_operation_in_progress`): ```json { "error": { "code": "campaign_operation_in_progress", "message": "This campaign is being started or resumed by another request. Retry shortly.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_operation_in_progress" } } ``` Response 409 (`campaign_outcome_unknown`): ```json { "error": { "code": "campaign_outcome_unknown", "message": "The request was sent but its outcome is not confirmed yet. Check the campaign before trying again.", "details": { "action": "start", "needs_support": false }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_outcome_unknown" } } ``` Response 409 (`campaign_not_resumable`): ```json { "error": { "code": "campaign_not_resumable", "message": "This campaign cannot be resumed. Start it again to begin a new run.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_not_resumable" } } ``` Response 409 (`campaign_recipients_blocked`): ```json { "error": { "code": "campaign_recipients_blocked", "message": "Some recipients of this campaign are now blocked by compliance rules, so it was not resumed.", "details": { "blocked_total": 2, "by_reason": { "dnc_listed": 1, "consent_required": 1 }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ], "contact_ids_truncated": false }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_blocked" } } ``` Response 409 (`campaign_dnc_override_enabled`): ```json { "error": { "code": "campaign_dnc_override_enabled", "message": "This campaign is set in the console to also call contacts on the do-not-call list, so it cannot be started or resumed through the API.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_dnc_override_enabled" } } ``` Response 422 (`agent_not_ready`): ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` Response 422 (`calling_not_configured`): ```json { "error": { "code": "calling_not_configured", "message": "Calling is not set up for this workspace yet.", "details": { "reason": "no_outbound_assignment" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/calling_not_configured" } } ``` Response 422 (`outside_calling_hours`): ```json { "error": { "code": "outside_calling_hours", "message": "This is outside the permitted calling window for this workspace. Nothing was dialled.", "details": { "window": "09:00-21:00", "timezone": "Asia/Kolkata" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/outside_calling_hours" } } ``` Response 422 (`campaign_too_large`): ```json { "error": { "code": "campaign_too_large", "message": "This campaign has too many recipients to start in one run.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_too_large" } } ``` Response 422 (`telephony_disconnected`): ```json { "error": { "code": "telephony_disconnected", "message": "The telephony connection for this workspace is not working, so the campaign was paused.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/telephony_disconnected" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` Response 503 (`campaign_recipients_unavailable`): ```json { "error": { "code": "campaign_recipients_unavailable", "message": "The campaign's recipients could not be read for screening right now, so nothing was dialled.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_unavailable" } } ``` ## Stop a campaign `POST /campaigns/{campaign_id}/stop` Stops a `draft`, `running` or `paused` campaign. No further calls are started; calls already connected finish normally. A stopped campaign can be started again, which begins a new run with the same recipients. - Operation ID: `stopCampaign` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `campaign_id` | path | yes | The campaign id (`cmp_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "status": "stopped", "capabilities": { "start": true, "pause": false, "resume": false, "stop": false, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "dnc_screening": "enforced", "created_at": "2026-10-20T08:00:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`campaign_state_conflict`): ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/contacts # Contacts People you call, with consent and custom fields that become call variables. ## List contacts `GET /contacts` Lists contacts, newest first. Filter by exact phone number, consent or do-not-call status. - Operation ID: `listContacts` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `created_after` | query | no | Only objects created at or after this time (RFC 3339, UTC). | | `created_before` | query | no | Only objects created before this time (RFC 3339, UTC). | | `phone_number` | query | no | Only the contact with this number (E.164). Matches the same way the do-not-call check does. | | `consent_granted` | query | no | Only contacts with (`true`) or without (`false`) recorded consent. | | `do_not_call` | query | no | Only contacts on (`true`) or off (`false`) the do-not-call list. | Response 200: ```json { "object": "list", "data": [ { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Create a contact `POST /contacts` Creates a contact. Phone numbers are unique within a workspace and mode; creating a second contact with the same number is `409 contact_already_exists` (use the upsert to create-or-update). Setting `consent_granted: true` records that the person consented to be called, with the time and the API as its source. Only set it when they really did: when the workspace requires consent, it is what allows calls to this contact. - Operation ID: `createContact` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" } } ``` Response 201: ```json { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`contact_already_exists`): ```json { "error": { "code": "contact_already_exists", "message": "A contact with this phone number already exists.", "details": { "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_already_exists" } } ``` Response 409 (`contact_limit_reached`): ```json { "error": { "code": "contact_limit_reached", "message": "No more contacts can be created right now.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_limit_reached" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`custom_field_not_found`): ```json { "error": { "code": "custom_field_not_found", "message": "This custom field does not exist in the workspace.", "details": { "field": "policy_numbr" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/custom_field_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Create or update a contact by phone number `POST /contacts/upsert` Creates the contact with this phone number, or updates it if one exists, in one request. The number is matched the same way the do-not-call check matches numbers, so a contact entered without the `+` and country code in the console is found too. Fields you omit are left unchanged on an existing contact. The response is `201` when a contact was created and `200` when one was updated. This is the recommended way to sync contacts from your CRM. - Operation ID: `upsertContact` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "phone_number": "+919000000001", "name": "Asha Rao", "consent_granted": true, "custom_fields": { "policy_number": "PN-55012" } } ``` Response 200: ```json { "id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "object": "contact", "livemode": true, "phone_number": "+919000000001", "name": "Asha Rao", "language": "hi", "country_code": "IN", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012" }, "created_at": "2026-10-19T11:02:00.000Z" } ``` Response 201: ```json { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "phone_number": "+12025550100", "name": "Asha Rao", "language": "en", "country_code": "US", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012", "due_date": "2026-11-05" }, "created_at": "2026-10-21T08:30:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`contact_limit_reached`): ```json { "error": { "code": "contact_limit_reached", "message": "No more contacts can be created right now.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_limit_reached" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`custom_field_not_found`): ```json { "error": { "code": "custom_field_not_found", "message": "This custom field does not exist in the workspace.", "details": { "field": "policy_numbr" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/custom_field_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve a contact `GET /contacts/{contact_id}` Returns one contact, with its consent, do-not-call status and custom field values. The `ETag` header is the version to send as `If-Match` when you update it. - Operation ID: `getContact` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `contact_id` | path | yes | The contact id (`ct_...`). | Response 200: ```json { "id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "object": "contact", "livemode": true, "phone_number": "+919000000001", "name": "Asha Rao", "language": "hi", "country_code": "IN", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012" }, "created_at": "2026-10-19T11:02:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Update a contact `PATCH /contacts/{contact_id}` Changes the fields you send. `custom_fields` merges: listed fields are set, a null value clears a field, omitted fields are kept. `consent_granted` records consent (true) or its withdrawal (false) with the time and the API as source. The do-not-call status cannot be changed here; use the do-not-call endpoints. Requires `If-Match` with the contact's current `ETag`. - Operation ID: `updateContact` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `contact_id` | path | yes | The contact id (`ct_...`). | | `If-Match` | header | yes | The `ETag` from your most recent read of this resource. The update is refused if the resource has changed since. `*` matches any current version: the update is applied without the concurrency check. | Example request body: ```json { "consent_granted": false } ``` Response 200: ```json { "id": "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3", "object": "contact", "livemode": true, "phone_number": "+919000000001", "name": "Asha Rao", "language": "hi", "country_code": "IN", "consent_granted": true, "do_not_call": false, "custom_fields": { "policy_number": "PN-55012" }, "created_at": "2026-10-19T11:02:00.000Z" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`contact_already_exists`): ```json { "error": { "code": "contact_already_exists", "message": "A contact with this phone number already exists.", "details": { "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_already_exists" } } ``` Response 412 (`version_conflict`): ```json { "error": { "code": "version_conflict", "message": "The resource changed since you read it.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/version_conflict" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`custom_field_not_found`): ```json { "error": { "code": "custom_field_not_found", "message": "This custom field does not exist in the workspace.", "details": { "field": "policy_numbr" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/custom_field_not_found" } } ``` Response 428 (`precondition_required`): ```json { "error": { "code": "precondition_required", "message": "This update needs an If-Match header with the ETag of the version you read.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/precondition_required" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Delete a contact `DELETE /contacts/{contact_id}` Permanently deletes a contact, its custom field values and its consent history, and removes it from every campaign's recipient list (a campaign that has already started keeps calling the recipients its run began with). Calls to the contact remain in the call list. A contact on the do-not-call list cannot be deleted (`409 contact_on_dnc_list`), so a deletion never removes a do-not-call protection. - Operation ID: `deleteContact` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `contact_id` | path | yes | The contact id (`ct_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "object": "contact", "livemode": false, "deleted": true } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`contact_on_dnc_list`): ```json { "error": { "code": "contact_on_dnc_list", "message": "This contact is on the do-not-call list. Remove its do-not-call entry before deleting it.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_on_dnc_list" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/do-not-call-list # Do-not-call list Numbers that must never be called. Checked, in both modes, on every `POST /v1/calls`, when a campaign starts and when it resumes (the resume re-screens). A number added while a campaign is running is not removed from that run: pause and resume the campaign to re-screen it, or stop the campaign. ## List or look up do-not-call entries `GET /dnc-entries` Lists the do-not-call list: in test mode newest entry first; in live mode by the contact, newest first (live mode does not record when an entry was added). To check one number, pass `phone_number`: the result has one entry if the number is on the list and none if it is not, using the same matching as the call check (formatting is ignored). - Operation ID: `listDncEntries` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `phone_number` | query | no | Look up one number (E.164). | Response 200: ```json { "object": "list", "data": [ { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "phone_number": "+919000000003", "contact_id": "ct_5Qw8Er2Ty6Ui9Op3As7Df4", "created_at": null } ], "has_more": false, "next_cursor": null } ``` Response 200: ```json { "object": "list", "data": [], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Add a number to the do-not-call list `POST /dnc-entries` Adds a number to the workspace do-not-call list, in the key's mode. From then on calls to it are refused, campaigns leave it out when they start, and a paused campaign that still holds it cannot be resumed. A campaign that is already running is not screened again: pause and resume it (the resume is then refused while the number is a remaining recipient) or stop it. If a contact has this number, it is marked `do_not_call: true` and its consent is withdrawn. If no contact has it, a contact holding only the number is created so the number is visible in the console. Adding a number that is already on the list returns the existing entry with `200`. - Operation ID: `createDncEntry` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "phone_number": "+919000000003" } ``` Response 200: ```json { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "phone_number": "+919000000003", "contact_id": "ct_5Qw8Er2Ty6Ui9Op3As7Df4", "created_at": null } ``` Response 201: ```json { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "phone_number": "+919000000003", "contact_id": "ct_5Qw8Er2Ty6Ui9Op3As7Df4", "created_at": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 400 (`invalid_phone_number`): ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`contact_limit_reached`): ```json { "error": { "code": "contact_limit_reached", "message": "No more contacts can be created right now.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_limit_reached" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Remove a number from the do-not-call list `DELETE /dnc-entries/{dnc_entry_id}` Removes a number from the do-not-call list, so it can be called again. The removal is recorded in the workspace audit log with the API key that made it. Consent is not restored: if the workspace requires consent, record it again on the contact only if the person gives it. - Operation ID: `deleteDncEntry` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `dnc_entry_id` | path | yes | The do-not-call entry id (`dnc_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "dnc_4Ty8Ui2Op6As3Df9Gh7Jk5", "object": "dnc_entry", "livemode": true, "deleted": true } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/events # Events RelationsAI sends these events to the webhook endpoints you register. Every event has the same envelope (`id`, `object: "event"`, `type`, `created_at`, `livemode`, `api_version`, `data.object`). Delivery is at least once and unordered across resources, so deduplicate on the event `id`. See [Receiving webhooks](/guides/receiving-webhooks) and [Verifying webhook signatures](/guides/verifying-webhook-signatures). ## `call.ended` Sent when a call ends, whatever the outcome. `outcome`, `end_reason` and `duration_seconds` are known; the transcript, recording, analysis and charge may still be pending (see `call.settled`). In live mode it is sent once the end is observed, usually within seconds and at most a few minutes after the call ends. Delivered only to enabled endpoints subscribed to `call.ended`, in the event's mode, while webhooks are enabled for the workspace. ```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", "livemode": true, "status": "ended", "outcome": "completed", "end_reason": "hangup", "direction": "outbound", "source": "api", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "campaign_id": null, "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "from": "+918000000001", "to": "+919000000001", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": "2026-10-21T09:32:14.000Z", "duration_seconds": 118, "billable_minutes": null, "cost": null, "artifacts": { "transcript": { "state": "pending" }, "recording": { "state": "pending" }, "analysis": { "state": "pending" } }, "settled": false, "settled_at": null } } } ``` ## `call.settled` Sent once everything about a call is final: outcome, duration, transcript, recording, analysis and the charge. This can be several minutes after `call.ended`. Use it to sync final results to your systems. Delivered only to enabled endpoints subscribed to `call.settled`, in the event's mode, while webhooks are enabled for the workspace. ```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", "livemode": true, "status": "ended", "outcome": "completed", "end_reason": "hangup", "direction": "outbound", "source": "api", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "campaign_id": null, "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "from": "+918000000001", "to": "+919000000001", "metadata": { "crm_lead_id": "L-20931" }, "created_at": "2026-10-21T09:30:00.000Z", "ended_at": "2026-10-21T09:32:14.000Z", "duration_seconds": 118, "billable_minutes": 2, "cost": { "amount": 600, "currency": "INR" }, "artifacts": { "transcript": { "state": "ready" }, "recording": { "state": "ready" }, "analysis": { "state": "ready" } }, "settled": true, "settled_at": "2026-10-21T09:41:02.000Z" } } } ``` ## `campaign.started` Sent when a campaign run starts dialling, after a start request or when a start that was being confirmed is confirmed. Delivered only to enabled endpoints subscribed to `campaign.started`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_1Aq2Sw3De4Fr5Gt6Hy7Ju8", "object": "event", "type": "campaign.started", "created_at": "2026-10-20T08:05:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": null } } ``` ## `campaign.paused` Sent when a campaign is paused, by a request or automatically. `data.reason` says why, for example `budget_cap_reached` or `insufficient_balance`. A campaign paused automatically can be resumed once the cause is fixed. Delivered only to enabled endpoints subscribed to `campaign.paused`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_2Ws3Ed4Rf5Tg6Yh7Uj8Ik9", "object": "event", "type": "campaign.paused", "created_at": "2026-10-20T11:42:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "paused", "capabilities": { "start": true, "pause": false, "resume": true, "stop": true, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": "budget_cap_reached" } } ``` ## `campaign.resumed` Sent when a paused campaign resumes its run. Delivered only to enabled endpoints subscribed to `campaign.resumed`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_3Ed4Rf5Tg6Yh7Uj8Ik9Ol1", "object": "event", "type": "campaign.resumed", "created_at": "2026-10-20T12:10:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "running", "capabilities": { "start": false, "pause": true, "resume": false, "stop": true, "update": false, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": null } } ``` ## `campaign.stopped` Sent when a campaign is stopped. `data.reason` is `requested` for a stop request. Delivered only to enabled endpoints subscribed to `campaign.stopped`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_4Rf5Tg6Yh7Uj8Ik9Ol1Pm2", "object": "event", "type": "campaign.stopped", "created_at": "2026-10-20T15:00:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "stopped", "capabilities": { "start": true, "pause": false, "resume": false, "stop": false, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": "requested" } } ``` ## `campaign.completed` Sent when a campaign run has worked through every recipient. In live mode it can arrive up to about 15 minutes after the last call ends. Delivered only to enabled endpoints subscribed to `campaign.completed`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_5Tg6Yh7Uj8Ik9Ol1Pm2Nb3", "object": "event", "type": "campaign.completed", "created_at": "2026-10-20T16:20:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "completed", "capabilities": { "start": true, "pause": false, "resume": false, "stop": false, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": null } } ``` ## `campaign.failed` Sent when a campaign run ends in failure before working through its recipients. In live mode it can arrive up to about 15 minutes after the run ends. Retrieve the recipients list to see who was reached. Delivered only to enabled endpoints subscribed to `campaign.failed`, in the event's mode, while webhooks are enabled for the workspace. ```json { "id": "evt_6Yh7Uj8Ik9Ol1Pm2Nb3Vc4", "object": "event", "type": "campaign.failed", "created_at": "2026-10-20T16:20:00.000Z", "livemode": true, "api_version": "2026-10-21", "data": { "object": { "id": "cmp_7Wq3Er9Ty2Ui5Op8As4Df6", "object": "campaign", "livemode": true, "name": "November renewals", "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "dnc_screening": "enforced", "status": "failed", "capabilities": { "start": true, "pause": false, "resume": false, "stop": false, "update": true, "add_recipients": false }, "concurrency_limit": 5, "budget_cap": { "amount": 500000, "currency": "INR" }, "recipient_count": 1200, "progress": { "calls": 702, "connected": 451, "no_answer": 210, "failed": 41, "in_progress": 0 }, "spend": { "amount": 270600, "currency": "INR" }, "run_number": 1, "created_at": "2026-10-20T08:00:00.000Z" }, "reason": null } } ``` ## `webhook_endpoint.test` Sent only when you call POST /v1/webhook-endpoints/{webhook_endpoint_id}/test, and only to that endpoint (which must be enabled). It cannot be subscribed to. `data.object` is the endpoint, never its secret. Use it to check your signature verification. ```json { "id": "evt_3Df7Gh1Jk5Lz9Xc2Vb6Nm8", "object": "event", "type": "webhook_endpoint.test", "created_at": "2026-10-21T09:45:00.000Z", "livemode": false, "api_version": "2026-10-21", "data": { "object": { "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 } } } ``` --- Source: /api/phone-numbers # Phone numbers The workspace's phone numbers and which agents use them. Numbers are added and assigned in the console. ## List phone numbers `GET /phone-numbers` Lists the workspace's phone numbers and the agents that use them. Numbers are added and assigned in the console. A number is listed only when the workspace has proven it holds that number, so a number visible here can be used as a caller number. `outbound_agent_ids` are the agents that call from the number; a call without `phone_number_id` uses the agent's outbound number. `inbound_agent_id` answers calls to the number. Listed by number, in a stable order. - Operation ID: `listPhoneNumbers` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | Response 200: ```json { "object": "list", "data": [ { "id": "pn_6Vhm3sFrB5UpmXAnsvmDu", "object": "phone_number", "livemode": false, "number": "+12025550150", "label": "Sample number", "status": "active", "capabilities": { "outbound": true, "inbound": false }, "inbound_agent_id": null, "outbound_agent_ids": [ "agent_1ZltiWzxrXmMprGD6q63" ], "sample": true }, { "id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "object": "phone_number", "livemode": false, "number": "+918000000001", "label": "Collections line", "status": "active", "capabilities": { "outbound": true, "inbound": true }, "inbound_agent_id": null, "outbound_agent_ids": [ "agent_3hT9kQ2mVx7RbN4pLc8Wz1" ], "sample": false } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve a phone number `GET /phone-numbers/{phone_number_id}` Returns one phone number with its capabilities and agent assignments. There is no carrier or account information in this object. - Operation ID: `getPhoneNumber` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `phone_number_id` | path | yes | The phone number id (`pn_...`). | Response 200: ```json { "id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d", "object": "phone_number", "livemode": true, "number": "+918000000001", "label": "Collections line", "status": "active", "capabilities": { "outbound": true, "inbound": true }, "inbound_agent_id": null, "outbound_agent_ids": [ "agent_3hT9kQ2mVx7RbN4pLc8Wz1" ], "sample": false } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/recordings # Recordings ## Retrieve a call recording link `GET /calls/{call_id}/recording` Returns the recording's state and, when it is ready, a short-lived HTTPS link to the audio (about 15 minutes; see `expires_at`). The link needs no API key, so treat it like a password and do not log or store it; ask for a new one when you need it. Recordings are usually ready within a minute of the call ending. A call that was not answered has no recording (`state: absent`). - Operation ID: `getCallRecording` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `call_id` | path | yes | The call id (`call_...`). | Response 200: ```json { "object": "call_recording", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "url": "https://voice-api.relationsai.com/v1/recordings/rec_tok_Y2FsbF8ySGQ4S3M0THE3TXczTng5UHo1UmMx", "expires_at": "2026-10-21T10:00:00.000Z", "content_type": "audio/ogg" } ``` Response 200: ```json { "object": "call_recording", "livemode": false, "call_id": "call_9Tg5Yh2Uj8Ik4Ol7Pm3Nb6", "state": "absent", "simulated": false, "url": null, "expires_at": null, "content_type": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Download recording audio `GET /recordings/{recording_token}` Streams the audio of a recording. You do not build this URL yourself: it is the `url` returned by `GET /v1/calls/{call_id}/recording`. The token in the path is the credential, so no `Authorization` header is needed (it is ignored), and the link stops working at its `expires_at`. Scope: the key that calls `GET /v1/calls/{call_id}/recording` needs `recordings:read` to obtain the link; the download itself needs no key and no scope (`x-required-scopes` names the scope behind the link). Supports `Range` requests for seeking; a range that cannot be served is answered with the whole file (200). - Operation ID: `downloadRecording` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `recording_token` | path | yes | The opaque token from a recording `url`. It is the credential for this request. | | `Range` | header | no | A byte range, for example `bytes=0-65535`. | Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 410 (`media_link_expired`): ```json { "error": { "code": "media_link_expired", "message": "This recording link has expired. Retrieve the recording again for a new link.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/media_link_expired" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/transcripts # Transcripts ## Retrieve a call transcript `GET /calls/{call_id}/transcript` Returns the conversation turn by turn, verbatim. Transcripts are produced after the call ends (live transcripts are not available); until then `state` is `pending` and `turns` is empty. A call that was not connected has `state: absent`. Reading the transcript never changes the call. - Operation ID: `getCallTranscript` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `call_id` | path | yes | The call id (`call_...`). | Response 200: ```json { "object": "call_transcript", "livemode": true, "call_id": "call_2Hd8Ks4Lq7Mw3Nx9Pz5Rc1", "state": "ready", "simulated": false, "turns": [ { "index": 0, "speaker": "agent", "text": "Hello, this is an AI assistant calling from Acme Lending. Am I speaking with Asha?", "start_ms": 400, "end_ms": 4900 }, { "index": 1, "speaker": "caller", "text": "Yes, speaking.", "start_ms": 5600, "end_ms": 6500 } ] } ``` Response 200: ```json { "object": "call_transcript", "livemode": true, "call_id": "call_6Vb3Nm8Qw2Er5Ty9Ui4Op7", "state": "pending", "simulated": false, "turns": [] } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/webhook-endpoints # Webhook endpoints Receive signed event notifications on your server. ## List webhook endpoints `GET /webhook-endpoints` Lists the webhook endpoints of the key's mode, newest first. Secrets are never listed, only their last four characters. - Operation ID: `listWebhookEndpoints` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | Response 200: ```json { "object": "list", "data": [ { "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 } ], "has_more": false, "next_cursor": null } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Create a webhook endpoint `POST /webhook-endpoints` Registers an HTTPS URL to receive events of the key's mode. The response contains the signing secret (`whsec_...`) **once**; store it to verify signatures. A replay with the same Idempotency-Key returns the same endpoint with `secret: null` (the secret is never stored in readable form; if it was lost, rotate it). Every delivery is a POST with the event as JSON and these headers: `RelationsAI-Signature: t=,v1=." with the secret>`, `RelationsAI-Event-Id`, `RelationsAI-Event-Type`, `RelationsAI-Delivery-Id` and `User-Agent: RelationsAI-Webhooks/1.0`. Reject a signature older than 5 minutes. Answer with any 2xx within 10 seconds. Anything else is retried after about 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours, then marked `failed`. Delivery is at-least-once and unordered. Redirects are not followed. The URL must be public HTTPS on port 443. Back-pressure: at most 2 attempts are in flight to one endpoint at a time; after 5 failed attempts in a row an endpoint is paused (1 minute, doubling up to 1 hour) and its waiting deliveries are attempted after the pause, without using up their attempts; one success resumes it. The 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. - Operation ID: `createWebhookEndpoint` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "url": "https://example.com/webhooks/relationsai", "description": "CRM sync (test)", "events": [ "call.ended", "call.settled", "campaign.completed" ] } ``` Response 201: ```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_test_7c2f9a4b1e8d3c6a5f0b2e9d4c7a1x9Qa" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`webhooks_not_enabled`): ```json { "error": { "code": "webhooks_not_enabled", "message": "Webhook delivery is not available yet.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhooks_not_enabled" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`webhook_endpoint_limit_reached`): ```json { "error": { "code": "webhook_endpoint_limit_reached", "message": "This workspace already has the maximum number of webhook endpoints for this mode.", "details": { "limit": 16 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_endpoint_limit_reached" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`webhook_url_not_allowed`): ```json { "error": { "code": "webhook_url_not_allowed", "message": "This URL cannot receive webhooks.", "details": { "reason": "private_address" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_url_not_allowed" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retrieve a webhook endpoint `GET /webhook-endpoints/{webhook_endpoint_id}` Returns one webhook endpoint with a summary of its most recent delivery. - Operation ID: `getWebhookEndpoint` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | Response 200: ```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": { "status": "succeeded", "attempted_at": "2026-10-21T09:31:02.000Z", "response_status": 200 } } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Update a webhook endpoint `PATCH /webhook-endpoints/{webhook_endpoint_id}` Changes the URL, description, subscribed events or status. Disabling an endpoint stops new deliveries to it; events that occur while it is disabled are not delivered later. - Operation ID: `updateWebhookEndpoint` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | Example request body: ```json { "status": "disabled" } ``` Response 200: ```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": { "status": "succeeded", "attempted_at": "2026-10-21T09:31:02.000Z", "response_status": 200 } } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 422 (`webhook_url_not_allowed`): ```json { "error": { "code": "webhook_url_not_allowed", "message": "This URL cannot receive webhooks.", "details": { "reason": "private_address" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_url_not_allowed" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Delete a webhook endpoint `DELETE /webhook-endpoints/{webhook_endpoint_id}` Deletes the endpoint. Pending deliveries to it are cancelled. This cannot be undone. - Operation ID: `deleteWebhookEndpoint` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Response 200: ```json { "id": "we_9Pl3Ok7Ij2Uh6Yg4Tf8Rd1", "object": "webhook_endpoint", "livemode": false, "deleted": true } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Rotate a webhook signing secret `POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret` Issues a new signing secret, returned once. During the grace period every delivery carries two `v1=` signatures, one per secret, so you can deploy the new secret without missing events; after it, only the new secret signs. At most two secrets ever sign a delivery. Rotating again while a grace period runs: - `grace_period_hours: 0` is always allowed and retires EVERY previous secret immediately: use it when a secret may have leaked, and deploy the new one at once. - a grace period above 0 keeps the OLDEST secret still signing (the one your receiver most likely uses) until its original grace end, discards the secret issued by the rotation in between, and issues a new one. Use it when the response to a rotation was lost. A replay of this request with the same Idempotency-Key returns the endpoint with `secret: null`: the secret is shown only in the first response and is never stored in readable form. To recover a secret you did not receive, rotate again (with a grace period, as above). - Operation ID: `rotateWebhookEndpointSecret` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | Example request body: ```json { "grace_period_hours": 24 } ``` Response 200: ```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_test_7c2f9a4b1e8d3c6a5f0b2e9d4c7a1x9Qa" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_json`): ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 413 (`payload_too_large`): ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` Response 415 (`unsupported_media_type`): ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Send a test event `POST /webhook-endpoints/{webhook_endpoint_id}/test` Creates a `webhook_endpoint.test` event and delivers it to this endpoint only, signed like any other event. The response is the delivery, usually `pending`; read the deliveries list to see the result. - Operation ID: `sendWebhookEndpointTestEvent` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | 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" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`webhooks_not_enabled`): ```json { "error": { "code": "webhooks_not_enabled", "message": "Webhook delivery is not available yet.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhooks_not_enabled" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`webhook_endpoint_disabled`): ```json { "error": { "code": "webhook_endpoint_disabled", "message": "This webhook endpoint is disabled.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_endpoint_disabled" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## List deliveries `GET /webhook-endpoints/{webhook_endpoint_id}/deliveries` Lists deliveries to this endpoint, newest first, for troubleshooting. Deliveries are kept for 30 days. - Operation ID: `listWebhookDeliveries` Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_endpoint_id` | path | yes | The webhook endpoint id (`we_...`). | | `limit` | query | no | Maximum number of objects to return, from 1 to 100. Defaults to 25. | | `cursor` | query | no | The `next_cursor` of the previous page. Send it with exactly the same filters as the request that returned it; a changed filter set is `400 invalid_cursor`. | | `status` | query | no | Only deliveries in this status. | Response 200: ```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 } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_cursor`): ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` ## Retry a delivery `POST /webhook-deliveries/{webhook_delivery_id}/retry` Schedules an immediate new attempt of a `failed` or `retrying` delivery. The event and its id are unchanged, so your duplicate check still works. - Operation ID: `retryWebhookDelivery` - Accepts an optional `Idempotency-Key` header: send one to make a retry of the same request safe to repeat. Parameters: | Name | In | Required | Description | | --- | --- | --- | --- | | `webhook_delivery_id` | path | yes | The webhook delivery id (`wd_...`). | | `Idempotency-Key` | header | no | Optional. A unique key for this operation, 1 to 255 printable ASCII characters with no spaces (a UUID v4 is ideal). With it, a retry of the same request (same key, same body) returns the stored answer and never runs the action twice; reuse the same key for every retry, it is kept for 24 hours. Without it, every request runs: a retry after a timeout can repeat the action, so check the state with a GET before retrying. | 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" } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 400 (`invalid_idempotency_key`): ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 403 (`webhooks_not_enabled`): ```json { "error": { "code": "webhooks_not_enabled", "message": "Webhook delivery is not available yet.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhooks_not_enabled" } } ``` Response 404 (`resource_not_found`): ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` Response 409 (`idempotency_key_reused`): ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` Response 409 (`idempotency_key_in_progress`): ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` Response 409 (`idempotency_outcome_unknown`): ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` Response 409 (`webhook_endpoint_disabled`): ```json { "error": { "code": "webhook_endpoint_disabled", "message": "This webhook endpoint is disabled.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_endpoint_disabled" } } ``` Response 409 (`webhook_delivery_not_retryable`): ```json { "error": { "code": "webhook_delivery_not_retryable", "message": "This delivery cannot be retried in its current status.", "details": { "status": "succeeded" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_delivery_not_retryable" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /api/workspace # Workspace The workspace and API key a request is authenticated as, and what they can do. ## Retrieve the workspace `GET /workspace` Returns the workspace your API key belongs to, the key itself (name, mode, scopes, expiry), what the workspace can do through the API (`capabilities`) and the limits that apply (`limits`). Call this first: it is the quickest way to check that a key works, which mode it is in and which scopes it has. It needs no scope beyond the `workspace:read` scope every key has, and it never changes anything. - Operation ID: `getWorkspace` Response 200: ```json { "id": "ws_7Hk2Lm9QxR4tB8vN3cZp6W", "object": "workspace", "livemode": false, "name": "Acme Lending", "timezone": "Asia/Kolkata", "currency": "INR", "api_key": { "id": "key_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "name": "CRM sync (test)", "environment": "test", "scopes": [ "workspace:read", "agents:read", "numbers:read", "calls:read", "calls:write", "transcripts:read", "recordings:read", "analysis:read" ], "created_at": "2026-10-20T08:15:00.000Z", "expires_at": null }, "capabilities": { "campaign_pause": true, "campaign_resume": true, "call_ringing_status": false, "live_transcript": false, "sentiment_analysis": false, "end_call": false, "agent_editing": false, "inbound_calls": false, "webhooks": true }, "limits": { "rate_limits": { "read": { "per_key_per_minute": 600, "per_workspace_per_minute": 1200 }, "write": { "per_key_per_minute": 120, "per_workspace_per_minute": 240 }, "dial": { "per_key_per_minute": null, "per_workspace_per_minute": 30 } }, "daily_call_limit": null, "campaign_max_concurrency": 10 } } ``` Response 200: ```json { "id": "ws_7Hk2Lm9QxR4tB8vN3cZp6W", "object": "workspace", "livemode": true, "name": "Acme Lending", "timezone": "Asia/Kolkata", "currency": "INR", "api_key": { "id": "key_9Bv2Cx7Zl4Kj8Hg3Fd6Sa5", "name": "CRM sync", "environment": "live", "scopes": [ "workspace:read", "calls:read", "calls:write" ], "created_at": "2026-10-21T06:00:00.000Z", "expires_at": "2027-01-19T06:00:00.000Z" }, "capabilities": { "campaign_pause": true, "campaign_resume": true, "call_ringing_status": false, "live_transcript": false, "sentiment_analysis": false, "end_call": false, "agent_editing": false, "inbound_calls": false, "webhooks": false }, "limits": { "rate_limits": { "read": { "per_key_per_minute": 600, "per_workspace_per_minute": 1200 }, "write": { "per_key_per_minute": 120, "per_workspace_per_minute": 240 }, "dial": { "per_key_per_minute": null, "per_workspace_per_minute": 30 } }, "daily_call_limit": 500, "campaign_max_concurrency": 10 } } ``` Response 400 (`validation_error`): ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` Response 401 (`authentication_required`): ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` Response 401 (`invalid_api_key`): ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` Response 401 (`api_key_expired`): ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` Response 401 (`api_key_revoked`): ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` Response 401 (`legacy_api_key_not_supported`): ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` Response 403 (`insufficient_scope`): ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` Response 403 (`insufficient_permissions`): ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` Response 403 (`api_access_not_enabled`): ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` Response 403 (`ip_not_allowed`): ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` Response 403 (`live_key_not_allowed_in_browser`): ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` Response 429 (`rate_limited`): ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` Response 500 (`internal_error`): ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` Response 503 (`service_unavailable`): ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_4k7m2qat5xvz3c6vqnwpbdfh", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` --- Source: /errors/account_inactive # account_inactive - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: yes **Workspace account inactive.** The workspace trial has ended or the account has been suspended. Reads keep working; calls and campaign dialling are refused. ## What it means The API returns HTTP 403 with `error.code` set to `account_inactive`. The default message is: "This workspace cannot place calls because its account is not active." ## Common causes - `trial_expired`: the free trial ended. - `suspended`: the account was suspended. ## How to identify it Check the response status (`403`) and `error.code` (`account_inactive`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - A workspace admin can upgrade the plan in the console, or contact support about a suspension. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support For a suspended account, write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "account_inactive", "message": "This workspace cannot place calls because its account is not active.", "details": { "reason": "trial_expired" }, "request_id": "req_eliwakvluokh74zzpoeg6nnl", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/account_inactive" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/agent_not_ready # agent_not_ready - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes **Agent not ready to call.** The agent exists but cannot place calls yet. `details.reasons` lists every reason found, using the same codes as the agent readiness endpoint. Agents are created, configured, published and switched on in the console. ## What it means The API returns HTTP 422 with `error.code` set to `agent_not_ready`. The default message is: "This agent cannot place calls right now." ## Common causes - `agent_paused`: the agent is switched off in the console. - `agent_not_connected`: the agent's setup is not finished (open it in the console and save it once). - `agent_not_published`: the agent has never been published, or its live version is paused. - `no_outbound_number`: no phone number in the workspace is assigned to this agent for outbound calls. ## How to identify it Check the response status (`422`) and `error.code` (`agent_not_ready`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Call GET /v1/agents/{agent_id}/readiness to see every reason. - In the console, switch the agent on, publish it, and assign it an outbound phone number. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If readiness reports `agent_not_connected` after saving the agent in the console, write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "agent_not_ready", "message": "This agent cannot place calls right now.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "reasons": [ "agent_not_published" ] }, "request_id": "req_dlph75zpjfcr7h3qgqtn6xo2", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/agent_not_ready" } } ``` ## Incorrect and corrected request Incorrect: The agent is switched off (or unpublished) in the console. ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001" } ``` Corrected: The same request, sent after the agent was switched on and published in the console (GET /v1/agents/{agent_id}/readiness reports ready: true). Nothing in the request itself changes. ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/api_access_not_enabled # api_access_not_enabled - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: yes **API access not enabled.** The v1 API is in private preview and must be enabled for each workspace. ## What it means The API returns HTTP 403 with `error.code` set to `api_access_not_enabled`. The default message is: "API access is not enabled for this workspace." ## Common causes - The workspace has not been enabled for the v1 API yet. ## How to identify it Check the response status (`403`) and `error.code` (`api_access_not_enabled`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Write to support@relationsai.com to request access for the workspace. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support Write to [support@relationsai.com](mailto:support@relationsai.com) to request access, quoting the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "api_access_not_enabled", "message": "API access is not enabled for this workspace.", "details": {}, "request_id": "req_vrsfwbas6p3uz55gn4huiryv", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_access_not_enabled" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/api_key_expired # api_key_expired - HTTP status: 401 - Retry: No - Execution: not started - Admin needed: yes **API key expired.** The key reached its expiry date, or its rotation grace period ended after it was replaced. ## What it means The API returns HTTP 401 with `error.code` set to `api_key_expired`. The default message is: "This API key has expired." ## Common causes - The expiry chosen when the key was created has passed. - The key was rotated and the grace period is over. ## How to identify it Check the response status (`401`) and `error.code` (`api_key_expired`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Switch to the replacement key, or ask a workspace admin to create a new key in the console. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "api_key_expired", "message": "This API key has expired.", "details": {}, "request_id": "req_cdcjizplnp5fpkfypcsmuhqc", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_expired" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/api_key_revoked # api_key_revoked - HTTP status: 401 - Retry: No - Execution: not started - Admin needed: yes **API key revoked.** A workspace admin revoked the key. Revocation is immediate and cannot be undone. ## What it means The API returns HTTP 401 with `error.code` set to `api_key_revoked`. The default message is: "This API key has been revoked." ## Common causes - The key was revoked in the console. ## How to identify it Check the response status (`401`) and `error.code` (`api_key_revoked`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Ask a workspace admin for a new key. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "api_key_revoked", "message": "This API key has been revoked.", "details": {}, "request_id": "req_c5vj3dtz7oej36v5oh4dzyu3", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/api_key_revoked" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/authentication_required # authentication_required - HTTP status: 401 - Retry: No - Execution: not started - Admin needed: no **Authentication required.** The request carried no `Authorization` header, or the header was not of the form `Bearer <key>`. ## What it means The API returns HTTP 401 with `error.code` set to `authentication_required`. The default message is: "Send your API key as a Bearer token in the Authorization header." ## Common causes - No `Authorization` header. - A scheme other than `Bearer`. - An empty token. ## How to identify it Check the response status (`401`) and `error.code` (`authentication_required`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Send `Authorization: Bearer rai_test_...` (test mode) or `Authorization: Bearer rai_live_...` (live mode). ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "authentication_required", "message": "Send your API key as a Bearer token in the Authorization header.", "details": {}, "request_id": "req_stwgrknsunh4rsqs7632uj2u", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/authentication_required" } } ``` ## Incorrect and corrected request Incorrect: The key was sent without the `Bearer ` scheme. ```http GET /v1/workspace Authorization: rai_test_YOUR_TEST_KEY ``` Corrected: The key as a Bearer token. ```http GET /v1/workspace Authorization: Bearer rai_test_YOUR_TEST_KEY ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/call_not_accepted # call_not_accepted - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Call not accepted.** The call was definitively refused before it started, for a reason not covered by a more specific code. The refusal is final for this request. A call record with `status: ended`, `outcome: failed` and `end_reason: refused` may appear in the call list. ## What it means The API returns HTTP 422 with `error.code` set to `call_not_accepted`. The default message is: "The call could not be placed. Nothing was dialled and nothing was charged." ## Common causes - The destination cannot be reached from this workspace's numbers. - The call could not be started right now for a reason not covered by a more specific code. ## How to identify it Check the response status (`422`) and `error.code` (`call_not_accepted`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Check the destination number and the agent's outbound number. If it keeps happening, contact support with the request_id. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If calls to valid numbers keep being refused, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "call_not_accepted", "message": "The call could not be placed. Nothing was dialled and nothing was charged.", "details": {}, "request_id": "req_g5oloudttmdku5qtzs3h4wg4", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/call_not_accepted" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/calling_capacity_reached # calling_capacity_reached - HTTP status: 429 - Retry: After Retry-After - Execution: not started - Admin needed: no **Calling capacity reached.** Call starts are paced. This is separate from your API rate limit: the request was valid, but no capacity was free to start the call, so nothing was dialled. ## What it means The API returns HTTP 429 with `error.code` set to `calling_capacity_reached`. The default message is: "Too many calls are being started right now. Retry after the number of seconds in the Retry-After header." ## Common causes - A burst of call starts above the pace calls can be started at. - The workspace is already running its maximum number of simultaneous calls. ## How to identify it Check the response status (`429`) and `error.code` (`calling_capacity_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). Spread call starts out, or use a campaign for volume. ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was dialled. Retry after the wait (with the SAME key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If this happens at low volume, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "calling_capacity_reached", "message": "Too many calls are being started right now. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_lby4x2delf27vubq7zxttem6", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/calling_capacity_reached" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/calling_not_configured # calling_not_configured - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes **Calling not set up.** The workspace cannot place calls until a telephony account is connected and a phone number is assigned to an agent for outbound calls. `details.reason` says which step is missing. ## What it means The API returns HTTP 422 with `error.code` set to `calling_not_configured`. The default message is: "Calling is not set up for this workspace yet." ## Common causes - `no_telephony_account`: no telephony account is connected. - `no_phone_number`: no phone number has been added. - `no_outbound_assignment`: no number is assigned to an agent for outbound calls. - `not_enabled`: outbound calling has not been enabled for the workspace. ## How to identify it Check the response status (`422`) and `error.code` (`calling_not_configured`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Finish calling setup in the console (Numbers), then retry. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If `details.reason` is `not_enabled`, write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "calling_not_configured", "message": "Calling is not set up for this workspace yet.", "details": { "reason": "no_outbound_assignment" }, "request_id": "req_jfrwwfk6gd2a7vpxhhiwwztf", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/calling_not_configured" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_budget_reached # campaign_budget_reached - HTTP status: 402 - Retry: No - Execution: not started - Admin needed: no **Campaign budget reached.** The campaign's spend has reached its `budget_cap`. It will not dial again until the cap is raised or removed. ## What it means The API returns HTTP 402 with `error.code` set to `campaign_budget_reached`. The default message is: "This campaign has reached its budget cap." ## Common causes - Spend on this campaign reached `budget_cap`. ## How to identify it Check the response status (`402`) and `error.code` (`campaign_budget_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Raise or clear `budget_cap` with PATCH /v1/campaigns/{campaign_id}, then start or resume. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_budget_reached", "message": "This campaign has reached its budget cap.", "details": { "budget_cap": { "amount": 500000, "currency": "INR" } }, "request_id": "req_bfxvre5lnjal5eiesdlhmcze", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_budget_reached" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_dnc_override_enabled # campaign_dnc_override_enabled - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: yes > **A compliance outcome, not an error to fix:** This refusal protects the person you tried to call. Record it and do not call them now. Never resend the request, remove a do-not-call entry or record consent just to make a call go through. See [Consent and DNC](/guides/consent-and-dnc). **Campaign calls numbers on the do-not-call list.** A campaign's do-not-call screening can be switched off only in the console, by a person (the campaign's `dnc_screening` is then `disabled_in_console`). The API never starts or resumes such a campaign, so no API key can place calls to numbers on the do-not-call list. Nothing was dialled. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_dnc_override_enabled`. The default message is: "This campaign is set in the console to also call contacts on the do-not-call list, so it cannot be started or resumed through the API." ## Common causes - The campaign's console setting "also call contacts on the Do-Not-Call list" is on. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_dnc_override_enabled`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## What to do - Ask a workspace admin to switch the setting off in the console, then start or resume it through the API. - Or start it from the console, where that decision is made. ## Retry guidance Do not resend the request, and do not change settings, to get around this refusal. A new request is appropriate only when the rule genuinely no longer applies: the person asked to be called or gave consent, or the permitted calling window has started. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency No call was placed. If you sent an Idempotency-Key, the refusal is not stored under it. If the rule later stops applying (for example the person gives consent), place a new call (with a new key, if you use one). ## Who can fix it Nobody should change data to make this call go through. Remove a do-not-call entry or record consent only when the person asked you to, and keep a record of who asked and when; these changes are audited. See [Consent and DNC](/guides/consent-and-dnc). ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_dnc_override_enabled", "message": "This campaign is set in the console to also call contacts on the do-not-call list, so it cannot be started or resumed through the API.", "details": {}, "request_id": "req_hfkycq2q7fcupn5qceq3rdxs", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_dnc_override_enabled" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_has_no_recipients # campaign_has_no_recipients - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Campaign has no recipients.** A campaign needs at least one recipient before it can start. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_has_no_recipients`. The default message is: "This campaign has no recipients to call." ## Common causes - No recipients were added. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_has_no_recipients`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Add recipients with POST /v1/campaigns/{campaign_id}/recipients, then start it. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_has_no_recipients", "message": "This campaign has no recipients to call.", "details": {}, "request_id": "req_kuitjbjm7koe3f5m6z2wnbff", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_has_no_recipients" } } ``` ## Incorrect and corrected request Incorrect: The campaign has no recipients. ```http POST /v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/start ``` Corrected: Add recipients while it is a draft, then start it with a new Idempotency-Key. ```http POST /v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients { "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1" ] } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_not_resumable # campaign_not_resumable - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Campaign cannot be resumed.** The paused run can no longer continue (for example it ended while paused, or it began before a change to how the workspace places calls). Starting the campaign again begins a new run that calls every recipient of the previous run again, including people already reached, and charges for those calls. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_not_resumable`. The default message is: "This campaign cannot be resumed. Start it again to begin a new run." ## Common causes - The paused run finished or was cancelled. - The workspace calling configuration changed since the run began. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_not_resumable`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - To call only the people not reached yet, list the recipients whose status is still `pending`, create a new campaign with those contacts, and start it. - Or use POST /v1/campaigns/{campaign_id}/start to begin a new run over every recipient again. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If a start is also refused, write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_not_resumable", "message": "This campaign cannot be resumed. Start it again to begin a new run.", "details": {}, "request_id": "req_fqpqnooutievlvaarbyqcg2m", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_not_resumable" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_operation_in_progress # campaign_operation_in_progress - HTTP status: 409 - Retry: After Retry-After - Execution: not started - Admin needed: no **Another start or resume is in progress.** Only one start or resume runs per campaign at a time. Another request holds the campaign right now. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_operation_in_progress`. The default message is: "This campaign is being started or resumed by another request. Retry shortly." ## Common causes - Two clients started or resumed the same campaign at the same moment. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_operation_in_progress`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds, retrieve the campaign, and act on its new status. ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done by this request. Retry after the wait (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_operation_in_progress", "message": "This campaign is being started or resumed by another request. Retry shortly.", "details": {}, "request_id": "req_rg4otkxbmsqp5uhol6deftiv", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_operation_in_progress" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_outcome_unknown # campaign_outcome_unknown - HTTP status: 409 - Retry: After Retry-After - Execution: may have started - Admin needed: no **Campaign start or resume not confirmed.** A start or resume of this campaign was sent and its confirmation did not arrive, or it failed after it was sent. Dialling may have begun. The API never sends it again on a guess. If you sent an Idempotency-Key, this answer is stored under it and a retry with the same key returns it again; without a key, a retry is a new start or resume request, so check the campaign first. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_outcome_unknown`. The default message is: "The request was sent but its outcome is not confirmed yet. Check the campaign before trying again." ## Common causes - A start or resume timed out or failed after it was sent. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_outcome_unknown`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds, then retrieve the campaign: a `running` status means it started. Do not start or resume it again before you have checked. - If it is not running and you want to try again, send the action again (with a NEW Idempotency-Key if you use keys): the campaign refuses a second dial of the same run, so recipients are not dialled twice. - If `details.needs_support` is true, the outcome cannot be resolved automatically; contact support before acting. ## Retry guidance Wait for the number of seconds in the `Retry-After` header. For a write, read the resource first to find out whether the change was applied before you send anything again. ## Did anything happen? The work may have started before this error was returned. Do not assume nothing happened: read the resource (for a call, list calls filtered by `to` and `created_after`; for a campaign, retrieve it) before you send anything again. A charge may have been recorded for work that started; check the call or campaign before you act. ## Idempotency With a key: stored under it, so a retry with the same key returns this answer again and sends nothing. Without a key nothing is stored and a retry is a new attempt. Either way, read the campaign state first; a new attempt is refused by the campaign if the run already dialled. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support When `details.needs_support` is true, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_outcome_unknown", "message": "The request was sent but its outcome is not confirmed yet. Check the campaign before trying again.", "details": { "action": "start", "needs_support": false }, "request_id": "req_hsbfd6flzkbvvuhxjb7repju", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_outcome_unknown" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_pause_not_confirmed # campaign_pause_not_confirmed - HTTP status: 409 - Retry: Yes, with backoff - Execution: may have started - Admin needed: no **Pause not confirmed.** The campaign is recorded as `paused` only once the telephony network confirms it. It did not, so the campaign status is unchanged and dialling may continue. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_pause_not_confirmed`. The default message is: "The pause was requested but not confirmed. The campaign may still be dialling." ## Common causes - The pause did not take effect in time. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_pause_not_confirmed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Retry the pause (with the same Idempotency-Key if you sent one; a repeated pause is harmless), then retrieve the campaign. If it is still running, use stop. ## Retry guidance Use exponential backoff (for example 1 s, 2 s, 4 s, up to about a minute). For a write, read the resource first to find out whether the change was applied before you send anything again. ## Did anything happen? The work may have started before this error was returned. Do not assume nothing happened: read the resource (for a call, list calls filtered by `to` and `created_after`; for a campaign, retrieve it) before you send anything again. A charge may have been recorded for work that started; check the call or campaign before you act. ## Idempotency Retry with the SAME key if you sent one; without a key a retry is simply another pause, which is harmless. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If repeated pauses are not confirmed, stop the campaign and write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_pause_not_confirmed", "message": "The pause was requested but not confirmed. The campaign may still be dialling.", "details": {}, "request_id": "req_vswznd7bcxaukatwznrlp2z7", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/campaign_pause_not_confirmed" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_recipients_blocked # campaign_recipients_blocked - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Recipients now blocked by compliance.** Before resuming, every recipient the run could still dial is screened again against the do-not-call list, consent and the per-number daily limit. If any is now blocked, nothing is resumed and the campaign stays `paused`: a resume continues an existing run and cannot drop recipients from it. `details` counts the blocked recipients by reason and lists up to 100 of their contact ids. Stopping and starting this campaign again is NOT the way on: a new start calls every recipient of the previous run again, including people already reached, and charges for those calls. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_recipients_blocked`. The default message is: "Some recipients of this campaign are now blocked by compliance rules, so it was not resumed." ## Common causes - A recipient was added to the do-not-call list after the campaign started. - A recipient withdrew consent. - A number reached its daily call limit. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_recipients_blocked`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - List the campaign's recipients with `status=pending` (the people not reached yet), create a new campaign with those contacts minus the blocked ones in `details.contact_ids`, and start it. - Then stop this campaign. Stopping does not hang up calls already in progress. - If a contact was marked by mistake, correct it (remove the do-not-call entry or record consent only if it was really given) and resume. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Refused for compliance; nothing was dialled and the refusal is not stored under the Idempotency-Key. Do not retry: resending the same call (with any key) is refused again until the cause has legitimately changed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_recipients_blocked", "message": "Some recipients of this campaign are now blocked by compliance rules, so it was not resumed.", "details": { "blocked_total": 2, "by_reason": { "dnc_listed": 1, "consent_required": 1 }, "contact_ids": [ "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1", "ct_2Mh7Yd4Qs8Fv1Kc6Bx9Tn3" ], "contact_ids_truncated": false }, "request_id": "req_e42tftykifj3epsrjh37r2ei", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_blocked" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_recipients_suppressed # campaign_recipients_suppressed - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Every recipient is suppressed.** At start, recipients on the do-not-call list, without required consent or at their daily limit are left out. When that leaves nobody, the start is refused. ## What it means The API returns HTTP 422 with `error.code` set to `campaign_recipients_suppressed`. The default message is: "Every recipient of this campaign is blocked by your compliance settings. Nothing was dialled." ## Common causes - All recipients are on the do-not-call list, lack consent, or reached their daily limit. ## How to identify it Check the response status (`422`) and `error.code` (`campaign_recipients_suppressed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - This is a compliance outcome: do not retry; an unchanged start is refused again. - Add recipients who can be called. Record consent or remove a do-not-call entry only when the person really asked, and keep a record of it. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Refused for compliance; nothing was dialled and the refusal is not stored under the Idempotency-Key. Do not retry: resending the same call (with any key) is refused again until the cause has legitimately changed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_recipients_suppressed", "message": "Every recipient of this campaign is blocked by your compliance settings. Nothing was dialled.", "details": {}, "request_id": "req_vaip2hi2rkfqn2eguaai5ycy", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_suppressed" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_recipients_unavailable # campaign_recipients_unavailable - HTTP status: 503 - Retry: After Retry-After - Execution: not started - Admin needed: no **Recipients could not be checked.** Starting again or resuming re-screens recipients. The recipient list could not be read completely, so the API refused rather than dial anyone unscreened. ## What it means The API returns HTTP 503 with `error.code` set to `campaign_recipients_unavailable`. The default message is: "The campaign's recipients could not be read for screening right now, so nothing was dialled." ## Common causes - A temporary failure reading the recipient list. ## How to identify it Check the response status (`503`) and `error.code` (`campaign_recipients_unavailable`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was dialled. Retry (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If it persists for more than a few minutes, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_recipients_unavailable", "message": "The campaign's recipients could not be read for screening right now, so nothing was dialled.", "details": {}, "request_id": "req_sgqkigvfg7na6jm4ot2nnocl", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/campaign_recipients_unavailable" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_state_conflict # campaign_state_conflict - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Campaign is in the wrong state.** Each action is allowed only in some statuses (see the campaign lifecycle). `details.status` is the status the campaign was in and `details.action` the action refused. The campaign `capabilities` object says which actions are allowed right now. ## What it means The API returns HTTP 409 with `error.code` set to `campaign_state_conflict`. The default message is: "This action is not allowed in the campaign's current status." ## Common causes - Pausing a campaign that is not running, or resuming one that is not paused. - Editing a running campaign. - Adding recipients to a campaign that has already been started. - Starting a campaign that is already running. ## How to identify it Check the response status (`409`) and `error.code` (`campaign_state_conflict`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Retrieve the campaign, check `status` and `capabilities`, and choose an allowed action. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_state_conflict", "message": "This action is not allowed in the campaign's current status.", "details": { "status": "running", "action": "update" }, "request_id": "req_e2x4ghsa6ap33uks6dy3icuy", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_state_conflict" } } ``` ## Incorrect and corrected request Incorrect: The campaign is a `draft`: there is nothing to pause. ```http POST /v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/pause ``` Corrected: Read `status` and `capabilities`, then send an action that is allowed (here: start). ```http GET /v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1 ``` ## Operations that can return this error - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/campaign_too_large # campaign_too_large - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Campaign too large.** A single run can carry a limited number of recipients, and a resume can re-screen a limited number of remaining recipients. Retrying will not help: split the audience across several campaigns. ## What it means The API returns HTTP 422 with `error.code` set to `campaign_too_large`. The default message is: "This campaign has too many recipients to start in one run." ## Common causes - The recipient list is larger than one run accepts (start). - Too many recipients remain to re-screen before a resume (resume). ## How to identify it Check the response status (`422`) and `error.code` (`campaign_too_large`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Start: split recipients across several campaigns. - Resume: list the recipients still `pending`, create new campaigns of them in smaller parts, start those, and stop this campaign (stopping does not hang up calls in progress). ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "campaign_too_large", "message": "This campaign has too many recipients to start in one run.", "details": {}, "request_id": "req_gis2j6iczk7wnvfjsz2ll7uy", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/campaign_too_large" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/consent_required # consent_required - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no > **A compliance outcome, not an error to fix:** This refusal protects the person you tried to call. Record it and do not call them now. Never resend the request, remove a do-not-call entry or record consent just to make a call go through. See [Consent and DNC](/guides/consent-and-dnc). **Consent required.** The workspace's compliance settings require recorded consent before calling, and the destination matches a contact whose `consent_granted` is false. The rule applies only to numbers that match a contact: a number with no contact record is not checked for consent and is called (the do-not-call list, calling hours and the per-number daily limit still apply to it). ## What it means The API returns HTTP 422 with `error.code` set to `consent_required`. The default message is: "This contact has no recorded consent, and consent is required for calls in this workspace." ## Common causes - The contact has not given consent, or withdrew it. The call is refused for compliance. ## How to identify it Check the response status (`422`) and `error.code` (`consent_required`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## What to do - Do not retry: the same request is refused every time until consent is recorded. - Record consent only when the person actually gave it: a workspace member who manages contacts sets it on the contact (PATCH /v1/contacts/{contact_id} with `consent_granted: true` using a key holding `contacts:write`, or the Contacts screen in the console). The change is recorded as a consent event. - Whether consent is required at all is a compliance setting a workspace admin controls in the console. ## Retry guidance Do not resend the request, and do not change settings, to get around this refusal. A new request is appropriate only when the rule genuinely no longer applies: the person asked to be called or gave consent, or the permitted calling window has started. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency No call was placed. If you sent an Idempotency-Key, the refusal is not stored under it. If the rule later stops applying (for example the person gives consent), place a new call (with a new key, if you use one). ## Who can fix it Nobody should change data to make this call go through. Remove a do-not-call entry or record consent only when the person asked you to, and keep a record of who asked and when; these changes are audited. See [Consent and DNC](/guides/consent-and-dnc). ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "consent_required", "message": "This contact has no recorded consent, and consent is required for calls in this workspace.", "details": {}, "request_id": "req_sydugolmpnl3rj7jfdqrc2nz", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/consent_required" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/contact_already_exists # contact_already_exists - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Contact already exists.** Phone numbers are unique within a workspace. Use POST /v1/contacts/upsert to create-or-update by phone number, or update the existing contact in `details.contact_id`. ## What it means The API returns HTTP 409 with `error.code` set to `contact_already_exists`. The default message is: "A contact with this phone number already exists." ## Common causes - Creating a contact whose number is already a contact. - Changing a contact's number to one another contact already has. ## How to identify it Check the response status (`409`) and `error.code` (`contact_already_exists`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Update the existing contact, or use the upsert operation. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "contact_already_exists", "message": "A contact with this phone number already exists.", "details": { "contact_id": "ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1" }, "request_id": "req_exi32aw2mlppoxpprvdymqgt", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_already_exists" } } ``` ## Incorrect and corrected request Incorrect: A contact with this number already exists. ```http POST /v1/contacts { "phone_number": "+12025550100", "name": "Asha Rao" } ``` Corrected: The upsert updates the existing contact (200) or creates one (201). ```http POST /v1/contacts/upsert { "phone_number": "+12025550100", "name": "Asha Rao" } ``` ## Operations that can return this error - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/contact_daily_limit_reached # contact_daily_limit_reached - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes **Daily call limit for this number reached.** The workspace's compliance settings cap how often one number can be called in a rolling 24-hour window. This is a per-destination limit, separate from the API daily call limit. ## What it means The API returns HTTP 422 with `error.code` set to `contact_daily_limit_reached`. The default message is: "This number has already been called the maximum number of times allowed in 24 hours." ## Common causes - The number was already called the configured number of times in the last 24 hours, so the call is refused for compliance. ## How to identify it Check the response status (`422`) and `error.code` (`contact_daily_limit_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Do not retry now: the same request is refused until the oldest of those calls is more than 24 hours old. - Only a workspace admin can change the limit, in the console compliance settings. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Refused for compliance; nothing was dialled and the refusal is not stored under the Idempotency-Key. Resending within the 24-hour window is refused again. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "contact_daily_limit_reached", "message": "This number has already been called the maximum number of times allowed in 24 hours.", "details": {}, "request_id": "req_jb6gnlw7praysnsnaynmahof", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_daily_limit_reached" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/contact_limit_reached # contact_limit_reached - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Contact limit reached.** The contact store has reached its current capacity, so the contact was not created. ## What it means The API returns HTTP 409 with `error.code` set to `contact_limit_reached`. The default message is: "No more contacts can be created right now." ## Common causes - The contact limit has been reached. ## How to identify it Check the response status (`409`) and `error.code` (`contact_limit_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Delete contacts you no longer need, or contact support. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support Always: write to [support@relationsai.com](mailto:support@relationsai.com) so the limit can be reviewed. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "contact_limit_reached", "message": "No more contacts can be created right now.", "details": {}, "request_id": "req_fogrwjym34rzl3t4qiip24ao", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_limit_reached" } } ``` ## Operations that can return this error - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/contact_on_dnc_list # contact_on_dnc_list - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no > **A compliance outcome, not an error to fix:** This refusal protects the person you tried to call. Record it and do not call them now. Never resend the request, remove a do-not-call entry or record consent just to make a call go through. See [Consent and DNC](/guides/consent-and-dnc). **Contact is on the do-not-call list.** Deleting a contact must never quietly remove a do-not-call protection. A contact whose number is on the do-not-call list cannot be deleted through the API while the entry exists. ## What it means The API returns HTTP 409 with `error.code` set to `contact_on_dnc_list`. The default message is: "This contact is on the do-not-call list. Remove its do-not-call entry before deleting it." ## Common causes - The contact has `do_not_call: true`. ## How to identify it Check the response status (`409`) and `error.code` (`contact_on_dnc_list`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## What to do - Keep the contact: it is what keeps the number from being called. Do not retry: the same request is refused every time. - Only if the person really asked to be called again: remove the entry with DELETE /v1/dnc-entries/{dnc_entry_id} (recorded in the audit log; keep your own record of who asked), then delete the contact. ## Retry guidance Do not resend the request, and do not change settings, to get around this refusal. A new request is appropriate only when the rule genuinely no longer applies: the person asked to be called or gave consent, or the permitted calling window has started. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency No call was placed. If you sent an Idempotency-Key, the refusal is not stored under it. If the rule later stops applying (for example the person gives consent), place a new call (with a new key, if you use one). ## Who can fix it Nobody should change data to make this call go through. Remove a do-not-call entry or record consent only when the person asked you to, and keep a record of who asked and when; these changes are audited. See [Consent and DNC](/guides/consent-and-dnc). ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "contact_on_dnc_list", "message": "This contact is on the do-not-call list. Remove its do-not-call entry before deleting it.", "details": {}, "request_id": "req_ibdccc2yocvajobujgsm4msv", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/contact_on_dnc_list" } } ``` ## Incorrect and corrected request Incorrect: The contact is on the do-not-call list (`do_not_call: true`). ```http DELETE /v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 ``` Corrected: Check `do_not_call` first and keep contacts that are on the list. ```http GET /v1/contacts/ct_8Rw2Tq5Lm9Xc3Vb7Nz4Kp1 ``` ## Operations that can return this error - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/custom_field_not_found # custom_field_not_found - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Custom field not found.** Keys of `custom_fields` must be the names of custom fields defined in the console (Contacts, Custom fields). The API does not create fields on the fly, so a typo cannot silently create a new field. ## What it means The API returns HTTP 422 with `error.code` set to `custom_field_not_found`. The default message is: "This custom field does not exist in the workspace." ## Common causes - A typo in a custom field name. - The field was deleted or never created. ## How to identify it Check the response status (`422`) and `error.code` (`custom_field_not_found`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Create the field in the console, or correct the key named in `details.field`. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "custom_field_not_found", "message": "This custom field does not exist in the workspace.", "details": { "field": "policy_numbr" }, "request_id": "req_z6vplhqdzobjbgdbzq724ujk", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/custom_field_not_found" } } ``` ## Incorrect and corrected request Incorrect: `policy_numbr` is not a custom field of the workspace. ```http POST /v1/contacts { "phone_number": "+12025550101", "custom_fields": { "policy_numbr": "PN-55012" } } ``` Corrected: The key names the field exactly as defined in the console. ```http POST /v1/contacts { "phone_number": "+12025550101", "custom_fields": { "policy_number": "PN-55012" } } ``` ## Operations that can return this error - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/daily_call_limit_reached # daily_call_limit_reached - HTTP status: 429 - Retry: After Retry-After - Execution: not started - Admin needed: yes **Daily call limit reached.** Live mode caps how many calls a workspace can place through the API per UTC day, as protection against runaway scripts. Each POST /v1/calls counts one call, and starting a campaign counts one per recipient (refused whole when they do not all fit). Resuming a campaign never counts: its start already did. `details` says how many calls the request needed and how many were left. The limit resets at 00:00 UTC. Test mode has no daily cap. ## What it means The API returns HTTP 429 with `error.code` set to `daily_call_limit_reached`. The default message is: "This workspace has reached its daily limit of calls placed through the API." ## Common causes - A loop or a retry storm placed more calls than intended. - Legitimate volume above the default cap. ## How to identify it Check the response status (`429`) and `error.code` (`daily_call_limit_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait until `Retry-After` (the next UTC midnight). - Ask support to review the limit if the volume is expected. ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. The request may be sent again after the reset (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support Write to [support@relationsai.com](mailto:support@relationsai.com) to discuss a higher daily limit. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "daily_call_limit_reached", "message": "This workspace has reached its daily limit of calls placed through the API.", "details": { "limit": 500, "requested": 120, "remaining": 37, "resets_at": "2026-10-22T00:00:00.000Z" }, "request_id": "req_mwdfmxtu46pv6eiwio5jrwuf", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/daily_call_limit_reached" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/dnc_listed # dnc_listed - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no > **A compliance outcome, not an error to fix:** This refusal protects the person you tried to call. Record it and do not call them now. Never resend the request, remove a do-not-call entry or record consent just to make a call go through. See [Consent and DNC](/guides/consent-and-dnc). **Number on the do-not-call list.** Every call request is checked against the workspace do-not-call list, and a listed number is refused. The check is always on and matches the number in any format (by its digits, and by its last ten digits when both numbers have at least ten). Campaigns are checked when they start and when they resume: a number added to the list while a campaign is running is not removed from that run, so pause and resume the campaign (the resume is refused while a remaining recipient is listed) or stop it. ## What it means The API returns HTTP 422 with `error.code` set to `dnc_listed`. The default message is: "This number is on your do-not-call list, so the call was not placed." ## Common causes - The destination number, or a contact with that number, is on the do-not-call list. The call is refused for compliance. ## How to identify it Check the response status (`422`) and `error.code` (`dnc_listed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## What to do - Do not call this number, and do not retry: the same request is refused every time. - Only if the number was added by mistake, or the person has since asked to be called again, a workspace member who manages contacts removes the entry (DELETE /v1/dnc-entries/{dnc_entry_id} with a key holding `dnc:write`, or the Contacts screen in the console). The removal is recorded in the audit log with who made it. ## Retry guidance Do not resend the request, and do not change settings, to get around this refusal. A new request is appropriate only when the rule genuinely no longer applies: the person asked to be called or gave consent, or the permitted calling window has started. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency No call was placed. If you sent an Idempotency-Key, the refusal is not stored under it. If the rule later stops applying (for example the person gives consent), place a new call (with a new key, if you use one). ## Who can fix it Nobody should change data to make this call go through. Remove a do-not-call entry or record consent only when the person asked you to, and keep a record of who asked and when; these changes are audited. See [Consent and DNC](/guides/consent-and-dnc). ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "dnc_listed", "message": "This number is on your do-not-call list, so the call was not placed.", "details": {}, "request_id": "req_juciyreuh7sfnlcmrauuzko6", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/dnc_listed" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/idempotency_key_in_progress # idempotency_key_in_progress - HTTP status: 409 - Retry: After Retry-After - Execution: may have started - Admin needed: no **Request with this Idempotency-Key in progress.** An earlier request with the same key has not finished yet. When it finishes, a successful (2xx) answer is stored and a retry with the same key replays it; so is an error whose documentation says its outcome is unknown and the answer is kept (for example `campaign_outcome_unknown`). Any other error is not stored, and the retry then runs the request again. ## What it means The API returns HTTP 409 with `error.code` set to `idempotency_key_in_progress`. The default message is: "A request with this Idempotency-Key is still being processed." ## Common causes - A client retried before the first attempt finished. - Two workers sent the same operation concurrently. ## How to identify it Check the response status (`409`) and `error.code` (`idempotency_key_in_progress`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait for `Retry-After` seconds and send the identical request again with the same key. ## Retry guidance Wait for the number of seconds in the `Retry-After` header. For a write, read the resource first to find out whether the change was applied before you send anything again. ## Did anything happen? The work may have started before this error was returned. Do not assume nothing happened: read the resource (for a call, list calls filtered by `to` and `created_after`; for a campaign, retrieve it) before you send anything again. ## Idempotency Keep the SAME key. After Retry-After the retry replays the stored answer if the first request stored one, or runs the request if it ended in an error that is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "idempotency_key_in_progress", "message": "A request with this Idempotency-Key is still being processed.", "details": {}, "request_id": "req_5mukun4qwubk7rw5rsxshcr5", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_in_progress" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list): Remove a number from the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint): Delete a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/idempotency_key_reused # idempotency_key_reused - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Idempotency-Key reused.** Within 24 hours an Idempotency-Key identifies one request: same method, path and body. Sending the key with a different body or to a different operation is refused so a retry can never silently do something else. ## What it means The API returns HTTP 409 with `error.code` set to `idempotency_key_reused`. The default message is: "This Idempotency-Key was already used with a different request." ## Common causes - The same key was used for two different operations. - The body changed between retries. ## How to identify it Check the response status (`409`) and `error.code` (`idempotency_key_reused`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Use a new key for a new operation. For a retry, send exactly the original request. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Generate a new key for the new request. The original request with this key is unaffected. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "idempotency_key_reused", "message": "This Idempotency-Key was already used with a different request.", "details": {}, "request_id": "req_ot57su2lklzm53wt35jhp5o2", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_key_reused" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list): Remove a number from the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint): Delete a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/idempotency_outcome_unknown # idempotency_outcome_unknown - HTTP status: 409 - Retry: No - Execution: may have started - Admin needed: no **Outcome of the earlier request unknown.** The first request with this key was interrupted before its result was recorded. It may or may not have taken effect, so the API refuses to run it a second time under the same key. What is stored: the unfinished claim. For 24 hours from the first attempt, every request with this key answers this same error; after that the key is free again. A `POST /v1/calls` sent WITHOUT a key can also get this answer when the call may have started but could not be confirmed: nothing is stored then, and a retry places another call. ## What it means The API returns HTTP 409 with `error.code` set to `idempotency_outcome_unknown`. The default message is: "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown." ## Common causes - The earlier request was interrupted by a server restart or a lost connection mid-operation. ## How to identify it Check the response status (`409`) and `error.code` (`idempotency_outcome_unknown`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - List or retrieve the resource (for a call, list calls filtered by `to` and `created_after`) to see whether the operation took effect. - Only if it did not, send the operation again (with a NEW key if you use keys). ## Retry guidance Do not resend yet. First find out whether the operation took effect (see How to fix it), then act on what you find. ## Did anything happen? The work may have started before this error was returned. Do not assume nothing happened: read the resource (for a call, list calls filtered by `to` and `created_after`; for a campaign, retrieve it) before you send anything again. A charge may have been recorded for work that started; check the call or campaign before you act. ## Idempotency If you sent a key: keep the SAME key for any retry; for 24 hours from the first attempt it answers this same error, so it can never run the request twice. Never send the request with a new key (or without one) until you know it did not happen. If you sent no key, nothing protects a retry: it runs the request again. Either way, learn the outcome first: GET the resource, or list by `to` or your `metadata`; only if it did not happen, send it again. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the state you read is still ambiguous after a few minutes, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "idempotency_outcome_unknown", "message": "An earlier request with this Idempotency-Key did not finish, and its outcome is unknown.", "details": {}, "request_id": "req_ai4vrzgioj36wznhcobglbkh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/idempotency_outcome_unknown" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list): Remove a number from the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint): Delete a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/insufficient_balance # insufficient_balance - HTTP status: 402 - Retry: No - Execution: not started - Admin needed: yes **Insufficient balance.** Calls are paid from the workspace balance. Before dialling, the API checks that the balance covers the minimum needed to start (an estimate for a campaign). `details` shows both amounts as charged to your workspace. ## What it means The API returns HTTP 402 with `error.code` set to `insufficient_balance`. The default message is: "The workspace balance is too low to start this call. Top up to continue." ## Common causes - The balance is below the amount needed to start the call or the campaign. ## How to identify it Check the response status (`402`) and `error.code` (`insufficient_balance`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - A workspace admin tops up the balance in the console (Billing), then retry. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "insufficient_balance", "message": "The workspace balance is too low to start this call. Top up to continue.", "details": { "balance": { "amount": 250, "currency": "INR" }, "required": { "amount": 600, "currency": "INR" } }, "request_id": "req_med4obvxklwxlxv323s4s23k", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_balance" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/insufficient_permissions # insufficient_permissions - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: yes **Key creator lacks permission.** A key can never do more than the member who created it is currently allowed to do. If that member was removed from the workspace or moved to a role without this permission, the key loses it too, even though the scope is still listed on the key. ## What it means The API returns HTTP 403 with `error.code` set to `insufficient_permissions`. The default message is: "The member who created this API key no longer has permission for this operation." ## Common causes - The key's creator was moved to a role without this permission. - The key's creator left the workspace. ## How to identify it Check the response status (`403`) and `error.code` (`insufficient_permissions`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Ask a workspace admin to create a new key from an account that holds the permission. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "insufficient_permissions", "message": "The member who created this API key no longer has permission for this operation.", "details": { "required_scopes": [ "campaigns:write" ] }, "request_id": "req_7sky3cy2br4bx34yd4bn5ezq", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_permissions" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/insufficient_scope # insufficient_scope - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: yes **Insufficient scope.** Each operation requires specific scopes (listed as `x-required-scopes` in the reference). The key was created without at least one of them. ## What it means The API returns HTTP 403 with `error.code` set to `insufficient_scope`. The default message is: "This API key does not have the scope this operation requires." ## Common causes - The key was created with read-only scopes and the operation writes. - The key lacks an artifact scope such as `transcripts:read`. ## How to identify it Check the response status (`403`) and `error.code` (`insufficient_scope`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Ask a workspace admin to create a key that includes the scopes in `details.required_scopes`. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "insufficient_scope", "message": "This API key does not have the scope this operation requires.", "details": { "required_scopes": [ "calls:write" ] }, "request_id": "req_oa7zjyztfj7b5q6yacxt67bz", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/insufficient_scope" } } ``` ## Incorrect and corrected request Incorrect: The key was created with `agents:read` only; listing calls needs `calls:read`. ```http GET /v1/calls Authorization: Bearer rai_test_KEY_WITH_AGENTS_READ_ONLY ``` Corrected: A key a workspace admin created with `calls:read` (see `details.required_scopes`). ```http GET /v1/calls Authorization: Bearer rai_test_KEY_WITH_CALLS_READ ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/internal_error # internal_error - HTTP status: 500 - Retry: Yes, with backoff - Execution: may have started - Admin needed: no **Internal error.** An unexpected error. A 500 is never stored: a retry runs the request again from the start (with or without the same Idempotency-Key); it does not replay an earlier result. A read can simply be retried. For a write, the API does not know whether the change was applied before the error, so read the resource first (for a call, list calls filtered by `to` and `created_after`; for a campaign or a contact, retrieve it) and retry only if the change is not there. ## What it means The API returns HTTP 500 with `error.code` set to `internal_error`. The default message is: "Something went wrong on our side." ## Common causes - An unexpected server fault. ## How to identify it Check the response status (`500`) and `error.code` (`internal_error`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - For a read: retry with exponential backoff. - For a write: read the resource state first, then, only if the change did not happen, retry (with the same Idempotency-Key if you sent one; a retry without a key is a new request). ## Retry guidance Use exponential backoff (for example 1 s, 2 s, 4 s, up to about a minute). For a write, read the resource first to find out whether the change was applied before you send anything again. ## Did anything happen? The work may have started before this error was returned. Do not assume nothing happened: read the resource (for a call, list calls filtered by `to` and `created_after`; for a campaign, retrieve it) before you send anything again. A charge may have been recorded for work that started; check the call or campaign before you act. ## Idempotency Nothing is stored after a 500, key or no key: a retry runs the request again (it is not a replay), so a blind retry of a write can repeat it. Check the state of a write first with a GET; retry only if it did not happen (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If it persists, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "internal_error", "message": "Something went wrong on our side.", "details": {}, "request_id": "req_r3hmr3ccufgy43lrd5ssiycn", "retry": "yes", "doc_url": "https://docs.relationsai.com/errors/internal_error" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/invalid_api_key # invalid_api_key - HTTP status: 401 - Retry: No - Execution: not started - Admin needed: yes **Invalid API key.** The token does not match any API key. Console sign-in tokens and other credentials are not API keys and are refused here too. ## What it means The API returns HTTP 401 with `error.code` set to `invalid_api_key`. The default message is: "The API key is not valid." ## Common causes - A typo or a truncated key. - A key copied with surrounding whitespace or quotes. - A credential that is not a RelationsAI API key. ## How to identify it Check the response status (`401`) and `error.code` (`invalid_api_key`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Copy the key again from where you stored it when it was created. Keys are shown only once; if it is lost, create a new key in the console under API & Developers. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "invalid_api_key", "message": "The API key is not valid.", "details": {}, "request_id": "req_deaiecuycog7xnloqx4ounlo", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_api_key" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/invalid_cursor # invalid_cursor - HTTP status: 400 - Retry: No - Execution: not applicable - Admin needed: no **Invalid pagination cursor.** Cursors are opaque and bound to the filters of the list request that produced them. A cursor that was edited, truncated, produced by a different list, or reused with different filters is refused. ## What it means The API returns HTTP 400 with `error.code` set to `invalid_cursor`. The default message is: "The pagination cursor is invalid or does not match these filters." ## Common causes - The cursor string was modified or truncated. - Filters changed between pages. - A cursor from one list was sent to another list. ## How to identify it Check the response status (`400`) and `error.code` (`invalid_cursor`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Send `next_cursor` exactly as returned, with the same filters. To change filters, start again without a cursor. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This error is about the request itself; it does not describe whether an earlier request did anything. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "invalid_cursor", "message": "The pagination cursor is invalid or does not match these filters.", "details": {}, "request_id": "req_viumdb2pitxpfklnem36z5eo", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_cursor" } } ``` ## Incorrect and corrected request Incorrect: The cursor came from a page listed with `status=ended`; the filter changed between pages. ```http GET /v1/calls?status=in_progress&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiJdfQ.c2lnbmF0dXJl Authorization: Bearer rai_test_YOUR_TEST_KEY ``` Corrected: The same filters as the request that returned this `next_cursor`. To change filters, start again without a cursor. ```http GET /v1/calls?status=ended&cursor=eyJ2IjoxLCJrIjpbIjIwMjYtMTAtMjFUMDk6MzA6MDAuMDAwWiJdfQ.c2lnbmF0dXJl Authorization: Bearer rai_test_YOUR_TEST_KEY ``` ## Operations that can return this error - [`GET /agents`](/api/agents#list-agents): List agents - [`GET /phone-numbers`](/api/phone-numbers#list-phone-numbers): List phone numbers - [`GET /calls`](/api/calls#list-calls): List calls - [`GET /campaigns`](/api/campaigns#list-campaigns): List campaigns - [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients): List recipients - [`GET /contacts`](/api/contacts#list-contacts): List contacts - [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries): List or look up do-not-call entries - [`GET /webhook-endpoints`](/api/webhook-endpoints#list-webhook-endpoints): List webhook endpoints - [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries): List deliveries See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/invalid_idempotency_key # invalid_idempotency_key - HTTP status: 400 - Retry: No - Execution: not started - Admin needed: no **Invalid Idempotency-Key.** The `Idempotency-Key` header was present but empty, too long, or contained spaces or non-ASCII characters. ## What it means The API returns HTTP 400 with `error.code` set to `invalid_idempotency_key`. The default message is: "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces." ## Common causes - A key longer than 255 characters. - A key containing spaces, tabs or non-ASCII characters. ## How to identify it Check the response status (`400`) and `error.code` (`invalid_idempotency_key`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Use a UUID v4 or another URL-safe token of at most 255 characters. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Send a valid key. Nothing was done. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "invalid_idempotency_key", "message": "The Idempotency-Key header must be 1 to 255 printable ASCII characters with no spaces.", "details": {}, "request_id": "req_oow7drypdti4zyumdehx6iuz", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_idempotency_key" } } ``` ## Incorrect and corrected request Incorrect: The key contains spaces. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json Idempotency-Key: call Asha retry 1 { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100" } ``` Corrected: A UUID v4: printable ASCII, no spaces, at most 255 characters. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json Idempotency-Key: 6f1d2c84-5b7e-4a3f-9c2d-8e1b0a7f4d63 { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list): Remove a number from the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint): Delete a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/invalid_json # invalid_json - HTTP status: 400 - Retry: No - Execution: not started - Admin needed: no **Invalid JSON body.** The body could not be parsed as a JSON document, so the request was not processed. ## What it means The API returns HTTP 400 with `error.code` set to `invalid_json`. The default message is: "The request body is not valid JSON." ## Common causes - A trailing comma, an unquoted key or a truncated body. - A form-encoded body sent with a JSON content type. ## How to identify it Check the response status (`400`) and `error.code` (`invalid_json`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Serialize the body with a JSON library rather than by string concatenation. - Send `Content-Type: application/json`. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Fix the request and send it again; if you sent an Idempotency-Key you may reuse it, because a refused request is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "invalid_json", "message": "The request body is not valid JSON.", "details": {}, "request_id": "req_hfrm5ytbbxqqrscd33wexrdr", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_json" } } ``` ## Incorrect and corrected request Incorrect: The body was built by string concatenation and ends with a trailing comma, which JSON does not allow. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json {"agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100",} ``` Corrected: The same request serialized with a JSON library. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint): Update a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/invalid_phone_number # invalid_phone_number - HTTP status: 400 - Retry: No - Execution: not started - Admin needed: no **Invalid phone number.** Phone numbers must be E.164 strings: a `+`, the country code and the subscriber number, digits only, at most 15 digits (for example `+919000000001`). ## What it means The API returns HTTP 400 with `error.code` set to `invalid_phone_number`. The default message is: "The phone number is not a valid E.164 number." ## Common causes - A local format such as `09000000001` or `(202) 555-0100`. - Spaces, dashes or letters in the number. - A number too short or too long for E.164. ## How to identify it Check the response status (`400`) and `error.code` (`invalid_phone_number`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Normalize numbers to E.164 before sending them. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Fix the request and send it again; if you sent an Idempotency-Key you may reuse it, because a refused request is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "invalid_phone_number", "message": "The phone number is not a valid E.164 number.", "details": { "field": "to" }, "request_id": "req_m3wrr5j6pwetgyu2pu4iwkmm", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/invalid_phone_number" } } ``` ## Incorrect and corrected request Incorrect: A local format with a leading zero and no country code. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "09000000001" } ``` Corrected: The same number in E.164: `+`, the country code, then the subscriber number. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+919000000001" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`GET /dnc-entries`](/api/do-not-call-list#list-or-look-up-do-not-call-entries): List or look up do-not-call entries - [`GET /contacts`](/api/contacts#list-contacts): List contacts - [`GET /calls`](/api/calls#list-calls): List calls See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/ip_not_allowed # ip_not_allowed - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: yes **IP address not allowed.** The workspace has an IP allowlist enabled in its security settings and the request's source address is not on it. ## What it means The API returns HTTP 403 with `error.code` set to `ip_not_allowed`. The default message is: "Requests from this IP address are not allowed for this workspace." ## Common causes - The workspace IP allowlist does not include the server making the request. ## How to identify it Check the response status (`403`) and `error.code` (`ip_not_allowed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Ask a workspace admin to add the server's egress address to the allowlist in the console security settings. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "ip_not_allowed", "message": "Requests from this IP address are not allowed for this workspace.", "details": {}, "request_id": "req_gyacvt7zutikmjyduh3osmuv", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/ip_not_allowed" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/legacy_api_key_not_supported # legacy_api_key_not_supported - HTTP status: 401 - Retry: No - Execution: not started - Admin needed: yes **Legacy API key not supported.** Keys that begin `sk_live_` predate scopes and test mode. They keep working where they worked before, but the v1 API accepts only scoped keys (`rai_test_` and `rai_live_`). ## What it means The API returns HTTP 401 with `error.code` set to `legacy_api_key_not_supported`. The default message is: "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console." ## Common causes - An `sk_live_` key was sent to the v1 API. ## How to identify it Check the response status (`401`) and `error.code` (`legacy_api_key_not_supported`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Create a scoped key in the console under API & Developers and use it instead. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "legacy_api_key_not_supported", "message": "Legacy API keys cannot be used with the v1 API. Create a scoped key in the console.", "details": {}, "request_id": "req_t4lwuuscjdazkytra2fdu4kp", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/legacy_api_key_not_supported" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/live_key_not_allowed_in_browser # live_key_not_allowed_in_browser - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: no **Live key used from a browser.** Requests that carry an `Origin` header come from browser code, where a key is visible to anyone who opens the page. Live keys are refused there; test keys are allowed from origins the workspace has approved. ## What it means The API returns HTTP 403 with `error.code` set to `live_key_not_allowed_in_browser`. The default message is: "Live API keys cannot be used from a browser. Use a test key, or call the API from your server." ## Common causes - A live key was used from front-end code or a browser-based API playground. ## How to identify it Check the response status (`403`) and `error.code` (`live_key_not_allowed_in_browser`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Move live calls to your server. - Use a `rai_test_` key in browser-based tools. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "live_key_not_allowed_in_browser", "message": "Live API keys cannot be used from a browser. Use a test key, or call the API from your server.", "details": {}, "request_id": "req_qq2kulb7nkgbm75x75nhowfu", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/live_key_not_allowed_in_browser" } } ``` ## Incorrect and corrected request Incorrect: A live key sent from browser code (the request carries an Origin header). ```http GET /v1/workspace Authorization: Bearer rai_live_YOUR_LIVE_KEY Origin: https://www.example.com ``` Corrected: From the browser, use a test key (from an origin the workspace allows); make live calls from your server. ```http GET /v1/workspace Authorization: Bearer rai_test_YOUR_TEST_KEY Origin: https://www.example.com ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/media_link_expired # media_link_expired - HTTP status: 410 - Retry: No - Execution: not applicable - Admin needed: no **Recording link expired.** Recording links are short-lived. Each GET /v1/calls/{call_id}/recording returns a fresh link with its own `expires_at`. ## What it means The API returns HTTP 410 with `error.code` set to `media_link_expired`. The default message is: "This recording link has expired. Retrieve the recording again for a new link." ## Common causes - The link was used after its `expires_at`. ## How to identify it Check the response status (`410`) and `error.code` (`media_link_expired`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Call GET /v1/calls/{call_id}/recording again and use the new `url` promptly. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This error is about the request itself; it does not describe whether an earlier request did anything. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "media_link_expired", "message": "This recording link has expired. Retrieve the recording again for a new link.", "details": {}, "request_id": "req_kdqluou3hscw65fnw2gnvhkj", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/media_link_expired" } } ``` ## Operations that can return this error - [`GET /recordings/{recording_token}`](/api/recordings#download-recording-audio): Download recording audio See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/method_not_allowed # method_not_allowed - HTTP status: 405 - Retry: No - Execution: not started - Admin needed: no **Method not allowed.** The path exists but not with this method. The `Allow` response header lists the supported methods. ## What it means The API returns HTTP 405 with `error.code` set to `method_not_allowed`. The default message is: "This route does not support that HTTP method." ## Common causes - PUT sent where the API uses PATCH. - DELETE sent to a resource that cannot be deleted. ## How to identify it Check the response status (`405`) and `error.code` (`method_not_allowed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Use a method listed in the `Allow` header. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "method_not_allowed", "message": "This route does not support that HTTP method.", "details": {}, "request_id": "req_rhmppwa5cy42vnog7mp66zqv", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/method_not_allowed" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/outside_calling_hours # outside_calling_hours - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes > **A compliance outcome, not an error to fix:** This refusal protects the person you tried to call. Record it and do not call them now. Never resend the request, remove a do-not-call entry or record consent just to make a call go through. See [Consent and DNC](/guides/consent-and-dnc). **Outside calling hours.** The workspace has a calling window (curfew) switched on in its compliance settings. Calls and campaign dialling are refused outside it. The window is evaluated in the timezone of the workspace's region (`details.timezone`), not the contact's. ## What it means The API returns HTTP 422 with `error.code` set to `outside_calling_hours`. The default message is: "This is outside the permitted calling window for this workspace. Nothing was dialled." ## Common causes - The request arrived outside the calling window, so it is refused for compliance. - The calling window is switched on but no hours are set, which blocks every call. ## How to identify it Check the response status (`422`) and `error.code` (`outside_calling_hours`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## What to do - Do not retry now: the same request is refused until the window opens. Send it again inside the window in `details` (a new request; the refusal was not stored). - Only a workspace admin can change the window, in the console compliance settings, and only to hours that are permitted for the people being called. ## Retry guidance Do not resend the request, and do not change settings, to get around this refusal. A new request is appropriate only when the rule genuinely no longer applies: the person asked to be called or gave consent, or the permitted calling window has started. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency No call was placed. If you sent an Idempotency-Key, the refusal is not stored under it. If the rule later stops applying (for example the person gives consent), place a new call (with a new key, if you use one). ## Who can fix it Nobody should change data to make this call go through. Remove a do-not-call entry or record consent only when the person asked you to, and keep a record of who asked and when; these changes are audited. See [Consent and DNC](/guides/consent-and-dnc). ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "outside_calling_hours", "message": "This is outside the permitted calling window for this workspace. Nothing was dialled.", "details": { "window": "09:00-21:00", "timezone": "Asia/Kolkata" }, "request_id": "req_swm247x6e63nqf3j3t4c3ash", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/outside_calling_hours" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/payload_too_large # payload_too_large - HTTP status: 413 - Retry: No - Execution: not started - Admin needed: no **Payload too large.** Request bodies are limited to 1 MB. Bulk operations also have per-request item limits documented on each operation. ## What it means The API returns HTTP 413 with `error.code` set to `payload_too_large`. The default message is: "The request body is too large." ## Common causes - Too many recipients in one request. - Very large `metadata` or `variables` values. ## How to identify it Check the response status (`413`) and `error.code` (`payload_too_large`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Split the request into smaller batches. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Fix the request and send it again; if you sent an Idempotency-Key you may reuse it, because a refused request is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "payload_too_large", "message": "The request body is too large.", "details": { "limit_bytes": 1048576 }, "request_id": "req_wmes2bqgtznwtj5ku4ts42tl", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/payload_too_large" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint): Update a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/phone_number_not_usable # phone_number_not_usable - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes **Phone number cannot be used for this call.** The `phone_number_id` names a number in your workspace, but it cannot be used as the caller number for this agent: it does not support outbound calls, or it is not connected for calling. ## What it means The API returns HTTP 422 with `error.code` set to `phone_number_not_usable`. The default message is: "This phone number cannot place outbound calls for this agent." ## Common causes - The number is inbound only. - The number is not connected for calling yet. ## How to identify it Check the response status (`422`) and `error.code` (`phone_number_not_usable`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Pick a number whose `capabilities.outbound` is true, or omit `phone_number_id` to use the agent's outbound number. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "phone_number_not_usable", "message": "This phone number cannot place outbound calls for this agent.", "details": { "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d" }, "request_id": "req_sg7b4vcijdoiigjq5ao6xjs4", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_not_usable" } } ``` ## Incorrect and corrected request Incorrect: The pinned number is inbound only (capabilities.outbound is false). ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001", "phone_number_id": "pn_7Qw2Er4Ty6Ui8Op1As3Df5" } ``` Corrected: Pins a number whose capabilities.outbound is true and that lists the agent in outbound_agent_ids (or omits phone_number_id). ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001", "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/phone_number_required # phone_number_required - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Caller number must be chosen.** When `phone_number_id` is omitted, the agent's outbound number is used. This agent is assigned to several outbound numbers, so the API will not guess which one your contact should see. ## What it means The API returns HTTP 422 with `error.code` set to `phone_number_required`. The default message is: "This agent has more than one outbound number. Choose one with phone_number_id." ## Common causes - The agent is assigned to more than one outbound number. ## How to identify it Check the response status (`422`) and `error.code` (`phone_number_required`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Send `phone_number_id`, or assign the agent to a single outbound number in the console. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "phone_number_required", "message": "This agent has more than one outbound number. Choose one with phone_number_id.", "details": { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1" }, "request_id": "req_beny5osdfwrs6kgzv4uhhcav", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/phone_number_required" } } ``` ## Incorrect and corrected request Incorrect: The agent has two outbound numbers and none was chosen. ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001" } ``` Corrected: Names the caller number with phone_number_id. ```http POST /v1/calls { "agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+919000000001", "phone_number_id": "pn_5Lq8Xc2Rk9TzW3mBv7Np4d" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/precondition_required # precondition_required - HTTP status: 428 - Retry: No - Execution: not started - Admin needed: no **If-Match required.** Campaigns and contacts are updated with optimistic concurrency. Send the `ETag` from your latest read in `If-Match`, so an update never overwrites a change you have not seen. `If-Match: *` matches any current version: the update is applied without the concurrency check (use it only when overwriting whatever is there is what you want). ## What it means The API returns HTTP 428 with `error.code` set to `precondition_required`. The default message is: "This update needs an If-Match header with the ETag of the version you read." ## Common causes - The `If-Match` header was not sent. ## How to identify it Check the response status (`428`) and `error.code` (`precondition_required`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Retrieve the resource, copy its `ETag` response header into `If-Match`, and resend. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency PATCH does not use an Idempotency-Key. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "precondition_required", "message": "This update needs an If-Match header with the ETag of the version you read.", "details": {}, "request_id": "req_eesgkbbtxvpr6b6cu4zcwb7o", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/precondition_required" } } ``` ## Incorrect and corrected request Incorrect: A PATCH without If-Match. ```http PATCH /v1/campaigns/cmp_3Uat7Kq2Lm9Px4Rv8Ny5Bc Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "name": "October renewals" } ``` Corrected: The ETag from the latest GET of the campaign, sent as If-Match. ```http PATCH /v1/campaigns/cmp_3Uat7Kq2Lm9Px4Rv8Ny5Bc Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json If-Match: "c3f1a9e2b7d4" { "name": "October renewals" } ``` ## Operations that can return this error - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/rate_limited # rate_limited - HTTP status: 429 - Retry: After Retry-After - Execution: not started - Admin needed: no **Rate limit exceeded.** Requests are limited per key and per workspace, separately for test and live mode, in three classes: read, write and dial. Requests that end before they reach an operation (an unknown path or method, a body that is refused, a missing or invalid key, a workspace without API access) are also counted per client IP address: 300 a minute; once that is spent, every request from the address answers this error (`details.limit_class` `read`) until the window resets. `RateLimit-*` headers on every response show the budget that applied to it. ## What it means The API returns HTTP 429 with `error.code` set to `rate_limited`. The default message is: "Too many requests. Retry after the number of seconds in the Retry-After header." ## Common causes - A burst of requests above the class limit. - Several keys in the same workspace sharing the workspace limit. - More than 300 requests a minute from one IP address that failed before reaching an operation (for example a client sending an invalid key in a loop). ## How to identify it Check the response status (`429`) and `error.code` (`rate_limited`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds. Spread requests out and back off exponentially. - Fix requests that fail authentication or routing: they count against the address. ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Retry after the wait (with the same Idempotency-Key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If your steady workload needs higher limits, write to [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "rate_limited", "message": "Too many requests. Retry after the number of seconds in the Retry-After header.", "details": { "limit_class": "write" }, "request_id": "req_uppzyvuxkt7wv43lgedhqsyb", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/rate_limited" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/recipient_contacts_not_found # recipient_contacts_not_found - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Contacts not found.** Recipients are contacts. Every id in `contact_ids` must be a contact in this workspace and mode. Nothing was added. ## What it means The API returns HTTP 422 with `error.code` set to `recipient_contacts_not_found`. The default message is: "Some contact ids do not name contacts in this workspace." ## Common causes - A typo in a contact id. - A contact from the other mode (test vs live). - A deleted contact. ## How to identify it Check the response status (`422`) and `error.code` (`recipient_contacts_not_found`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Create the contacts first (POST /v1/contacts/upsert), then add their ids. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "recipient_contacts_not_found", "message": "Some contact ids do not name contacts in this workspace.", "details": { "contact_ids": [ "ct_9Zx4Vb7Nm2Qw5Er8Ty1Ui3" ] }, "request_id": "req_o2wrow7w2xqufsvsn4ksojj3", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/recipient_contacts_not_found" } } ``` ## Incorrect and corrected request Incorrect: The id names no contact in this workspace and mode (for example a live contact with a test key). ```http POST /v1/campaigns/cmp_2Kd7Fg4Hj9Lz3Xc8Vb5Nm1/recipients { "contact_ids": [ "ct_9Zx4Vb7Nm2Qw5Er8Ty1Ui3" ] } ``` Corrected: Create (or find) the contact in the same mode first, then add the id it returns. ```http POST /v1/contacts/upsert { "phone_number": "+12025550102" } ``` ## Operations that can return this error - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/resource_not_found # resource_not_found - HTTP status: 404 - Retry: No - Execution: not started - Admin needed: no **Resource not found.** The id does not name a resource this key can see. Ids are scoped to a workspace and a mode: a live-mode id is not found with a test key and vice versa, and another workspace's id is not found either. ## What it means The API returns HTTP 404 with `error.code` set to `resource_not_found`. The default message is: "No such resource." ## Common causes - A typo or a truncated id. - An id from the other mode (test vs live). - An id with the wrong prefix for this resource. - The resource was deleted. ## How to identify it Check the response status (`404`) and `error.code` (`resource_not_found`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Check the id and the key mode. List the resource to find valid ids. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "resource_not_found", "message": "No such resource.", "details": {}, "request_id": "req_bic5uwhfw3xxme7xdhmjberw", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/resource_not_found" } } ``` ## Operations that can return this error - [`GET /agents/{agent_id}`](/api/agents#retrieve-an-agent): Retrieve an agent - [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls): Check whether an agent can place calls - [`GET /phone-numbers/{phone_number_id}`](/api/phone-numbers#retrieve-a-phone-number): Retrieve a phone number - [`GET /calls/{call_id}`](/api/calls#retrieve-a-call): Retrieve a call - [`GET /calls/{call_id}/transcript`](/api/transcripts#retrieve-a-call-transcript): Retrieve a call transcript - [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link): Retrieve a call recording link - [`GET /calls/{call_id}/analysis`](/api/analysis#retrieve-a-call-analysis): Retrieve a call analysis - [`GET /recordings/{recording_token}`](/api/recordings#download-recording-audio): Download recording audio - [`GET /campaigns/{campaign_id}`](/api/campaigns#retrieve-a-campaign): Retrieve a campaign - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`GET /campaigns/{campaign_id}/recipients`](/api/campaigns#list-recipients): List recipients - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/pause`](/api/campaigns#pause-a-campaign): Pause a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign - [`POST /campaigns/{campaign_id}/stop`](/api/campaigns#stop-a-campaign): Stop a campaign - [`GET /contacts/{contact_id}`](/api/contacts#retrieve-a-contact): Retrieve a contact - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact - [`DELETE /contacts/{contact_id}`](/api/contacts#delete-a-contact): Delete a contact - [`DELETE /dnc-entries/{dnc_entry_id}`](/api/do-not-call-list#remove-a-number-from-the-do-not-call-list): Remove a number from the do-not-call list - [`GET /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#retrieve-a-webhook-endpoint): Retrieve a webhook endpoint - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint): Update a webhook endpoint - [`DELETE /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#delete-a-webhook-endpoint): Delete a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries): List deliveries - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/route_not_found # route_not_found - HTTP status: 404 - Retry: No - Execution: not started - Admin needed: no **Route not found.** No operation exists at this path. Paths are case sensitive and resource names are plural and kebab-case. ## What it means The API returns HTTP 404 with `error.code` set to `route_not_found`. The default message is: "No such route." ## Common causes - A typo in the path. - A missing `/v1` prefix. ## How to identify it Check the response status (`404`) and `error.code` (`route_not_found`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Compare the path with the API reference. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing was stored (under an Idempotency-Key, if you sent one), so the request can be sent again, with the same key if you used one, once the cause is fixed. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "route_not_found", "message": "No such route.", "details": {}, "request_id": "req_tbneaumpmazply5t4aveppsl", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/route_not_found" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/service_unavailable # service_unavailable - HTTP status: 503 - Retry: After Retry-After - Execution: not started - Admin needed: no **Service temporarily unavailable.** A dependency the API needs to authorize or process the request is not available right now, so the request was refused before it ran. The API fails closed: it never skips a check because a dependency is down. Nothing was done and nothing is stored under the Idempotency-Key: a retry with the same key after `Retry-After` runs the request normally. ## What it means The API returns HTTP 503 with `error.code` set to `service_unavailable`. The default message is: "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header." ## Common causes - A brief infrastructure outage or a deploy in progress. ## How to identify it Check the response status (`503`) and `error.code` (`service_unavailable`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Wait `Retry-After` seconds and retry (with the same Idempotency-Key if you sent one). ## Retry guidance Wait for the number of seconds in the `Retry-After` response header, then retry. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and nothing is stored. Retry after Retry-After (with the same key if you sent one); the retry runs the request normally. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If it lasts more than a few minutes, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "service_unavailable", "message": "The service is temporarily unavailable. Retry after the number of seconds in the Retry-After header.", "details": {}, "request_id": "req_24w77nwppt27d3v6wwptvl47", "retry": "after", "doc_url": "https://docs.relationsai.com/errors/service_unavailable" } } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/spend_limit_reached # spend_limit_reached - HTTP status: 402 - Retry: No - Execution: not started - Admin needed: yes **Monthly spend limit reached.** A workspace admin set a monthly spend limit in the console and this month's calling spend has reached it. New calls are refused until the limit is raised or the month rolls over. ## What it means The API returns HTTP 402 with `error.code` set to `spend_limit_reached`. The default message is: "This workspace has reached the monthly spend limit set by its admin." ## Common causes - Calling spend this month reached the limit configured in the console. ## How to identify it Check the response status (`402`) and `error.code` (`spend_limit_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - A workspace admin raises or removes the limit in the console settings. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "spend_limit_reached", "message": "This workspace has reached the monthly spend limit set by its admin.", "details": {}, "request_id": "req_2mwpru2hc33wzcc4kk3dhwhu", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/spend_limit_reached" } } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/telephony_disconnected # telephony_disconnected - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: yes **Telephony connection down.** Before dialling a campaign the API checks that the workspace's telephony connection is healthy. It is not, so the campaign was set to `paused` and nothing was dialled. ## What it means The API returns HTTP 422 with `error.code` set to `telephony_disconnected`. The default message is: "The telephony connection for this workspace is not working, so the campaign was paused." ## Common causes - The telephony account connected in the console was disconnected, suspended or its credentials changed. ## How to identify it Check the response status (`422`) and `error.code` (`telephony_disconnected`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Reconnect the telephony account in the console, then start or resume the campaign again. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it A workspace administrator usually has to act, in the RelationsAI console. ## When to contact support If the console shows the connection as healthy, write to [support@relationsai.com](mailto:support@relationsai.com) with the request_id. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "telephony_disconnected", "message": "The telephony connection for this workspace is not working, so the campaign was paused.", "details": {}, "request_id": "req_jntj2br6copym7cpnivap4gm", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/telephony_disconnected" } } ``` ## Operations that can return this error - [`POST /campaigns/{campaign_id}/start`](/api/campaigns#start-a-campaign): Start a campaign - [`POST /campaigns/{campaign_id}/resume`](/api/campaigns#resume-a-campaign): Resume a campaign See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/unsupported_media_type # unsupported_media_type - HTTP status: 415 - Retry: No - Execution: not started - Admin needed: no **Unsupported media type.** The API accepts JSON bodies only. A body with any other `Content-Type` is refused before it is read. ## What it means The API returns HTTP 415 with `error.code` set to `unsupported_media_type`. The default message is: "Request bodies must be sent as application/json." ## Common causes - `Content-Type` missing or set to `application/x-www-form-urlencoded`, `multipart/form-data` or `text/plain`. ## How to identify it Check the response status (`415`) and `error.code` (`unsupported_media_type`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Send `Content-Type: application/json` with a JSON body. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Fix the request and send it again; if you sent an Idempotency-Key you may reuse it, because a refused request is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "unsupported_media_type", "message": "Request bodies must be sent as application/json.", "details": {}, "request_id": "req_ddqcfray35osp7oqwlonif36", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/unsupported_media_type" } } ``` ## Incorrect and corrected request Incorrect: A form-encoded body; the API reads JSON only. ```http POST /v1/contacts Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/x-www-form-urlencoded phone_number=%2B12025550100&name=Asha ``` Corrected: The same contact as a JSON body with Content-Type application/json. ```http POST /v1/contacts Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "phone_number": "+12025550100", "name": "Asha" } ``` ## Operations that can return this error - [`POST /calls`](/api/calls#place-a-call): Place a call - [`POST /campaigns`](/api/campaigns#create-a-campaign): Create a campaign - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`POST /campaigns/{campaign_id}/recipients`](/api/campaigns#add-recipients): Add recipients - [`POST /contacts`](/api/contacts#create-a-contact): Create a contact - [`POST /contacts/upsert`](/api/contacts#create-or-update-a-contact-by-phone-number): Create or update a contact by phone number - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact - [`POST /dnc-entries`](/api/do-not-call-list#add-a-number-to-the-do-not-call-list): Add a number to the do-not-call list - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint): Update a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/rotate-secret`](/api/webhook-endpoints#rotate-a-webhook-signing-secret): Rotate a webhook signing secret See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/validation_error # validation_error - HTTP status: 400 - Retry: No - Execution: not started - Admin needed: no **Validation failed.** The request was well formed JSON but one or more fields, query parameters or headers broke the documented rules. `details.errors` lists every problem found, not just the first. ## What it means The API returns HTTP 400 with `error.code` set to `validation_error`. The default message is: "One or more fields are invalid." ## Common causes - A required field is missing. - A field has the wrong type, is too long, or is outside its allowed values. - A field that the operation does not accept was sent (request bodies are strict). - An unknown query parameter or an invalid `limit`. ## How to identify it Check the response status (`400`) and `error.code` (`validation_error`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Read `details.errors[].field` and `details.errors[].issue`, correct each field and resend. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done. Fix the request and send it again; if you sent an Idempotency-Key you may reuse it, because a refused request is not stored. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "validation_error", "message": "One or more fields are invalid.", "details": { "errors": [ { "field": "agent_id", "issue": "required", "message": "agent_id is required." } ] }, "request_id": "req_os35ljoytapm22npytlktnvc", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/validation_error" } } ``` ## Incorrect and corrected request Incorrect: `priority` is not a field of the request; request bodies are strict, so the whole request is refused. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100", "priority": "high" } ``` Corrected: Only documented fields. Values your agent needs go in `variables`, your own references in `metadata`. ```http POST /v1/calls Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json { "agent_id": "agent_4Nf8Gt2Hx6Jq9Lm3Pv7Rs1", "to": "+12025550100" } ``` See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/version_conflict # version_conflict - HTTP status: 412 - Retry: No - Execution: not started - Admin needed: no **Version conflict.** The `If-Match` value is not the current `ETag`. Someone (or something, such as a running campaign) changed the resource after your read. ## What it means The API returns HTTP 412 with `error.code` set to `version_conflict`. The default message is: "The resource changed since you read it." ## Common causes - Another client or a console user updated the resource. - A campaign changed state while you were editing it. ## How to identify it Check the response status (`412`) and `error.code` (`version_conflict`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Retrieve the resource again, re-apply your change to the new version, and resend with the new `ETag`. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency PATCH does not use an Idempotency-Key. ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "version_conflict", "message": "The resource changed since you read it.", "details": {}, "request_id": "req_75h5mlew62uzae24hyvx2c2t", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/version_conflict" } } ``` ## Incorrect and corrected request Incorrect: The ETag is from a read made before someone else changed the campaign. ```http PATCH /v1/campaigns/cmp_3Uat7Kq2Lm9Px4Rv8Ny5Bc Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json If-Match: "9b1e0c4d2a77" { "name": "October renewals" } ``` Corrected: Retrieved again, the change re-applied to the current version, and sent with its new ETag. ```http PATCH /v1/campaigns/cmp_3Uat7Kq2Lm9Px4Rv8Ny5Bc Authorization: Bearer rai_test_YOUR_TEST_KEY Content-Type: application/json If-Match: "c3f1a9e2b7d4" { "name": "October renewals" } ``` ## Operations that can return this error - [`PATCH /campaigns/{campaign_id}`](/api/campaigns#update-a-campaign): Update a campaign - [`PATCH /contacts/{contact_id}`](/api/contacts#update-a-contact): Update a contact See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/webhook_delivery_not_retryable # webhook_delivery_not_retryable - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Delivery cannot be retried.** Only a delivery whose status is `failed` or `retrying` can be retried by hand. A pending delivery is about to be attempted, and a succeeded one is done. ## What it means The API returns HTTP 409 with `error.code` set to `webhook_delivery_not_retryable`. The default message is: "This delivery cannot be retried in its current status." ## Common causes - The delivery is `pending` or `succeeded` (`details.status`). - An attempt of this delivery is being made right now (`details.status` is `attempt_in_progress`). A retry then would send the same delivery twice at once. ## How to identify it Check the response status (`409`) and `error.code` (`webhook_delivery_not_retryable`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Retrieve the delivery list and retry only `failed` or `retrying` deliveries; for `attempt_in_progress`, wait a few seconds and read the delivery again. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "webhook_delivery_not_retryable", "message": "This delivery cannot be retried in its current status.", "details": { "status": "succeeded" }, "request_id": "req_muidnqs65gyrzxl5mswtz3fe", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_delivery_not_retryable" } } ``` ## Operations that can return this error - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/webhook_endpoint_disabled # webhook_endpoint_disabled - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Webhook endpoint disabled.** A disabled endpoint receives no deliveries, including test events and manual retries. ## What it means The API returns HTTP 409 with `error.code` set to `webhook_endpoint_disabled`. The default message is: "This webhook endpoint is disabled." ## Common causes - The endpoint `status` is `disabled`. ## How to identify it Check the response status (`409`) and `error.code` (`webhook_endpoint_disabled`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Enable it with PATCH /v1/webhook-endpoints/{webhook_endpoint_id} and `status: enabled`. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "webhook_endpoint_disabled", "message": "This webhook endpoint is disabled.", "details": {}, "request_id": "req_xldohvlaznkeqpckundwnl2q", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_endpoint_disabled" } } ``` ## Operations that can return this error - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/webhook_endpoint_limit_reached # webhook_endpoint_limit_reached - HTTP status: 409 - Retry: No - Execution: not started - Admin needed: no **Webhook endpoint limit reached.** Each workspace can have a limited number of webhook endpoints per mode (test and live counted separately). ## What it means The API returns HTTP 409 with `error.code` set to `webhook_endpoint_limit_reached`. The default message is: "This workspace already has the maximum number of webhook endpoints for this mode." ## Common causes - The limit in `details.limit` was reached. ## How to identify it Check the response status (`409`) and `error.code` (`webhook_endpoint_limit_reached`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Delete an endpoint you no longer use, or subscribe one endpoint to more event types. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "webhook_endpoint_limit_reached", "message": "This workspace already has the maximum number of webhook endpoints for this mode.", "details": { "limit": 16 }, "request_id": "req_suzyyih3gq3ckcpyqtcaxtal", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_endpoint_limit_reached" } } ``` ## Operations that can return this error - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/webhook_url_not_allowed # webhook_url_not_allowed - HTTP status: 422 - Retry: No - Execution: not started - Admin needed: no **Webhook URL not allowed.** A webhook URL must be an HTTPS URL on port 443, with no user name or password, whose host is not a RelationsAI host and resolves only to public internet addresses. The request schema already requires the URL to start with `https://`, so a URL with any other scheme is refused earlier with 400 `validation_error` (issue `invalid_format`) and never reaches this check. The URL is checked when the endpoint is created, when it is changed and again before every delivery; at delivery time a refusal is recorded on the delivery as `blocked_destination` instead. ## What it means The API returns HTTP 422 with `error.code` set to `webhook_url_not_allowed`. The default message is: "This URL cannot receive webhooks." ## Common causes - `port_not_allowed`: the URL names a port other than 443. - `credentials_in_url`: the URL contains a user name or a password. - `private_address`: the host is, or resolves to, an address that is not on the public internet: private (10/8, 172.16/12, 192.168/16, fc00::/7), loopback, link-local (including cloud metadata addresses), shared (100.64/10), multicast, broadcast, unspecified, reserved, benchmarking or documentation ranges, or an IPv6 form that embeds an IPv4 address (IPv4-mapped, IPv4-compatible, NAT64, 6to4, Teredo). If any one of several resolved addresses is not public, the URL is refused. - `invalid_host`: the host cannot be resolved, is not a valid public host name (for example a single label such as `localhost`, or a local-only suffix such as `.local` or `.internal`), or is a RelationsAI host (`relationsai.com` or any of its subdomains). ## How to identify it Check the response status (`422`) and `error.code` (`webhook_url_not_allowed`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Use a public HTTPS URL on port 443 that resolves only to public addresses. Authenticate deliveries by verifying the signature instead of putting credentials in the URL. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support If the error persists after the steps above, contact [support@relationsai.com](mailto:support@relationsai.com). See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "webhook_url_not_allowed", "message": "This URL cannot receive webhooks.", "details": { "reason": "private_address" }, "request_id": "req_e3n52eyuduiy2nswlicrxtjh", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhook_url_not_allowed" } } ``` ## Operations that can return this error - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`PATCH /webhook-endpoints/{webhook_endpoint_id}`](/api/webhook-endpoints#update-a-webhook-endpoint): Update a webhook endpoint See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /errors/webhooks_not_enabled # webhooks_not_enabled - HTTP status: 403 - Retry: No - Execution: not started - Admin needed: no **Webhooks not enabled.** Webhook delivery is in private preview and is switched on for the RelationsAI platform as a whole, separately from API access. While it is off, endpoints cannot be created, test events and manual retries are refused, and no events are sent. Existing endpoints can still be listed, read, changed and deleted. ## What it means The API returns HTTP 403 with `error.code` set to `webhooks_not_enabled`. The default message is: "Webhook delivery is not available yet." ## Common causes - Webhook delivery has not been switched on yet. ## How to identify it Check the response status (`403`) and `error.code` (`webhooks_not_enabled`). Branch on `error.code`, not on the message text: messages can be reworded, codes do not change within v1. Log `error.request_id` (also sent as the `X-Request-Id` header) so support can find the request. ## How to fix it - Write to support@relationsai.com to ask when webhook delivery will be available. ## Retry guidance Sending the request unchanged fails the same way. Fix the cause first (see How to fix it), then send it again. ## Did anything happen? This request was refused before any call was placed and before anything was charged. ## Idempotency Nothing was done and the refusal is not stored under the Idempotency-Key. Fix the cause, then send the same request (with the same key if you sent one). ## Who can fix it The developer who sends the request can fix it; no administrator action is normally needed. ## When to contact support Write to [support@relationsai.com](mailto:support@relationsai.com) to ask about webhook availability. See [Support](/resources/support) for what to include (the `request_id`, the time in UTC, the operation, and whether you used a test or live key) and what never to send (API keys, Authorization headers, webhook signing secrets, recording links, transcripts or customer data). ## Example error response ```json { "error": { "code": "webhooks_not_enabled", "message": "Webhook delivery is not available yet.", "details": {}, "request_id": "req_ptimzruklyowcpkngcofqwbu", "retry": "no", "doc_url": "https://docs.relationsai.com/errors/webhooks_not_enabled" } } ``` ## Operations that can return this error - [`POST /webhook-endpoints`](/api/webhook-endpoints#create-a-webhook-endpoint): Create a webhook endpoint - [`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event): Send a test event - [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery): Retry a delivery See all codes in the [error-code reference](/fundamentals/error-codes). --- Source: /resources/changelog # Changelog Changes to the API and the documentation, newest first. Each entry is labelled **Added**, **Changed**, **Deprecated**, **Removed** or **Fixed**. Additive changes (new operations, fields, enum values, event types and error codes) can ship at any time and are listed here; breaking changes need a new major version and at least six months' notice. See [Base URL and versioning](/fundamentals/base-url-and-versioning). ## 2026-10-21: API v1 private preview **Availability: Preview** API version `2026-10-21`. **Added.** The first release of API v1, for workspaces in the private preview ([request access](/getting-started/api-availability#getting-access)). **Availability: Available** - **Workspace**: retrieve the workspace, the current key, capabilities and limits. - **Agents**: list and retrieve agents, and check whether an agent can place calls (readiness). - **Phone numbers**: list and retrieve the workspace's numbers and the agents that use them. - **Calls**: list and retrieve calls, with status, outcome, timing and cost; read transcripts and analysis. - **Campaigns**: create, list, retrieve and update campaigns, add recipients, pause and stop. - **Contacts**: create, list, retrieve, update, delete, and create-or-update by phone number, with consent and custom fields. - **Do-not-call list**: add, list, look up and remove numbers. - Conventions for every operation: scoped `rai_test_` and `rai_live_` keys, the error envelope with `retry` hints, optional idempotency keys, cursor pagination, ETags on campaigns and contacts, rate limits with `RateLimit-*` headers, and `X-Request-Id` on every response. **Availability: Preview** - **Calls**: place a call (`POST /v1/calls`); retrieve recording links and download recording audio. - **Campaigns**: start and resume; list recipients. - **Webhooks**: webhook endpoints (create, list, retrieve, update, delete, rotate the signing secret, send a test event), deliveries (list, retry) and nine event types: `call.ended`, `call.settled`, `campaign.started`, `campaign.paused`, `campaign.resumed`, `campaign.stopped`, `campaign.completed`, `campaign.failed` and `webhook_endpoint.test`. **Availability: Test mode** - **Test mode** for every operation with `rai_test_` keys: simulated calls whose result is chosen by the destination number (`+12025550100` to `+12025550106`), simulated transcripts, recordings and analysis, test-mode campaigns, contacts, do-not-call entries and webhooks, a built-in Sample agent and sample number. Nothing is dialled or charged. **Availability: Console only** - Creating, configuring and publishing agents; adding and assigning phone numbers; creating, rotating and revoking API keys; members, billing and workspace settings. **Availability: Planned** - Creating, configuring and publishing agents through the API. Until then, agents are a console prerequisite. **Changed.** `Idempotency-Key` is optional on every `POST` and `DELETE`. When you send one, a retry of the same request replays the original answer; without one, every request runs, so check the state before retrying an action whose outcome you do not know. See [Idempotency](/fundamentals/idempotency). **Changed.** Older `sk_live_` keys keep working where they worked before, and are not accepted by API v1 ([`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported)). Create scoped keys in the platform under **Settings → API Keys**. **Added (docs).** These docs: guides, API fundamentals, the API reference, a page for every error code, and machine-readable versions ([llms.txt](/llms.txt), Markdown pages, [OpenAPI JSON](/openapi.json) and [YAML](/openapi.yaml)). --- Source: /resources/troubleshooting # Troubleshooting Start here when something does not work as expected. Each section describes a symptom, how to confirm the cause and how to fix it. For a specific error code, open its page from the [error-code reference](/fundamentals/error-codes). Three habits make every problem faster to solve: 1. **Read `error.code`**, not the HTTP status alone and never the message text. Each code links to its own page through `error.doc_url`. 2. **Read `error.retry`**: `no` means retrying as is cannot help. 3. **Keep `error.request_id`** (also the `X-Request-Id` header). Support needs it. When a key or workspace problem is suspected, ask the API who you are. This request returns the key's workspace, mode and scopes, or the exact error with its fix link: ```bash curl -X GET https://voice-api.relationsai.com/v1/workspace \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/workspace", { method: "GET", headers: { "Authorization": "Bearer ", }, }); const workspace = await response.json(); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/workspace", headers={ "Authorization": "Bearer ", }, timeout=30, ) workspace = response.json() ``` `200` shows the workspace, the key's id, mode and current scopes. An error shows its `error.code`, `error.request_id` and the link to its page in `error.doc_url`. Complete runnable example: [cURL script](/examples/resources/troubleshooting/check-key.sh) · [JavaScript](/examples/resources/troubleshooting/check-key.mjs) · [Python](/examples/resources/troubleshooting/check-key.py) ## Requests fail with 401 The key is missing, malformed or no longer valid. The code tells you which: - [`authentication_required`](/errors/authentication_required): No `Authorization` header, or not in the form `Bearer `. Send `Authorization: Bearer rai_test_...` (one space, no quotes). - [`invalid_api_key`](/errors/invalid_api_key): The key does not exist: a typo, a truncated copy, extra whitespace or a newline from a file. Copy the key again; trim whitespace when reading it from a file or variable. - [`api_key_expired`](/errors/api_key_expired): The key passed its expiry date. An admin creates a new key; plan rotations before expiry. - [`api_key_revoked`](/errors/api_key_revoked): An admin revoked the key. Use the replacement key. - [`legacy_api_key_not_supported`](/errors/legacy_api_key_not_supported): An older `sk_live_...` key. Create a scoped `rai_live_...` or `rai_test_...` key in the console. Check the environment your program really runs with: a stale variable in a shell, container or CI secret is the most common cause. ## Requests fail with 403 The key is valid but not allowed to do this. Five different causes: - [`insufficient_scope`](/errors/insufficient_scope): The key was created without a scope this operation needs; `details.required_scopes` lists them. An admin creates a key that includes those scopes. - [`insufficient_permissions`](/errors/insufficient_permissions): The scope is on the key, but the member who created it lost that permission (role change, left the workspace). An admin creates a new key from an account that has the permission. - [`api_access_not_enabled`](/errors/api_access_not_enabled): The workspace is not enabled for the API (private preview). Write to [support@relationsai.com](mailto:support@relationsai.com). - [`ip_not_allowed`](/errors/ip_not_allowed): The workspace IP allowlist does not include your server. An admin adds your egress address in the console security settings. - [`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser): A live key sent with an `Origin` header, which browsers add. Call the API from your server; use a test key for browser experiments. Also 403, but about the account or a feature rather than the key: [`account_inactive`](/errors/account_inactive) (trial ended or account suspended; reads still work) and [`webhooks_not_enabled`](/errors/webhooks_not_enabled) (webhook delivery is not available yet; check `capabilities.webhooks`). ## An id returns 404 [`resource_not_found`](/errors/resource_not_found) means "no such object **for this key**". The API answers the same way for an id that does not exist, an id from the other mode, an id from another workspace and a malformed id, so as not to reveal anything. Check, in order: 1. **The mode.** Test and live data are separate: a call, contact, campaign or webhook endpoint created with a test key does not exist for a live key, and the reverse. Only agents and phone numbers are shared (and the Sample agent and sample number exist only in test mode). 2. **The workspace.** Is this key from the same workspace that created the object? 3. **The id itself.** Copy it whole, with its prefix (`call_`, `cmp_`, `ct_`...). Ids are opaque: do not build or shorten them. [`route_not_found`](/errors/route_not_found) means the path does not exist: usually a base URL without `/v1`, a typo in the path, or a path from another API. [`method_not_allowed`](/errors/method_not_allowed) (405) means the path exists with another method. ## Requests fail with 400 or 422 validation errors - [`validation_error`](/errors/validation_error) (400): A field is missing, unknown, of the wrong type or out of range. Bodies are strict: unknown fields are refused. `details.errors` lists each problem with `field`, `issue` and `message`. Fix each listed field. - [`invalid_phone_number`](/errors/invalid_phone_number) (400): Not E.164. `+`, country code, number, no spaces or punctuation: `+919000000001`. - [`invalid_json`](/errors/invalid_json) (400): The body is not valid JSON. Serialise with your JSON library, do not build strings by hand. - [`unsupported_media_type`](/errors/unsupported_media_type) (415): Missing or wrong `Content-Type`. Send `Content-Type: application/json`. - [`invalid_idempotency_key`](/errors/invalid_idempotency_key) (400): You sent an `Idempotency-Key` (optional) with spaces, non-ASCII characters or is too long. 1 to 255 printable ASCII characters; a UUID is ideal. - [`invalid_cursor`](/errors/invalid_cursor) (400): A cursor was changed, or reused with different filters. Send `next_cursor` unchanged, with the same filters. - [`payload_too_large`](/errors/payload_too_large) (413): The body is larger than 1 MB. Split the request. (Too many items in a list, for example more than 1000 recipients, is a `400 validation_error` instead.). - [`custom_field_not_found`](/errors/custom_field_not_found) (422): A custom field name is not defined in the console. Define it, or fix the name. ## Conflicts: 409, 412 and 428 **Idempotency conflicts** happen only when you send the optional `Idempotency-Key` and it was already used: - [`idempotency_key_reused`](/errors/idempotency_key_reused): the same key with a different request. You probably reused a key for a new action, or the body changed between retries (a timestamp in the body, a re-ordered list). Use a new key for a new action; keep retry bodies identical. - [`idempotency_key_in_progress`](/errors/idempotency_key_in_progress): the first attempt is still running. Wait `Retry-After`, then resend the same request with the same key. - [`idempotency_outcome_unknown`](/errors/idempotency_outcome_unknown): the first attempt was interrupted and may or may not have taken effect. Check the state before acting ([Retry and reliability practices](/guides/retry-and-reliability#when-the-outcome-is-unknown)). **State conflicts** mean the object is not in a state that allows the action: [`campaign_state_conflict`](/errors/campaign_state_conflict) (read the campaign's `capabilities`), [`campaign_operation_in_progress`](/errors/campaign_operation_in_progress) (another start or resume is running), [`contact_already_exists`](/errors/contact_already_exists) (use upsert), [`contact_on_dnc_list`](/errors/contact_on_dnc_list), [`webhook_endpoint_disabled`](/errors/webhook_endpoint_disabled), [`webhook_delivery_not_retryable`](/errors/webhook_delivery_not_retryable). **Concurrency**: [`precondition_required`](/errors/precondition_required) (428) means a PATCH without `If-Match`; [`version_conflict`](/errors/version_conflict) (412) means the object changed since you read it. Read it again, reapply your change, and send the new `ETag` ([Concurrency](/fundamentals/concurrency)). ## The agent is not ready [`agent_not_ready`](/errors/agent_not_ready) lists every reason in `details.reasons`. Call [`GET /agents/{agent_id}/readiness`](/api/agents#check-whether-an-agent-can-place-calls) for the same list with explanations, and fix them in the console: switch the agent on, publish it, assign an outbound number ([Creating and preparing agents](/guides/creating-and-preparing-agents)). Related refusals about calling setup: [`phone_number_required`](/errors/phone_number_required) (the agent has several outbound numbers: send `phone_number_id`), [`phone_number_not_usable`](/errors/phone_number_not_usable) (that number cannot call out for this agent), [`calling_not_configured`](/errors/calling_not_configured) (`details.reason` names the missing setup step), [`telephony_disconnected`](/errors/telephony_disconnected) (reconnect the telephony account in the console). ## Calls are refused A refused call was not placed and not charged. The code says which rule applied: - [`dnc_listed`](/errors/dnc_listed): The number is on the do-not-call list. Do not call it. - [`consent_required`](/errors/consent_required): Consent is required and the contact has none. Record consent only if it was given. - [`outside_calling_hours`](/errors/outside_calling_hours): Outside the workspace calling window (`details.window`, `details.timezone`) Retry inside the window; the timezone is the workspace's, not the contact's. - [`contact_daily_limit_reached`](/errors/contact_daily_limit_reached): Too many calls to this number in 24 hours. Try later. - [`call_not_accepted`](/errors/call_not_accepted): The telephony network definitively refused the call. Check the number; contact support if valid numbers keep being refused. See [Consent and DNC](/guides/consent-and-dnc). Never route around a refusal. ## Balance and limits - [`insufficient_balance`](/errors/insufficient_balance) (402): The balance cannot cover the call or campaign; `details` shows both amounts. An admin tops up in the console. - [`spend_limit_reached`](/errors/spend_limit_reached) (402): The monthly spend limit set in the console is reached. An admin raises it. - [`campaign_budget_reached`](/errors/campaign_budget_reached) (402): The campaign reached its `budget_cap` (spend can pass the cap by up to about 15 minutes of calls before the pause). Raise or clear the cap with PATCH, then start or resume. - [`daily_call_limit_reached`](/errors/daily_call_limit_reached) (429): The workspace's daily limit of calls placed through the API (live mode, resets 00:00 UTC) Wait for the reset; check for loops; ask support if the volume is expected. - [`calling_capacity_reached`](/errors/calling_capacity_reached) (429): Too many call starts right now; nothing was dialled. Wait `Retry-After` and send it again; use a campaign for volume. - [`contact_limit_reached`](/errors/contact_limit_reached) (409): The contact store is at its current capacity. Delete contacts you no longer need, or contact support. ## A campaign did not resume - [`campaign_recipients_blocked`](/errors/campaign_recipients_blocked) (409): Remaining recipients are now blocked (do-not-call, consent, daily limit); `details` lists them. Do not stop and start it (a new start calls every previous recipient again, including people already reached). List the recipients with `status=pending`, create a new campaign with them minus the blocked ones, and stop this one. - [`campaign_not_resumable`](/errors/campaign_not_resumable) (409): The paused run can no longer continue. To reach only the people not called yet, create a new campaign with the pending recipients (a new start of this campaign calls everyone again). - [`campaign_recipients_unavailable`](/errors/campaign_recipients_unavailable) (503): Recipients could not be screened right now; nothing was dialled. Wait `Retry-After` and send it again; if it keeps failing on a very large campaign, create a new campaign with the pending recipients. - [`campaign_outcome_unknown`](/errors/campaign_outcome_unknown) (409): A start or resume was sent and not confirmed; dialling may have begun. Wait, then read the campaign; send the action again only if it did not start (with a new key if you use idempotency keys). - [`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled) (409): Do-not-call screening is switched off for this campaign in the console, so the API refuses it. An admin switches it back on, or starts it from the console. - [`campaign_state_conflict`](/errors/campaign_state_conflict) (409): The campaign is not `paused`. Read its `status` and `capabilities`. If a campaign paused by itself, the `campaign.paused` event's `data.reason` says why (budget, balance, spend limit, telephony, failure streak). Fix that first. A pause that answered [`campaign_pause_not_confirmed`](/errors/campaign_pause_not_confirmed) may still be dialling: read the campaign, then pause again or stop the campaign if it is still running. ## An artifact is not ready The transcript, recording or analysis answers `200` with `state: "pending"`: the call has not settled. Wait for `call.settled`. `state: "absent"` means there is none (the call did not connect, or nobody spoke). A recording link that answers [`media_link_expired`](/errors/media_link_expired) (410) passed its `expires_at` (about 15 minutes): read the recording again for a fresh link and download it straight away. A link that answers `404` before its `expires_at` belongs to a key that was revoked, expired, lost `recordings:read` or whose creator left the workspace: get a new link with a valid key. See [Retrieving recordings and transcripts](/guides/retrieving-recordings-and-transcripts). ## Webhooks do not arrive or fail verification Work through these in order: 1. **Is delivery enabled?** `capabilities.webhooks` on [`GET /workspace`](/api/workspace#retrieve-the-workspace) must be `true`. 2. **Right mode, events and status?** Test-mode events go only to endpoints created with a test key, live to live. Check the endpoint's `livemode`, `events` and `status: enabled`. Events that occur while an endpoint is disabled are not delivered later. 3. **What does the delivery log say?** [`GET /webhook-endpoints/{webhook_endpoint_id}/deliveries`](/api/webhook-endpoints#list-deliveries): `error` is `timeout` (answer within 10 seconds), `connection_failed`, `tls_error` (check your certificate chain), `blocked_destination` (the URL resolves to a non-public address) or `http_error` with your server's `response_status`. 4. **Send a test event** ([`POST /webhook-endpoints/{webhook_endpoint_id}/test`](/api/webhook-endpoints#send-a-test-event)) and watch your server's logs. 5. **Signature mismatch** (your server answers `400`): verify the **raw** body before parsing, use the whole `whsec_...` secret as the key, accept any of several `v1` values, and check your clock is within 5 minutes. Check `secret_last4` against the secret you deployed. The verifier's command-line mode checks a saved delivery ([Verifying webhook signatures](/guides/verifying-webhook-signatures)). 6. **Late or bursty deliveries**: after 5 failed attempts in a row an endpoint is paused (1 minute, doubling up to 1 hour), at most 2 attempts run at once per endpoint, and test-mode deliveries are limited to 60 a minute per workspace. The backlog drains on its own once your endpoint answers `2xx` again; paused time does not use up attempts. 7. **Lost signing secret**: a create or rotate retried with the same optional idempotency key returns `secret: null`. Rotate again and deploy that secret ([Verifying webhook signatures](/guides/verifying-webhook-signatures#during-secret-rotation)). 8. **Redirects** are not followed: register the final HTTPS URL. Frameworks that require authentication or CSRF tokens on every POST must exempt the webhook path. After fixing, redeliver `failed` deliveries with [`POST /webhook-deliveries/{webhook_delivery_id}/retry`](/api/webhook-endpoints#retry-a-delivery), and run a reconciliation for anything older. ## Requests fail with 429 [`rate_limited`](/errors/rate_limited) means a rate limit for the key or the workspace was exceeded in the operation's class (`read`, `write` or `dial`). Wait the seconds in `Retry-After`, then slow down: read `RateLimit-Remaining` and `RateLimit-Reset` on every response, pace batch jobs, give each component its own key, and use webhooks and list requests instead of polling. Other keys of the same workspace share the workspace limit. The 429 codes [`daily_call_limit_reached`](/errors/daily_call_limit_reached) and [`calling_capacity_reached`](/errors/calling_capacity_reached) are not rate limits; see [Balance and limits](#balance-and-limits). Requests that are refused before they reach an operation (an invalid or missing key, an unknown route, a wrong method, a malformed body, an IP that is not allowed) are also limited **per IP address**, about 300 per minute. A script stuck on `401`s can therefore get `429 rate_limited` for every request from that machine, even with a valid key. Fix the underlying error first, and stop retrying `4xx` answers in a loop. ## Requests fail with 5xx - [`service_unavailable`](/errors/service_unavailable) (503): a dependency is unavailable and the request was refused before it ran. The API fails closed: it never skips a check because something is down. Send it again after `Retry-After`. - [`internal_error`](/errors/internal_error) (500): an unexpected fault. For a write, it **may** have been applied, and a retry (with or without an idempotency key) runs the request again. Read the resource first (for a call, list calls by `to` and `created_after` and match your `metadata`); retry with the same key only if it did not happen. - If either persists for more than a few minutes, write to [support@relationsai.com](mailto:support@relationsai.com) with the request ids. ## Getting help Email [support@relationsai.com](mailto:support@relationsai.com) with: - the `request_id` of one or more failing requests (from the error body or the `X-Request-Id` header), - the time in UTC and the operation (method and path), - the mode (test or live) and the key id (`key_...`, from [`GET /workspace`](/api/workspace#retrieve-the-workspace); never the key itself), - what you expected and what happened. Never send API keys or full `Authorization` headers, webhook signing secrets, recording links, customer personal data (names, phone numbers, contact details) or transcripts, recordings and analysis of real calls; send ids (`key_...`, `call_...`) instead. See [Support](/resources/support). --- Source: /resources/faq # FAQs Short answers to the questions developers ask most often, with links to the full explanation. ## Is there a free sandbox? Yes. Test keys (`rai_test_...`) use test mode: nothing is dialled, nothing is charged, and every object has `"livemode": false`. Calls are simulated, and the destination number chooses the outcome (`+12025550100` completes, `+12025550101` is not answered, and so on), with a fixture transcript, a short synthetic recording and analysis. Test mode also has a built-in Sample agent and sample number, so you can try everything before your own agent is ready. Compliance rules apply in test mode too. See [Test mode](/getting-started/test-mode). ## Is the API generally available? Not yet. The API is in **private preview** and is enabled per workspace by RelationsAI; a workspace that has not been enabled answers `403 api_access_not_enabled`. Webhook delivery is in preview too and can be unavailable even when the API is enabled: check `capabilities.webhooks` on `GET /workspace`. To request access, write to [support@relationsai.com](mailto:support@relationsai.com). Operations marked Preview in the [API reference](/api) are the most likely to change before general availability. See [API availability](/getting-started/api-availability). ## Will the API change under me? Additive changes (new operations, fields, enum values, event types and error codes) ship without a new version and are listed in the [changelog](/resources/changelog). Write your code to tolerate them: ignore unknown fields and treat unknown enum values as "other". Breaking changes need a new major version in the path and at least six months' notice. Events carry an `api_version`. ## Can I create agents through the API? Not yet. Agents are created, configured and published in the console; the API reads them and checks their readiness. Creating and editing agents through the API is planned for a later version. See [Creating and preparing agents](/guides/creating-and-preparing-agents). ## Can I hang up a call, receive inbound calls or read a transcript during the call? Not through the API today. The workspace object reports these as capabilities (`end_call`, `inbound_calls`, `live_transcript`, all `false` today), so your code can check instead of assuming. Transcripts are available after the call ends; inbound calls do not appear in the call list. ## Does the analysis include sentiment? No. Analysis has a summary, the fields the agent is configured to extract, and whether the goal was met. `capabilities.sentiment_analysis` is `false`. See [Working with analysis results](/guides/working-with-analysis-results). ## Can I use the API from a browser? Test keys only, for experimentation (the docs' API explorer accepts test keys only). Live keys are refused from browsers with `403 live_key_not_allowed_in_browser`: a key in browser code is visible to anyone who opens the page. Call the API from your server. ## How do I avoid placing a call twice? Every request is executed, so do not resend a call blindly. Put your own id in `metadata`, and if you did not see the answer, list calls with `to` and `created_after` and look for it before sending again. For extra safety, send an optional `Idempotency-Key` and reuse it on every retry of that call: a retry of a request that already succeeded then returns the original response instead of placing another call. See [Idempotency](/fundamentals/idempotency) and [Retry and reliability practices](/guides/retry-and-reliability). ## My request timed out. Was the call placed? Maybe. Check before sending it again: list calls with `to` and `created_after` and look for your `metadata`. If you sent an idempotency key, you can instead resend the same request with the same key: if the first attempt went through you get its response back (with `Idempotent-Replayed: true`). Never assume a timed-out request had no effect. ## Why does POST /calls answer 202 and not 200? Because the call happens after your request returns. `202` means the call passed every check and was handed over to be placed. The result comes later through `call.ended` and `call.settled`, or by reading the call. See [Making an outbound call](/guides/making-an-outbound-call). ## When are the transcript, recording and analysis ready? After the call ends; wait for `call.settled`, which means everything is final, including the charge. In test mode that is about 30 seconds after the call is created. In live mode artifacts are usually ready within a minute, sometimes several minutes later. ## Why do I get 404 for an id I just used? Usually the mode: test and live data are completely separate, so an id created with a test key is not found with a live key, and the reverse. The API answers `404 resource_not_found` for ids from another mode or workspace, exactly as for ids that do not exist. See [Troubleshooting](/resources/troubleshooting#an-id-returns-404). ## Can API keys turn off the do-not-call list or consent checks? No. The do-not-call list, consent, calling hours and the per-number daily limit are applied to every call and every campaign start and resume, and no key, scope or request option can switch them off. The API also refuses to start or resume a campaign whose do-not-call screening a person switched off in the console (`409 campaign_dnc_override_enabled`). One timing detail: a campaign that is already running is not re-checked before each call, so a number added to the list mid-run is applied when you pause and resume the campaign, or stop it. Compliance settings are changed by admins in the console. See [Consent and DNC](/guides/consent-and-dnc). ## Can I manage API keys through the API? No. Keys are created, rotated and revoked by workspace admins in the platform, under **Settings → API Keys** ([Get an API key](/getting-started/api-keys#get-an-api-key)). The API can tell you about the key you are using ([`GET /workspace`](/api/workspace#retrieve-the-workspace)), never create one. ## Are webhooks delivered in order, and exactly once? No to both. Delivery is at least once and unordered. Deduplicate by the event `id`, and never let an older snapshot overwrite newer state. Failed deliveries are retried for about a day, longer when your endpoint keeps failing: after 5 failures in a row it is paused (up to an hour at a time) without using up the deliveries' attempts. See [Receiving webhooks](/guides/receiving-webhooks). ## Is there an endpoint that lists past events? Not in v1. To troubleshoot, use the delivery log of each endpoint (kept for 30 days) and redeliver failed deliveries. To catch up after an outage, reconcile with the list endpoints (calls, campaigns, recipients), which always reflect the current state. ## How much does a call cost? Live calls are charged per started minute of talk time at your workspace rate, from the workspace balance; a call that is not answered is not charged. Each call reports `billable_minutes` and `cost` (in minor units of the workspace currency) once settled. Test mode never charges. Rates and balance are managed in the console. ## Are there official SDKs? Not today. The API is plain HTTPS and JSON, and every guide has complete examples for cURL, Node.js and Python. The OpenAPI 3.1 document is published at [/openapi.json](/openapi.json) and [/openapi.yaml](/openapi.yaml) if you want to generate a client. ## Is there a status page? There is no public status page. If the API is unavailable you get `503 service_unavailable` with `Retry-After`; if a problem lasts more than a few minutes, write to [support@relationsai.com](mailto:support@relationsai.com) with request ids. See [Status](/resources/status). ## How do I get help? Write to [support@relationsai.com](mailto:support@relationsai.com) with the `request_id` of a failing request, the time in UTC, the operation and the mode. Never send API keys or full `Authorization` headers, webhook signing secrets, recording links, customer personal data (names, phone numbers, contact details) or transcripts, recordings and analysis of real calls; send ids (`key_...`, `call_...`) instead. See [Support](/resources/support). --- Source: /resources/status # Status and service availability This page explains how to tell whether a problem is on your side or ours, how the API reports temporary unavailability and how to get help. RelationsAI does not publish a public status page for the API preview; the API itself tells you when it is temporarily unavailable, and support is by email. ## Check the API from your side [`GET /workspace`](/api/workspace#retrieve-the-workspace) is the best health check: it is a cheap read, needs only the scope every key has, and exercises authentication, your workspace and the API in one request. Look at the status code and the `X-Request-Id` response header (add `-i` to cURL to print the headers). ```bash curl https://voice-api.relationsai.com/v1/workspace \ -H "Authorization: Bearer " ``` ```js const response = await fetch("https://voice-api.relationsai.com/v1/workspace", { headers: { Authorization: "Bearer ", }, }); console.log(response.status, await response.json()); ``` ```python response = requests.get( "https://voice-api.relationsai.com/v1/workspace", headers={ "Authorization": "Bearer ", }, timeout=30, ) print(response.status_code, response.json()) ``` | What you see | What it means | | --- | --- | | `200` with an `X-Request-Id` | The API is reachable and your key works. | | `401` or `403` with an error code | The API is up; the problem is the key or the workspace. See [Authentication](/getting-started/authentication). | | `429` | The API is up; you are over a limit. See [Rate limits](/fundamentals/rate-limits). | | `503` [`service_unavailable`](/errors/service_unavailable) with `Retry-After` | Temporarily unavailable on our side. Retry after the delay. | | `500` [`internal_error`](/errors/internal_error) | An unexpected error on our side. It is not stored under an `Idempotency-Key`, so a retry runs the request again. Retry reads with backoff; for a write, check whether the change was applied first, then retry with the same key only if it was not. | | No response, a timeout, a TLS or DNS error, or an error page that is not JSON | The request did not reach the API, or the answer did not reach you. Check your network, proxy and DNS first; then check whether a write already happened before sending it again (or retry with the same `Idempotency-Key`, if you sent one). | Every answer that comes from the API carries an `X-Request-Id` header and, for errors, the JSON error envelope. An answer without them did not come from the API. ## Temporary unavailability When a service the API depends on is briefly unavailable (for example during maintenance or a deploy), the API answers [`service_unavailable`](/errors/service_unavailable) (503) with a `Retry-After` header. The API **fails closed**: it refuses the request before doing anything rather than skipping a check, so nothing was created, dialled or charged. - Wait `Retry-After` seconds and send the request again: nothing was done the first time. - Back off further if it repeats, and alert a person if it lasts more than a few minutes. - Do not switch to new idempotency keys or replay old work: once the API answers again, retries with the original keys complete each operation exactly once. Calls and campaigns that were already running continue independently of your API requests. Read their state once the API answers again; webhook deliveries that failed in the meantime are retried on their [schedule](/fundamentals/webhooks#retry-schedule). ## Incidents If errors persist for more than a few minutes, email [support@relationsai.com](mailto:support@relationsai.com) with: - the request ids (`X-Request-Id`) of a few failing requests; - when they happened, in UTC (for example `2026-10-21T09:30:12Z`); - the operations, the HTTP statuses and the error codes; - whether it affects test mode, live mode or both. Never send API keys or full `Authorization` headers, webhook signing secrets, recording links, customer personal data (names, phone numbers, contact details) or transcripts, recordings and analysis of real calls; send ids (`key_...`, `call_...`) instead. See [Support](/resources/support) for everything to include. There is no phone line or chat, and no response-time commitment is published for the preview. --- Source: /resources/security-and-data-handling # Security and data handling This page describes how API access is protected and how call data available through the API (transcripts, recordings, analysis and contact data) is handled. For what you should do in your own integration, see [Security practices](/fundamentals/security-practices). ## Access control - **Scoped keys.** Every key is limited to the scopes chosen when it was created, and never does more than the member who created it may do right now. A creator who leaves the workspace takes the key's access with them. Keys cannot change workspace settings, compliance settings, members, billing or other keys. - **Expiring keys.** Live keys always expire (30 to 365 days). Rotation keeps the old key working only for the grace period you choose; revocation is immediate. - **Keys are shown once** and the console only ever shows a prefix and the last four characters afterwards. - **One workspace, one mode.** A key reads and changes only its own workspace's data, in its own mode. Ids from another workspace or from the other mode answer [`resource_not_found`](/errors/resource_not_found), so a request cannot even learn whether they exist. - **Test and live separated.** Test mode never dials, charges or touches live data, and test data is invisible to live keys. - **No live keys in browsers.** A live key sent from a browser is refused before it is checked ([`live_key_not_allowed_in_browser`](/errors/live_key_not_allowed_in_browser)). - **IP allowlist.** Workspaces can restrict API access to their own addresses in the console security settings ([`ip_not_allowed`](/errors/ip_not_allowed)). - **Fails closed.** When the API cannot complete an access check, it refuses the request ([`service_unavailable`](/errors/service_unavailable)) rather than letting it through. - **Attributed.** Every API request is attributed to the key that made it, and the console shows each key's last use and recent request counts. - **HTTPS only**, for the API, recording links and webhook deliveries. ## Data in API responses Responses return your workspace's data **in full** to keys of that workspace: phone numbers as stored, contact names and custom fields, verbatim transcripts and call analysis. The API does not mask it, because your systems need the real values to act on them. Protect it in your systems accordingly: restrict which keys have `transcripts:read`, `recordings:read` and `contacts:read`, and which people can read what those keys fetch. Error messages never include personal data, internal details or stack traces. ## Recordings - Recordings are reached through short-lived links: [`GET /calls/{call_id}/recording`](/api/recordings#retrieve-a-call-recording-link) returns a fresh `url` that works for about 15 minutes (`expires_at`). - The link itself is the credential: it needs no API key. Anyone with the link can download the audio until it expires, so do not log, store or forward it. - A link is tied to the key that obtained it: it stops working (`404`) as soon as that key is revoked or expires, loses `recordings:read`, or its creator leaves the workspace. - An expired link answers [`media_link_expired`](/errors/media_link_expired); ask for a new one. ## Compliance controls Calls from the API, campaigns and the console, in test mode and in live mode, pass the same checks: the do-not-call list, consent when the workspace requires it (for numbers that match a contact), calling hours and the per-number daily limit. A call is checked when it is placed; a campaign when it starts and when it resumes, not again while it runs. API keys cannot switch the checks off, and the API refuses to start or resume a campaign whose do-not-call screening was switched off in the console ([`campaign_dnc_override_enabled`](/errors/campaign_dnc_override_enabled)). Removals from the do-not-call list are audited. An agent cannot be published unless its instructions tell the people it calls that they are speaking with an AI. ## Metadata, variables and webhooks - `metadata` you attach to a call is stored and returned as sent. It is never sent to the person called and never used by the agent. Do not put secrets in it. - `variables` are passed to the agent for that call and can be spoken in the conversation. - Webhook events carry the same data as the API (for example a call snapshot with the phone numbers) and are signed with a secret only you and RelationsAI hold. Webhook deliveries are kept for 30 days for troubleshooting. ## Questions For security or data-protection questions, including how long call data is kept for your workspace, contact [support@relationsai.com](mailto:support@relationsai.com). To report a suspected vulnerability, put "Security" in the subject and do not include real customer data or live credentials. --- Source: /resources/support # Support Email is the support channel for the RelationsAI API. This page explains what to include so we can help quickly, and what you must never send. ## Contact Email [support@relationsai.com](mailto:support@relationsai.com). There is no chat or phone support for the API, and no response-time commitment is published during the private preview. Write to support to: - request API access for a workspace during the preview; - report a problem with the API, an unexpected error or behaviour that does not match these docs; - ask for a higher daily call limit or rate limit for an expected workload; - report a suspected security issue (put "Security" in the subject); - report a mistake in these docs (include the page URL). Before writing, check the error's page in the [error-code reference](/fundamentals/error-codes) and the [Troubleshooting](/resources/troubleshooting) page: most errors say exactly what to change. ## What to include | Include | Example | | --- | --- | | The request id, from the `X-Request-Id` header or `error.request_id` | `req_4k7m2qat5xvz3c6vqnwpbdfh` | | When it happened, in UTC | `2026-10-21T09:30:12Z` | | The operation: method and path | `POST /v1/calls` | | The mode | test or live | | The HTTP status and error code | `422 agent_not_ready` | | Ids of the objects involved | `call_...`, `cmp_...`, `agent_...`, `key_...` | | A short description: what you did, what you expected, what happened | "The call stayed `queued` for 10 minutes." | | A sanitised copy of the request | Method, path, non-secret headers and the body with personal data replaced | The request id is the most useful thing you can send: with it we can find the exact request. If you have several failing requests, send a few request ids. A sanitised request looks like this: ```http POST /v1/calls Authorization: Bearer [REDACTED] Idempotency-Key: 00000000-0000-4000-8000-000000000001 Content-Type: application/json {"agent_id": "agent_3hT9kQ2mVx7RbN4pLc8Wz1", "to": "+12025550100", "variables": {"first_name": "[NAME]"}} ``` ## Never send secrets or customer data Never include any of these in a support email, attachment or screenshot: - **API keys** (`rai_test_...`, `rai_live_...`, `sk_live_...`) or full `Authorization` headers. Send the key id (`key_...`) instead. - **Webhook signing secrets** (`whsec_...`). - **Recording links** (`url` from the recording endpoint): they work without a key until they expire or the key that obtained them is revoked. - **Customer personal data**: real names, phone numbers, contact details or custom field values of the people you call. - **Private transcripts, recordings or analysis** of real calls. Replace them with placeholders such as `[REDACTED]` or use the fictional test numbers. Ids, request ids and timestamps are enough for us to find what we need in your workspace. If a key or secret was sent by mistake, rotate it in the console right away.