Skip to content

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.

Open Phone / Voice

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.ended and call.outcome. call.ended tells you how the line ended; call.outcome tells you what the call achieved and brings back your own metadata. call.started is 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 #

What happens when you place a callYour backendAmomic-AIYour customerYour endpointPOST /api/v1/calls201 · status: ringingthe number ringsansweredagent speaks firstcall.ended (signed)201 means ringing, not connected. Poll the call or wait forcall.endedto learn how it went.
POST /calls returns at ringing. How it ended arrives on call.ended, and what it achieved on call.outcome — the 201 tells you almost nothing about how the call went.
  1. Your CRM automation fires on a stage change and enqueues a job.
  2. The job checks your own guards — do not call, quiet hours, already called today — then POSTs to /calls.
  3. 201 comes back with status: "ringing" and a call_id. Store it on the CRM record.
  4. If the call connects, call.started fires, and the agent opens the conversation, states who it is calling for, and asks whether now is a good time.
  5. call.ended fires with the line outcome and duration. Your handler maps it to a dial disposition.
  6. Usually within a minute, call.outcome fires with the reading — disposition, summary, answers — and your metadata. 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:

412 Precondition Failed
{
"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_number must be an active number owned by your workspace, matched on digits. A number belonging to another workspace is a 403.
  • 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 400 with self_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:

JSON
{
"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 #

Node.js
// place-call.js
const 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 else
from_number: CALLER_ID,
agent_id: AGENT_ID,
purpose: renewalBrief(opportunity),
ring_seconds: 30, // 10-90
metadata: { 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})`);
}
201 Created
{
"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 #

429rate_limitedcall_concurrencyThis workspace already has 10 calls in progress. `details.limit` is 10. Requeue.
412failed_preconditionno_agent_deployedNo agent is deployed to the caller-ID number, so nobody would speak.
412failed_preconditionagent_number_mismatch`agent_id` is not the agent on that number. `details.deployed_agent_id` names the real one.
412failed_preconditionno_phone_numberThe workspace owns no phone number at all.
412failed_preconditionwallet_emptyThe plan's voice minutes for this month are used up and there are no prepaid credits left. Top up in Billing.
412failed_preconditionchannel_dormantThe plan no longer includes voice, so the number is being held and has stopped calling.
403permission_deniedworkspace_suspendedBilling suspension. Every API call with this workspace's keys is refused until it is resolved.
403permission_deniedunknown_from_number`from_number` is not one of this workspace's numbers.
403permission_deniedcountry_not_allowedOutbound calls to that country are not enabled. `details.allowed_country_codes` lists the ones that are.
403permission_denieddestination_blockedThe destination is in a blocked number range, such as premium-rate.
429rate_limiteddaily_call_budgetThe workspace reached its daily outbound safety limit. `details` gives the counts and limits. Resume tomorrow.
400invalid_argumentself_dialThe destination is one of your own numbers.
500internaloriginate_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.ended arrives, 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).

POST to your endpoint
{
"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:

Node.js
// reconcile-stuck-calls.js — run on a 15-minute schedule
async 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 sweep
const 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:

Node.js
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:

POST to your endpoint
{
"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"
}
}
Node.js
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 | unclear
callSummary: data.summary ?? "",
nextStep: data.next_step, // none | call_back | send_whatsapp | human_follow_up
answers: 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 summary on GET /calls/{call_id} is not populated. The field exists on the call and comes back null. The written summary of what happened is result.summary there, filled in once the call has been read (usually within a minute of it ending), and summary on call.outcome. See Call outcomes.
  • recording_uri only 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:

Shell
curl -X POST https://services.amomic.in/api/v1/calls/call_01JQRS4T8XZ0AB1CD2EF3GH4/hangup \
-H "Authorization: Bearer $AMOMIC_API_KEY"
200 OK
{ "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 purpose text 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 rejected as a stop signal.
  • Alert on no_agent_deployed and workspace_suspended separately. 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.