Trigger an AI call from your CRM
Place an outbound AI call when a deal changes stage, wait for the outcome properly, and write a disposition back to the record.
A deal moves to Qualified, or a renewal falls due, and someone should phone the customer. This recipe has your agent make that call — placed from your CRM's automation, with the outcome written back onto the record without anyone listening to a recording.
The call itself is asynchronous and that shapes the whole design. POST /calls returns as soon as the number rings. Everything you care about — whether they answered, how long they talked, what happened — arrives afterwards.
What you need before you start #
- A phone number with an agent deployed to it. Numbers are requested and KYC-verified in the console; the answering agent is set by activating a deployment on that number. See Numbers and KYC and Deployments.
- An API key with
calls:write. The same scope covers placing, reading, and hanging up. Tick Place calls under What this key can do when you create the key; scopes can't be added to a key later. - A webhook endpoint subscribed to
call.endedandcall.outcome.call.endedtells you how the line ended;call.outcometells you what the call achieved and brings back your ownmetadata.call.startedis optional and useful for live dashboards.
Outbound calling compliance is yours
We place the call you ask for. POST /calls has no consent registry, no DND or DNC check, and no calling-hours window. The only guards are safety limits: an allowed-country list, blocked premium and satellite ranges, ten live calls at once, and a daily ceiling of 100 calls per workspace. Whether a number may lawfully be called, at that hour, for that purpose, is a decision your system has to make before it calls POST /calls. Build the check into the CRM trigger, not into a policy document.
The sequence #
- Your CRM automation fires on a stage change and enqueues a job.
- The job checks your own guards — do not call, quiet hours, already called today — then POSTs to
/calls. 201comes back withstatus: "ringing"and acall_id. Store it on the CRM record.- If the call connects,
call.startedfires, and the agent opens the conversation, states who it is calling for, and asks whether now is a good time. call.endedfires with the line outcome and duration. Your handler maps it to a dial disposition.- Usually within a minute,
call.outcomefires with the reading — disposition, summary, answers — and yourmetadata. Your handler writes the result back to the record.
Choosing the caller-ID number #
from_number is the number the customer sees, and it does more than that: the agent that speaks is resolved from the caller-ID number, not from agent_id.
That is the single most surprising thing about this endpoint, and it follows from how the platform works — an agent is bound to a number by activating a deployment, so the number already answers the question of who is calling. Passing agent_id does not choose an agent; it asserts which one you expect, and fails loudly if you are wrong:
{"error": {"code": "failed_precondition","message": "The agent deployed to this number is different from the one requested.","details": {"reason": "agent_number_mismatch","deployed_agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4"},"request_id": "req_01JQRS4T8XZ0AB1CD2EF3GH4","correlation_id": "cor_01JQRS4T8XZ0AB1CD2EF3GH4"}}
Send agent_id anyway. It converts "we silently called 400 customers with the renewals agent instead of the onboarding one" into a 412 on the first call.
The rules:
from_numbermust be an active number owned by your workspace, matched on digits. A number belonging to another workspace is a403.- Omit it and the first active number is used. That is fine for a workspace with one number and a latent bug for a workspace that adds a second.
- Your numbers are listed in the console under Channels → Phone / Voice. Pin the one you want in configuration rather than discovering it at call time.
- The destination cannot be one of your own numbers — that returns
400withself_dial.
Telling the agent why it is calling #
purpose is up to 500 characters and it is not a label. It reaches the agent's opening line and its instructions for the whole call, so it is the difference between an agent that knows why it phoned and one that improvises.
Write it as a brief to a colleague, with the facts the agent will need:
{"to": "+919876543210","from_number": "+912269850800","agent_id": "bot_01JQRS4T8XZ0AB1CD2EF3GH4","purpose": "Rohit's annual maintenance plan for AC unit AM-4821 renews on 15 August at ₹4,500. Confirm whether he wants to renew, and if he does, tell him a payment link follows on WhatsApp. If he wants to cancel, ask why and thank him.","ring_seconds": 30,"metadata": { "crm_record_id": "opp_88213", "owner": "priya@acme.in" }}
Good purposes name the customer's situation, the specific outcome you want, and what to do in the obvious branches. Bad ones — "renewal call" — leave the agent to guess, and it will.
The opening line is fixed by us, not generated from purpose: the agent identifies the business, discloses that it is a virtual assistant, and asks whether now is a good time — in that order, before anything else happens. You cannot replace that with a sales opener, and it is deliberate.
metadata is free-form, stored on the call, and never spoken. It comes back, unchanged, on the call.outcome webhook — put your CRM record id there. It is not on call.ended, which carries only the call_id, so store the call_id from the 201 as well.
Placing the call #
// place-call.jsconst BASE_URL = "https://services.amomic.in/api/v1";const API_KEY = process.env.AMOMIC_API_KEY;const CALLER_ID = process.env.AMOMIC_CALLER_ID; // "+912269850800"const AGENT_ID = process.env.AMOMIC_AGENT_ID; // asserts the deployed agent// An error response releases the Idempotency-Key, so a requeue after one// reuses the same key and runs fresh.async function placeRenewalCall(opportunity) {// Your compliance guards run here, before anything is dialled.if (opportunity.doNotCall) return { skipped: "do_not_call" };if (!withinCallingHours()) return { skipped: "outside_hours" };const body = JSON.stringify({to: opportunity.contactPhone, // "+919876543210" — digits, optional +, nothing elsefrom_number: CALLER_ID,agent_id: AGENT_ID,purpose: renewalBrief(opportunity),ring_seconds: 30, // 10-90metadata: { crm_record_id: opportunity.id },});const response = await fetch(`${BASE_URL}/calls`, {method: "POST",headers: {Authorization: `Bearer ${API_KEY}`,"Content-Type": "application/json",// Derived from the CRM event. A network retry or a requeue reuses this// exact key."Idempotency-Key": `renewal-${opportunity.id}-${opportunity.stageChangedAt}`,"X-Correlation-Id": opportunity.traceId,},body,});if (response.ok) {const call = await response.json();// status is "ringing". Nobody has answered anything yet.await crm.update(opportunity.id, { amomicCallId: call.call_id, callState: "ringing" });return call;}const { error } = await response.json();const reason = error.details?.reason;if (reason === "rate_limited") {// 120 requests/minute on this key. Refused before the key was claimed.await queue.retryIn(opportunity.id, 60_000);return { deferred: true };}if (reason === "call_concurrency" || reason === "originate_failed") {// All 10 outbound slots are busy, or the call could not be placed.// Requeue; do not spin.await queue.retryIn(opportunity.id, 60_000);return { deferred: true };}if (reason === "daily_call_budget") {// The workspace's daily outbound limit. Try again tomorrow (UTC).await queue.retryTomorrow(opportunity.id);return { deferred: true };}// Everything else is a configuration or data problem. Surface it.await crm.update(opportunity.id, { callState: "failed", callError: reason ?? error.code });throw new Error(`${error.code}/${reason ?? "-"}: ${error.message} (${error.request_id})`);}
{"call_id": "call_01JQRS4T8XZ0AB1CD2EF3GH4","status": "ringing","direction": "outbound","to": "+919876543210","from_number": "+912269850800","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}}
201 means ringing
Every field that describes how the call went is null, because none of it has happened. A pipeline that marks the opportunity "contacted" on the 201 will report every unanswered phone as a successful conversation.
Errors worth handling by name #
rate_limitedcall_concurrencyThis workspace already has 10 calls in progress. `details.limit` is 10. Requeue.failed_preconditionno_agent_deployedNo agent is deployed to the caller-ID number, so nobody would speak.failed_preconditionagent_number_mismatch`agent_id` is not the agent on that number. `details.deployed_agent_id` names the real one.failed_preconditionno_phone_numberThe workspace owns no phone number at all.failed_preconditionwallet_emptyThe plan's voice minutes for this month are used up and there are no prepaid credits left. Top up in Billing.failed_preconditionchannel_dormantThe plan no longer includes voice, so the number is being held and has stopped calling.permission_deniedworkspace_suspendedBilling suspension. Every API call with this workspace's keys is refused until it is resolved.permission_deniedunknown_from_number`from_number` is not one of this workspace's numbers.permission_deniedcountry_not_allowedOutbound calls to that country are not enabled. `details.allowed_country_codes` lists the ones that are.permission_denieddestination_blockedThe destination is in a blocked number range, such as premium-rate.rate_limiteddaily_call_budgetThe workspace reached its daily outbound safety limit. `details` gives the counts and limits. Resume tomorrow.invalid_argumentself_dialThe destination is one of your own numbers.internaloriginate_failedThe call could not be placed. The key is released, so retry with the same idempotency key.Only three of those are worth an automatic retry: call_concurrency and originate_failed in a minute, and daily_call_budget tomorrow. All three come back after the Idempotency-Key was claimed, and an error response releases it, so the requeued call reuses the same key and runs fresh. The rest are configuration or billing, and a retry loop against them just burns your rate limit. Calls lists every refusal and Errors every reason.
The concurrency limit #
Ten outbound calls per workspace at once. The eleventh returns 429 with details.reason of call_concurrency and details.limit of 10. This is separate from the 120-requests-per-minute key limit and is not affected by it.
For a CRM trigger firing a handful of calls a day, you will never see it. For a batch — 200 renewals on the first of the month — you must pace, because the limit counts live calls, not requests, and a three-minute conversation holds its slot for three minutes.
The shape that works:
- Keep a worker pool of at most 8 or 9 concurrent calls, leaving headroom for anything else in the workspace that dials.
- Release a slot when
call.endedarrives, not when the 201 does. - On
call_concurrency, requeue with a delay of about a minute. Do not retry immediately; the slot is busy for as long as the conversation lasts.
Waiting for the outcome #
Subscribe to call.ended for how the line ended — answered or not, and for how long — and to call.outcome for what the call achieved (below).
{"id": "evt_7b1e9a3c5d2f8e6a0c4b7d92","type": "call.ended","created_at": "2026-08-01T11:07:31Z","tenant_id": "ten_01JQRS4T8XZ0AB1CD2EF3GH4","data": {"call_id": "call_01JQRS4T8XZ0AB1CD2EF3GH4","duration_seconds": 96,"outcome": "caller_hangup","direction": "outbound","to": "+919876543210","event_id": "evt_01JQRS4T8XZ0AB1CD2EF3GH4"}}
Fields with no value are omitted rather than sent as null, so read with defaults. Delivery is at-least-once — deduplicate on the envelope id.
Do not poll in a loop
GET /calls/{call_id} exists and it is tempting to call it every two seconds until status changes. Don't. A three-minute call at that rate is 90 requests out of your 120-per-minute budget, for one call, to learn something that would have been pushed to you for free.
Use call.ended as the primary path. Keep polling as a reconciliation sweep: every 15 minutes, list your own records that have been ringing for longer than ring_seconds plus your maximum call length, and read those specific calls. That is a handful of requests an hour instead of thousands.
The reconciliation sweep:
// reconcile-stuck-calls.js — run on a 15-minute scheduleasync function reconcileStuckCalls() {// Ringing far longer than any real call could be: your ring_seconds plus// the maximum call length configured on the number.const stale = await crm.findCalls({ state: "ringing", olderThanMinutes: 20 });for (const record of stale) {const response = await fetch(`${BASE_URL}/calls/${record.amomicCallId}`, {headers: { Authorization: `Bearer ${API_KEY}` },});if (response.status === 404) {await crm.update(record.id, { callState: "unknown" });continue;}if (!response.ok) continue; // try again next sweepconst call = await response.json();if (call.status === "ringing" || call.status === "in_progress") continue;await applyDisposition(record, call);await sleep(600); // stay well inside 120 requests/minute}}
Mapping outcomes to dispositions #
Two fields describe how a call finished. status is the coarse lifecycle state; outcome is the one to map, because it distinguishes a busy signal from a rejection from a conversation that ended normally.
An important detail: a call that was never answered still finalises with status: "completed" and duration_seconds: 0. Branching on status alone will book every voicemail as a conversation. Branch on outcome, and use duration_seconds to sanity-check it.
The call never connected:
outcome |
What happened | CRM disposition |
|---|---|---|
no_answer |
Rang out, or the network gave no better reason | No answer — retry later, count the attempt |
busy |
The line was engaged | Busy — retry sooner than a no-answer |
rejected |
The customer actively declined | Rejected — back off hard, treat as a soft opt-out |
invalid_number |
The number does not exist | Bad number — stop retrying, flag the record for correction |
unreachable |
Out of coverage or unroutable | Unreachable — retry, and stop after a few attempts |
congestion |
Network congestion | Retry — genuinely transient, safe to try again shortly |
The call connected and a conversation happened:
outcome |
What happened | CRM disposition |
|---|---|---|
caller_hangup |
The customer ended the call. The normal ending. | Contacted — read the transcript for the result |
agent_hangup |
The agent completed its job and ended the call | Contacted — usually the cleanest outcome |
idle |
Silence for long enough that the agent said goodbye | Contacted, no engagement — often a voicemail or a set-down handset |
max_duration |
The call hit its length cap | Contacted, truncated — worth a human review |
Something went wrong on our side:
outcome |
What happened | CRM disposition |
|---|---|---|
originate_error |
The call could not be placed | Failed — safe to retry |
setup_error |
The media path failed to establish | Failed — safe to retry |
suspended |
The workspace is billing-suspended | Blocked — alert an admin; every call will fail the same way |
Handle the outcome you have not seen
outcome is a plain string with no closed enum on the API. New values can appear. Give your mapping a default branch that writes Unknown and logs the raw value rather than throwing — a KeyError in a webhook handler returns a 500, which costs you four retries and moves you closer to having the endpoint auto-disabled.
A handler that puts it together:
const NEVER_CONNECTED = {no_answer: "No answer",busy: "Busy",rejected: "Rejected",invalid_number: "Bad number",unreachable: "Unreachable",congestion: "Retry",};const CONNECTED = {caller_hangup: "Contacted",agent_hangup: "Contacted",idle: "Contacted, no engagement",max_duration: "Contacted, truncated",};const PLATFORM_FAILURE = {originate_error: "Failed",setup_error: "Failed",suspended: "Blocked",};function disposition(call) {const outcome = call.outcome ?? "";if (outcome in PLATFORM_FAILURE) return PLATFORM_FAILURE[outcome];if (outcome in NEVER_CONNECTED) return NEVER_CONNECTED[outcome];if (outcome in CONNECTED) return CONNECTED[outcome];console.warn("unmapped call outcome", { outcome, call_id: call.call_id });return "Unknown";}async function onCallEnded(data) {const record = await crm.findByCallId(data.call_id);if (!record) return;await crm.update(record.id, {callState: "ended",disposition: disposition(data),callDurationSeconds: data.duration_seconds ?? 0,callEndedAt: new Date().toISOString(),});// Free a slot in your outbound worker pool here, not on the 201.await pool.release(data.call_id);}
What the call achieved: call.outcome #
call.ended says how the line ended, not what was said. Every finished AI call is then read, and the reading arrives on call.outcome — usually within a minute, sometimes before call.ended. It carries your metadata, so it can go straight to the CRM record:
{"id": "evt_9f2c4f3a7b28d05e91c4a761","type": "call.outcome","created_at": "2026-10-14T05:36:02Z","tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB","data": {"call_id": "call_01JF9T7Y1A5C9E3G7J1L5N9QRS","direction": "outbound","to": "+919811204587","disposition": "completed","goal_met": "yes","summary": "Rahul renewed the AMC for his split AC and asked for the payment link on WhatsApp.","sentiment": "positive","next_step": "send_whatsapp","next_step_reason": "He asked for the payment link.","fields": {},"captured": 0,"expected": 0,"metadata": { "crm_record_id": "opp_88213" },"event_id": "evt_01JF9T9A3C7E1G5J9L3N7Q1S5U"}}
async function onCallOutcome(data) {// Your own id comes back in metadata; fall back to the stored call_id.const record =(data.metadata?.crm_record_id && (await crm.findById(data.metadata.crm_record_id))) ||(await crm.findByCallId(data.call_id));if (!record) return;await crm.update(record.id, {result: data.disposition, // completed, partial, callback_requested, …goalMet: data.goal_met, // yes | no | unclearcallSummary: data.summary ?? "",nextStep: data.next_step, // none | call_back | send_whatsapp | human_follow_upanswers: data.fields ?? {},});}
A call can be read again — someone presses Re-read in the console — and each reading sends a new call.outcome. Keep the latest per call_id. To ask the agent for specific answers (a renewal decision, a preferred slot), send a capture with the call; Call outcomes covers it, and the field-by-field payload is in the Event reference.
Two things not to build on:
- The top-level
summaryonGET /calls/{call_id}is not populated. The field exists on the call and comes backnull. The written summary of what happened isresult.summarythere, filled in once the call has been read (usually within a minute of it ending), andsummaryoncall.outcome. See Call outcomes. recording_urionly exists if recording is enabled, which is off by default and requires an explicit opt-in per number. When it is on, callers hear a spoken notice before the greeting. See How voice works.
Cancelling a call in flight #
If the CRM stage changes again while the phone is still ringing, hang up:
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: false means the call was already over — it is not an error, and it is the answer you get if you race the customer hanging up. call.ended still fires either way.
Going to production #
- Call yourself first. Place a real call to your own mobile with the real
purposetext and listen to the whole thing. Reading the brief is not the same as hearing the agent open with it. - Put the compliance check in the trigger, not in a runbook. It is the one part of this flow the platform will not do for you.
- Cap attempts per contact per day in your own code and treat
rejectedas a stop signal. - Alert on
no_agent_deployedandworkspace_suspendedseparately. Both mean every call will fail identically until a human acts. - Watch the Calls page in the console during the first batch. It shows each call's outcome and duration as they finish.
Next #
The endpoint reference
POST /calls — every field, every guard, every error, plus hangup and read.
How voice actually works
How voice works — what the agent hears, how turn-taking works, and what recording does.
Event payloads
Event reference — the exact data shape of call.started, call.ended and call.outcome.
Message them instead
Send an order update on WhatsApp — the same pattern for the messaging channel.