# 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.

Source: https://docs.amomic.in/recipes/crm-call

---

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](/channels/phone-numbers) and [Deployments](/build/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.

::: danger 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

```mock
{"component": "CallSequenceDiagram", "props": {"caption": "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:

```json title="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

::: codegroup

```javascript 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})`);
}
```

```python Python
# place_call.py
import os
import json
import requests

BASE_URL = "https://services.amomic.in/api/v1"
API_KEY = os.environ["AMOMIC_API_KEY"]
CALLER_ID = os.environ["AMOMIC_CALLER_ID"]   # "+912269850800"
AGENT_ID = os.environ["AMOMIC_AGENT_ID"]     # asserts the deployed agent


def place_renewal_call(opportunity) -> dict:
    # Your compliance guards run here, before anything is dialled.
    if opportunity.do_not_call:
        return {"skipped": "do_not_call"}
    if not within_calling_hours():
        return {"skipped": "outside_hours"}

    body = json.dumps(
        {
            "to": opportunity.contact_phone,   # "+919876543210"
            "from_number": CALLER_ID,
            "agent_id": AGENT_ID,
            "purpose": renewal_brief(opportunity),
            "ring_seconds": 30,                # 10-90
            "metadata": {"crm_record_id": opportunity.id},
        }
    )

    headers = {
        "Authorization": f"Bearer {API_KEY}",
        "Content-Type": "application/json",
        # Derived from the CRM event. An error response releases the key, so a
        # requeue after one reuses it and runs fresh.
        "Idempotency-Key": f"renewal-{opportunity.id}-{opportunity.stage_changed_at}",
        "X-Correlation-Id": opportunity.trace_id,
    }

    response = requests.post(f"{BASE_URL}/calls", headers=headers, data=body, timeout=30)

    if response.ok:
        call = response.json()
        # status is "ringing". Nobody has answered anything yet.
        crm.update(opportunity.id, amomic_call_id=call["call_id"], call_state="ringing")
        return call

    error = response.json()["error"]
    reason = (error.get("details") or {}).get("reason")

    if reason == "rate_limited":
        # Refused before the key was claimed.
        queue.retry_in(opportunity.id, seconds=60)
        return {"deferred": True}
    if reason in ("call_concurrency", "originate_failed"):
        queue.retry_in(opportunity.id, seconds=60)
        return {"deferred": True}
    if reason == "daily_call_budget":
        queue.retry_tomorrow(opportunity.id)
        return {"deferred": True}

    crm.update(opportunity.id, call_state="failed", call_error=reason or error["code"])
    raise RuntimeError(f"{error['code']}/{reason or '-'}: {error['message']} ({error['request_id']})")
```

:::

```json title="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
  }
}
```

::: warning 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

```responses
[
  {"status": 429, "code": "rate_limited", "reason": "call_concurrency", "description": "This workspace already has 10 calls in progress. `details.limit` is 10. Requeue."},
  {"status": 412, "code": "failed_precondition", "reason": "no_agent_deployed", "description": "No agent is deployed to the caller-ID number, so nobody would speak."},
  {"status": 412, "code": "failed_precondition", "reason": "agent_number_mismatch", "description": "`agent_id` is not the agent on that number. `details.deployed_agent_id` names the real one."},
  {"status": 412, "code": "failed_precondition", "reason": "no_phone_number", "description": "The workspace owns no phone number at all."},
  {"status": 412, "code": "failed_precondition", "reason": "wallet_empty", "description": "The plan's voice minutes for this month are used up and there are no prepaid credits left. Top up in Billing."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_dormant", "description": "The plan no longer includes voice, so the number is being held and has stopped calling."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "Billing suspension. Every API call with this workspace's keys is refused until it is resolved."},
  {"status": 403, "code": "permission_denied", "reason": "unknown_from_number", "description": "`from_number` is not one of this workspace's numbers."},
  {"status": 403, "code": "permission_denied", "reason": "country_not_allowed", "description": "Outbound calls to that country are not enabled. `details.allowed_country_codes` lists the ones that are."},
  {"status": 403, "code": "permission_denied", "reason": "destination_blocked", "description": "The destination is in a blocked number range, such as premium-rate."},
  {"status": 429, "code": "rate_limited", "reason": "daily_call_budget", "description": "The workspace reached its daily outbound safety limit. `details` gives the counts and limits. Resume tomorrow."},
  {"status": 400, "code": "invalid_argument", "reason": "self_dial", "description": "The destination is one of your own numbers."},
  {"status": 500, "code": "internal", "reason": "originate_failed", "description": "The 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](/api/calls) lists every refusal and [Errors](/api/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](#what-the-call-achieved-call-outcome)).

```json title="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`.

::: warning 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:

::: codegroup

```javascript 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
  }
}
```

```python Python
# reconcile_stuck_calls.py — run on a 15-minute schedule
import time
import requests


def reconcile_stuck_calls() -> None:
    # Ringing far longer than any real call could be: your ring_seconds plus
    # the maximum call length configured on the number.
    stale = crm.find_calls(state="ringing", older_than_minutes=20)

    for record in stale:
        response = requests.get(
            f"{BASE_URL}/calls/{record.amomic_call_id}",
            headers={"Authorization": f"Bearer {API_KEY}"},
            timeout=30,
        )

        if response.status_code == 404:
            crm.update(record.id, call_state="unknown")
            continue
        if not response.ok:
            continue  # try again next sweep

        call = response.json()
        if call["status"] in ("ringing", "in_progress"):
            continue

        apply_disposition(record, call)
        time.sleep(0.6)  # 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 |

::: note 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:

::: codegroup

```javascript 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);
}
```

```python Python
NEVER_CONNECTED = {
    "no_answer": "No answer",
    "busy": "Busy",
    "rejected": "Rejected",
    "invalid_number": "Bad number",
    "unreachable": "Unreachable",
    "congestion": "Retry",
}

CONNECTED = {
    "caller_hangup": "Contacted",
    "agent_hangup": "Contacted",
    "idle": "Contacted, no engagement",
    "max_duration": "Contacted, truncated",
}

PLATFORM_FAILURE = {
    "originate_error": "Failed",
    "setup_error": "Failed",
    "suspended": "Blocked",
}


def disposition(call: dict) -> str:
    outcome = call.get("outcome") or ""
    for table in (PLATFORM_FAILURE, NEVER_CONNECTED, CONNECTED):
        if outcome in table:
            return table[outcome]
    logger.warning("unmapped call outcome", extra={"outcome": outcome, "call_id": call.get("call_id")})
    return "Unknown"


async def on_call_ended(data: dict) -> None:
    record = await crm.find_by_call_id(data["call_id"])
    if record is None:
        return

    await crm.update(
        record.id,
        call_state="ended",
        disposition=disposition(data),
        call_duration_seconds=data.get("duration_seconds") or 0,
    )

    # 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:

```json title="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"
  }
}
```

::: codegroup

```javascript 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 ?? {},
  });
}
```

```python Python
async def on_call_outcome(data: dict) -> None:
    # Your own id comes back in metadata; fall back to the stored call_id.
    crm_id = (data.get("metadata") or {}).get("crm_record_id")
    record = (await crm.find_by_id(crm_id) if crm_id else None) or await crm.find_by_call_id(data["call_id"])
    if record is None:
        return

    await crm.update(
        record.id,
        result=data.get("disposition"),        # completed, partial, callback_requested, …
        goal_met=data.get("goal_met"),         # yes | no | unclear
        call_summary=data.get("summary") or "",
        next_step=data.get("next_step"),       # none | call_back | send_whatsapp | human_follow_up
        answers=data.get("fields") or {},
    )
```

:::

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](/api/call-outcomes) covers it, and the field-by-field payload is in the [Event reference](/webhooks/events#call-outcome).

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](/api/call-outcomes#reading-the-result).
- **`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](/channels/voice).

## Cancelling a call in flight

If the CRM stage changes again while the phone is still ringing, hang up:

```bash
curl -X POST https://services.amomic.in/api/v1/calls/call_01JQRS4T8XZ0AB1CD2EF3GH4/hangup \
  -H "Authorization: Bearer $AMOMIC_API_KEY"
```

```json title="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

::: cards

::: card The endpoint reference
[POST /calls](/api/calls) — every field, every guard, every error, plus hangup and read.
:::

::: card How voice actually works
[How voice works](/channels/voice) — what the agent hears, how turn-taking works, and what recording does.
:::

::: card Event payloads
[Event reference](/webhooks/events) — the exact `data` shape of `call.started`, `call.ended` and `call.outcome`.
:::

::: card Message them instead
[Send an order update on WhatsApp](/recipes/order-updates) — the same pattern for the messaging channel.
:::

:::
