Making an outbound call
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 in live mode, so the guide is careful about retries.
Before you begin
- An API key with
calls:write(andcalls:readto 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. - Your key in place of
<token>in the examples. Every request goes tohttps://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).
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.
/callsComplete runnable example
Place one outbound call.
The same steps as a complete script, with error handling, tested against test mode on every docs build. Each script reads your test key from an environment variable named in its first lines.
- cURL script (bash)
create-call.sh - JavaScript (Node.js 20+)
create-call.mjs - Python 3.9+ (requests)
create-call.py
What the response means
A successful request answers 202 Accepted with the call object:
JSON
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:
queued: accepted and being placed.ringing: not reported today. The workspace'scapabilities.call_ringing_statusisfalse, so a call goes fromqueuedstraight toin_progressor toended. Do not wait forringing.in_progress: the person answered and the conversation is happening.ended:outcome,end_reasonandduration_secondsare known. Thecall.endedevent is sent.- Artifacts ready: the transcript, recording and analysis become
ready(orabsentwhen there is none, for example a call nobody answered). - Settled:
settledbecomestruewhen outcome, duration, artifacts and the charge are all final, and thecall.settledevent is sent. Treatoutcome,duration_secondsandcostas 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.
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, 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. List your numbers with GET/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 and the full pattern in Retry and reliability practices.
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; resets at 00:00 UTC). A refused call is not dialled and not charged.
Responses
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 means an earlier attempt may have placed the call (find out whether it exists first), and idempotency_key_reused means the key belongs to a different request (use a new key).
Fix the request (nothing was done):
validation_error(400): a field is missing, unknown or out of range;details.errorslists each one.invalid_phone_number(400):tois not E.164. Send+and the country code.resource_not_found(404):agent_idorphone_number_idis not in this workspace and mode. Use ids read with a key of the same workspace and mode.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(422):details.reasonssays why (readiness).phone_number_required(422): the agent has several outbound numbers; sendphone_number_id.phone_number_not_usable(422): that number cannot place outbound calls for this agent; pick another or omit it.calling_not_configured(422): calling setup is incomplete;details.reasonnames the step.insufficient_balance(402),spend_limit_reached(402),account_inactive(403): top up, raise the limit, or upgrade.
Respect the refusal (the rules protecting the person; see Consent and DNC):
dnc_listed(422): the number is on your do-not-call list. Do not call it.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(422): outside the calling window indetails.windowanddetails.timezone. Retry inside it.contact_daily_limit_reached(422): this number was called the maximum times in 24 hours.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(409): only with an idempotency key: the first attempt is still running. WaitRetry-After.calling_capacity_reached(429): too many call starts right now; nothing was dialled. WaitRetry-After.rate_limited(429): over thedialrate limit. WaitRetry-After.service_unavailable(503): a dependency is down; nothing was done. WaitRetry-After.internal_error(500): unexpected, and the call may have been accepted. Check first: list calls withtoandcreated_afterand match yourmetadata; send it again only if the call is not there.
Stop and check first:
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).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.
Check it works in test mode
- Send the request above with the Sample agent and
"to": "+12025550100". You get202and"status": "queued". - List calls with
toset to+12025550100: your call is there with yourmetadata. (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.) - Try
TO=+12025550101and follow the call: it ends withoutcome: no_answerandartifacts.recording.state: absent. - Add
+12025550107to the do-not-call list (Consent and DNC) and call it:422 dnc_listed, exactly as in live mode.