Calls
Place an outbound AI voice call with a script and the answers you want back, read what happened, and hang it up — the three endpoints that drive the phone from your own systems.
/api/v1/callsYour agent picks up the phone and calls someone. The request returns in well under a second; the conversation takes minutes. Everything about using this endpoint follows from that gap.
When you use this #
A call is the right tool when the answer needs a person's attention now and a message would sit unread — a technician visit that needs confirming today, an AMC that lapses this week, a high-value order that stalled at checkout, a lead that filled a form ninety seconds ago. Your CRM or workflow engine already knows the moment; this endpoint is the trigger.
The whole surface is three endpoints:
| Method | Path | Purpose |
|---|---|---|
POST |
/api/v1/calls |
Place a call. Returns while the phone is still ringing. |
GET |
/api/v1/calls/{call_id} |
Read the current state and, once it has been read, what came of it (result). |
POST |
/api/v1/calls/{call_id}/hangup |
End a call that is ringing or in progress. |
All three require the calls:write scope, including the read: there is no separate read scope for calls. See Scopes.
There is no list endpoint. You cannot enumerate calls over the API; you track the call_id values you created, or you consume the call.started, call.ended and call.outcome webhooks.
201 means ringing, not connected
This is the single most important thing on the page. A 201 tells you the call was placed and the destination is being rung. It does not mean anyone answered, and it does not mean the conversation went well. duration_seconds, outcome, started_at, and ended_at are all null in the create response, by definition — the call has not happened yet.
Parameters #
tostringrequiredThe number to call. 6–20 characters, digits with an optional leading + — pattern ^\+?\d+$. Send it in international form. See How to is read.
agent_idstringoptionaldefault nullMaximum 64 characters. An assertion, not a selector — see below. Omit it and the agent deployed to the caller-ID number answers.
from_numberstringoptionaldefault nullMaximum 20 characters. The caller-ID number the customer sees, which must be an active number in this workspace. Only the digits are compared, so +918047261935 and 918047261935 are the same number. Omit it and the workspace's first active number is used.
purposestringoptionaldefault nullMaximum 500 characters. Why you are calling, in plain language. Reaches the agent's opening line and system prompt.
scriptstringoptionaldefault nullMaximum 4,000 characters. The flow the call should follow. Covered in order and in the agent's own words, never read out. See Call outcomes.
detailsobjectoptionaldefault {}Up to 20 "Label": value pairs about the person, each value a string, number or boolean: {"Name": "Rohit", "Appliance": "Split AC, 1.5 ton"}. Given to the agent as information, never as instructions. When present, the agent confirms it is talking to the right person before using any of them. Labels are cut at 80 characters and values at 300; true and false become yes and no; empty pairs are dropped; lines past 3,000 characters in total are left out whole.
captureobjectoptionaldefault nullWhat to find out on the call: a goal, up to 12 fields, and a success rule. The agent asks for them during the call, and the answers come back in result. See Call outcomes.
ring_secondsintegeroptionaldefault 30How long to let the destination ring before giving up. Minimum 10, maximum 90.
metadataobjectoptionaldefault {}Free-form key/value object stored on the call record. Carry your own lead id or job reference here. Not spoken and not returned by the API, but sent back to you in the call.outcome webhook. Avoid the keys campaign_id, campaign_name and callback_id: see Reserved metadata keys.
There is no channel field. Voice is the only dialable channel, and it is resolved for you.
How to is read #
to is screened, and the number that passes the screen is the number dialled.
- With a leading
+, it is read as an international number. - Without one, it is first tried as a number in your workspace's country. If it is not a valid number there, it is read as international digits. In an India workspace,
9876543210becomes+919876543210, and919876543210is read as+91 98765 43210. - A number that cannot be read either way is refused with
400and the messageThat destination number isn't usable.One shorter than 8 digits is refused withThat destination number is too short.Neither carries adetails.reason. - Calls go only to countries enabled for outbound calling, and some number ranges (satellite and premium-rate ranges among them) are blocked. See the failures below.
The response's to is the number as dialled, as + and digits. Store that, not what you sent.
Reserved metadata keys #
Three keys in metadata mean something to the call record. campaign_id (with campaign_name) labels the call as a campaign call in the console, and callback_id labels it as a follow-up. A call labelled either way is no longer treated as one your integration placed, so its call.outcome webhook does not carry your metadata. Use other names, such as lead_id or job_id.
The agent comes from the number, not from agent_id #
This trips up everyone once, so it is worth being blunt about.
A phone number in your workspace has exactly one agent deployed to it. When you place a call, the caller-ID number is resolved first — from from_number if you supplied one, otherwise the workspace's first active number — and the agent deployed to that number is the agent that speaks. agent_id does not choose anything.
What agent_id does is assert your expectation. If you pass one and it does not match the agent actually deployed to that number, the call is refused with 412 failed_precondition and this details object:
{"reason": "agent_number_mismatch","deployed_agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4"}
That is the point of the field. If your system cares which agent talks to a customer — and for anything money-adjacent it should — pass both from_number and agent_id together. Then a deployment change made in the console on a Tuesday afternoon turns into a loud 412 in your logs instead of the wrong agent quietly having the wrong conversation. details.deployed_agent_id tells you what is actually there.
To route to a different agent, call from a different number. One number, one agent.
What purpose does #
purpose is free text, up to 500 characters, and it reaches the agent as the reason for the call. It is added to the agent's instructions for the whole conversation, and it is stored on the call record.
Write it as an instruction to a colleague, not as a script:
{ "purpose": "Confirm Asha's AC service visit on Thursday 15 October between 11am and 1pm, and offer another slot if it does not suit." }
For the steps of the call, use script; for what you want to know by the end of it, use capture. The three work together: purpose is why, script is how, capture is what to bring back. Call outcomes covers script, details and capture in full.
Two things it is not. It is not the greeting — the opening line is a fixed template that identifies your business, discloses that the caller is a virtual assistant, and asks whether now is a good time, in that order. And it is not the agent's knowledge; the agent still answers from the knowledge base bound to it. purpose sets the errand, nothing else.
The inbound greeting is deliberately not used
Whatever greeting your number uses for inbound calls is discarded on an outbound call. "Thank you for calling" is wrong when you did the calling. Outbound calls covers the opening in full, including the Hindi and Hinglish variants.
ring_seconds #
Bounded to 10–90, default 30. Below 10 the destination often has not finished routing to the handset; above 90 you are holding one of your 10 concurrent call slots for someone who is not going to answer. A call that rings out never reaches the agent and finalises with a zero duration and an outcome such as no_answer or busy.
Example #
Calling a customer the moment your service desk books their AC service visit, to confirm the slot.
curl -X POST https://services.amomic.in/api/v1/calls \-H "Authorization: Bearer $AMOMIC_API_KEY" \-H "Content-Type: application/json" \-H "Idempotency-Key: job-VH-4821-confirm-call" \-d '{"to": "+919876543210","from_number": "+918047261935","agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4","purpose": "Confirm the AC service visit for job VH-4821 on Thursday 15 October between 11am and 1pm, and offer another slot if it does not suit.","details": { "Name": "Asha Menon", "Appliance": "Split AC, 1.5 ton", "Job": "VH-4821" },"ring_seconds": 40,"metadata": { "job_id": "VH-4821" }}'
POST /calls honours Idempotency-Key, and you should always send one. A retry after a timeout replays the stored 201 rather than dialling the customer twice, which, unlike a duplicate message, is a phone ringing in someone's pocket a second time. See Idempotency.
Response #
{"call_id": "call_01JQRS4T8XZ0AB1CD2EF3GH4","status": "ringing","direction": "outbound","to": "+919876543210","from_number": "+918047261935","agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4","duration_seconds": null,"outcome": null,"summary": null,"recording_uri": null,"started_at": null,"ended_at": null,"capture": null,"result": { "state": "pending", "disposition": null, "goal_met": null, "success": null, "summary": null, "sentiment": null, "next_step": null, "next_step_reason": null, "fields": {}, "captured": 0, "expected": 0, "missing_required": [], "read_at": null }}
to and from_number are the numbers actually used, as + and digits. agent_id echoes what you sent, so it is null when you sent none, even though an agent will speak; read the call back to see which. capture echoes the capture you sent, with every field key filled in, or null when you sent none.
Store call_id. It is the only handle you get, and there is no way to look a call up by phone number or by your own metadata.
/api/v1/calls/{call_id}Read the current state of one call. Scope calls:write. No query parameters. Any call in the workspace can be read this way, including inbound calls and campaign calls, as long as you have its call_id.
curl https://services.amomic.in/api/v1/calls/call_01JQRS4T8XZ0AB1CD2EF3GH4 \-H "Authorization: Bearer $AMOMIC_API_KEY"
| Field | Type | Notes |
|---|---|---|
call_id |
string | call_…. The id you were given at create. |
status |
string | Where the call is in its lifecycle. Open vocabulary — see below. |
direction |
string | outbound or inbound. Falls back to outbound when unset. |
to |
string | null | The number called. For an inbound call, the caller's number. |
from_number |
string | null | Your number on the call: the caller ID on an outbound call, the number dialled on an inbound one. Digits only here, without the +. |
agent_id |
string | null | The agent that handled the call. |
duration_seconds |
integer | null | Connected duration. null until the call finalises; 0 for a call that was never answered. |
outcome |
string | null | How the line ended. null while it is live. |
summary |
string | null | A short summary of the call, once one has been written. null until then, and on calls nobody answered. For the reading of the call, use result.summary. |
recording_uri |
string | null | Set only when recording is on for that number's deployment. It is a storage reference, not a playable link; play recordings from the console. |
started_at |
string | null | ISO 8601 with offset, for example 2026-10-14T05:50:04.812311+00:00. |
ended_at |
string | null | Same format. null while the call is live. |
capture |
object | null | The capture sent with the call, with keys filled in. null when none was sent. |
result |
object | What came of the call: state, disposition, fields and more. state is pending until the call has been read. See Call outcomes. |
{"call_id": "call_01JQRS4T8XZ0AB1CD2EF3GH4","status": "completed","direction": "outbound","to": "+919876543210","from_number": "918047261935","agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4","duration_seconds": 96,"outcome": "caller_hangup","summary": "Asha confirmed the AC service visit for Thursday and asked the technician to call from the gate.","recording_uri": null,"started_at": "2026-10-14T05:50:04.812311+00:00","ended_at": "2026-10-14T05:51:40.930118+00:00","capture": null,"result": {"state": "ready","disposition": "completed","goal_met": "yes","success": null,"summary": "Asha confirmed the AC service visit for Thursday and asked the technician to call from the gate.","sentiment": "positive","next_step": "none","next_step_reason": null,"fields": {},"captured": 0,"expected": 0,"missing_required": [],"read_at": "2026-10-14T05:52:21.004512+00:00"}}
A call_id that does not exist, or belongs to another workspace, returns 404 not_found with the message Call not found. The two cases are answered identically on purpose.
Do not switch exhaustively on status #
status is typed as a plain string. No closed enum is declared, so no closed list is published. New lifecycle states can appear without a version bump, and code that throws on an unrecognised value will eventually throw in production at 3 a.m.
status |
What it means |
|---|---|
ringing |
Placed; the destination is being rung. |
dialing, starting, in_progress |
The call is being connected or is under way. |
human_handoff, human_active |
The agent handed the caller to a member of your team, who is about to take the call or is on it. |
completed |
The call is over. This includes calls nobody answered. |
failed |
The call could not be placed (outcome originate_error). |
Give yourself a default branch:
switch (call.status) {case "completed":return "done";case "failed":return "failed";default:// ringing, in_progress, a handoff, or a state added later: still live.return "live";}
The field that tells you how the line ended is outcome, and it is only meaningful once the call has ended. What the conversation achieved is in result:
outcome |
What it means |
|---|---|
caller_hangup |
The person you called ended the call. The usual ending for a normal conversation. |
agent_hangup |
The agent decided the conversation was finished and ended it. |
idle |
Silence on the line past the idle threshold; the agent said goodbye and hung up. |
max_duration |
The call hit the configured time cap. |
no_answer |
Rang out, or the destination could not be classified more precisely. |
busy |
The destination was busy. |
rejected |
The destination actively declined the call. |
invalid_number |
The number does not exist on the network. |
unreachable |
The destination could not be reached: switched off, out of coverage or out of order. |
congestion |
The network refused the call for capacity reasons. |
suspended |
The workspace is billing-suspended; the call was answered only to say so. |
setup_error |
Media setup failed after the call was answered. |
originate_error |
The call could not be placed at all. |
Unanswered calls finalise as status: "completed" with duration_seconds: 0 and one of the network outcomes above. A completed status therefore does not mean a conversation happened. Check duration_seconds and outcome together, or read result.disposition.
Getting the outcome: poll, or subscribe #
Subscribe. Register a webhook for call.ended and you are told the moment the call finalises. Register call.outcome as well and you get the reading (disposition, summary and every answer to your capture) as soon as it lands, usually within a minute after that. This is the right answer for anything that needs to react: updating a CRM, reopening a job, scheduling another try. See Event reference for exactly which fields each event carries, and remember that delivery is at-least-once, so dedupe on the event id.
Poll. If you cannot receive webhooks, GET /calls/{call_id} works, but poll gently: wait ring_seconds before your first read, check every few seconds, and stop as soon as ended_at is set. If you want the answers, carry on until result.state is no longer pending. Every poll spends one of your 120 requests per minute, and a tight loop across a batch of calls will hit the rate limit long before the calls finish.
import timeimport requestsdef wait_for_reading(call_id: str, headers: dict, ring_seconds: int = 30, timeout_s: int = 900):time.sleep(ring_seconds) # nothing to see before thisdeadline = time.monotonic() + timeout_swhile time.monotonic() < deadline:call = requests.get(f"{BASE}/calls/{call_id}", headers=headers, timeout=15).json()if call["ended_at"] is not None and call["result"]["state"] != "pending":return calltime.sleep(10)raise TimeoutError(f"{call_id} was not read within {timeout_s}s")
/api/v1/calls/{call_id}/hangupEnd a call. Scope calls:write. No request body.
curl -X POST https://services.amomic.in/api/v1/calls/call_01JQRS4T8XZ0AB1CD2EF3GH4/hangup \-H "Authorization: Bearer $AMOMIC_API_KEY"
{ "call_id": "call_01JQRS4T8XZ0AB1CD2EF3GH4", "ended": true }
ended is true when the call's status was ringing or in_progress and a hang-up was sent. It is false for any other status, and nothing is done. That covers a call that is already over, which is a normal, successful answer, not an error, so hangup is safe to call repeatedly. It also covers a call your team has taken over (human_handoff, human_active): the API does not cut off a member of your team mid-call.
An unknown call_id returns 404 not_found with Call not found.
Use it when your own system learns the call is pointless mid-flight: the customer just rescheduled online, someone else already reached them, or your operator cancelled the job.
Failures #
After the key, scope and request checks, guards run in this order, and the first one that trips is the error you get.
invalid_argumentRequest validation failed, for example a bad `to` pattern, `ring_seconds` outside 10–90, more than 20 `details`, or a capture that does not validate. `details.errors` names the field.unauthenticatedKey missing, malformed, invalid, revoked, expired after a roll, or from the wrong environment. `details.reason` distinguishes all six.permission_deniedworkspace_suspendedThe workspace is suspended for billing. Every API key in it is refused before anything else is checked.permission_deniedmissing_scopeThe key lacks `calls:write`. Required for placing, reading and hanging up.conflictidempotency_key_reuseThe same `Idempotency-Key` was already used with a different request body.conflictidempotency_in_progressA request with the same `Idempotency-Key` is still running. Wait `details.retry_after_seconds` (2) and retry with the same key.failed_preconditionunsupported_channelOutbound calling is not available on this deployment at all. `details.supported` lists what is.failed_preconditionvoice_not_configuredVoice calling is not configured on this deployment. Nothing you can fix in the request.failed_preconditionworkspace_suspendedCalling is paused on this workspace. Normally the 403 above fires first.failed_preconditionwallet_emptyThe workspace has used every voice minute its plan includes this month and has no prepaid credits left. Someone has to add credits or upgrade in Billing. Inbound calls and calls already in progress are unaffected.failed_preconditionchannel_dormantThe workspace's plan no longer includes phone numbers. The number is held for a short time so an upgrade can keep it, but it places no calls.invalid_argument`That destination number isn't usable.` or `That destination number is too short.` See How `to` is read.invalid_argumentself_dialThe destination is one of this workspace's own numbers. Calling it would connect the agent to itself.permission_denieddestination_blockedThe destination is in a number range blocked for outbound calling, such as satellite or premium-rate numbers.permission_deniedcountry_not_allowedOutbound calls are not enabled for the destination's country. `details.allowed_country_codes` lists the country codes that are, for example `["91"]`.rate_limitedcall_concurrencyThis workspace already has 10 calls in progress. `details.limit` is 10. Separate from the per-key request limit.failed_preconditionno_phone_numberThe workspace has no active phone number to call from. Get a number in the console first.permission_deniedunknown_from_numberThe `from_number` you asked for is not an active number in this workspace.failed_preconditionno_agent_deployedThe caller-ID number has no agent deployed to it. A number without a deployment cannot talk.permission_denied`That number isn't in this workspace.` The number's deployment belongs to another workspace.failed_preconditionagent_number_mismatchYour `agent_id` is not the agent deployed to that number. `details.deployed_agent_id` names the one that is.rate_limiteddaily_call_budgetThe workspace reached its daily outbound calling safety limit. `details` carries `calls` and `reserved_minutes` used so far, and the limits `call_limit` and `minute_limit`.rate_limitedrate_limited120 requests per minute per API key exceeded.not_found`Call not found.` From `GET /calls/{id}` and hangup, for an unknown id or a call owned by another workspace.internaloriginate_failedThe call could not be placed. The call record is marked `failed` with outcome `originate_error`. The key is released, so retry with the same `Idempotency-Key`.Three different 429s #
They mean different things and want different responses. None carries a Retry-After header, and none is a bug in your request.
reason: "rate_limited": you are calling the API too fast. 120 requests per minute per key. Slow your loop down.reason: "call_concurrency": you are calling people too fast. A workspace runs at most 10 live calls at once. Your loop is fine; your queue drain rate is not. Wait for calls to end, or spread the batch over time. See The other 429: concurrent calls.reason: "daily_call_budget": the workspace has placed as many outbound calls today as its safety limit allows. Each call reserves its maximum length in minutes against the limit when it is placed. Do not retry today; readdetailsto see which limit you met.
The 412 family is configuration, not weather #
voice_not_configured, workspace_suspended, wallet_empty, channel_dormant, no_phone_number, no_agent_deployed, agent_number_mismatch and unsupported_channel all return exactly the same result if you retry in ten seconds. Do not put them behind a backoff. Page someone, or fail the job with the reason attached. The fix is always in the console. The two billing ones are covered in What happens when you run out.
You own outbound compliance
Amomic-AI places the call you ask for. On calls placed through this endpoint it does not check the destination against any do-not-call or preference registry, does not enforce calling hours, and does not cap calls per recipient. The country allow-list and the daily safety limit protect the platform, not your customers. Those rules apply to you as the caller, so build the checks into the system that decides whom to call, before you reach this endpoint.
Related #
Outbound calls, in depth
How the agent opens the call, what the customer hears first, and what changes versus an inbound call.
Trigger a call from your CRM
The full recipe — trigger, idempotency, webhook, and writing the outcome back.
Numbers and KYC
Getting a number you can call from, and the compliance review that gates it.
Call outcomes
Design a capture and read back what the call learned.
Call events
call.started, call.ended and call.outcome — the push alternative to polling.