# Campaigns

> Create, check, launch and monitor a campaign from your own systems — with the same guardrails, the same refusals, and a dry-run that tells you whether a launch would be allowed.

Source: https://docs.amomic.in/api/campaigns

---

```endpoint
POST /api/v1/campaigns
```

A campaign reaches a list of people once, on WhatsApp or by phone. This is the same machinery the console drives: your integration gets everything the console gets, and it is held to everything the console is held to.

| Method | Path | Scope | Purpose |
|---|---|---|---|
| `POST` | `/api/v1/campaigns` | `campaigns:write` | Create a **draft**. Nothing is sent. |
| `GET` | `/api/v1/campaigns` | `campaigns:read` | List campaigns. |
| `GET` | `/api/v1/campaigns/{campaign_id}` | `campaigns:read` | Read one back. |
| `POST` | `/api/v1/campaigns/{campaign_id}/evaluate` | `campaigns:read` | Dry-run the launch checks. |
| `POST` | `/api/v1/campaigns/{campaign_id}/launch` | `campaigns:write` | Start sending. |
| `POST` | `/api/v1/campaigns/{campaign_id}/pause` | `campaigns:write` | Stop sending for now. |
| `POST` | `/api/v1/campaigns/{campaign_id}/resume` | `campaigns:write` | Continue a paused campaign. No duplicates. |
| `POST` | `/api/v1/campaigns/{campaign_id}/cancel` | `campaigns:write` | Stop for good. |
| `GET` | `/api/v1/campaigns/{campaign_id}/recipients` | `campaigns:read` | Per-recipient results. |
| `GET` | `/api/v1/campaigns/{campaign_id}/outcomes` | `campaigns:read` | A call campaign's outcome report. |
| `GET` | `/api/v1/campaigns/{campaign_id}/outcomes.csv` | `campaigns:write` | Every person's answers, as CSV. |

::: info Read and write are separate scopes
Unlike tickets, campaigns split the two. A write here reaches thousands of people at once, so a reporting integration that only needs last month's numbers shouldn't hold a credential that can start a new broadcast. The one exception is the CSV export, which needs `campaigns:write`. See [Scopes](/api/scopes).
:::

## Create and launch are two calls

Deliberately. `POST /campaigns` produces a **draft**: it messages nobody. You then evaluate it, which tells you the exact recipient count and every exclusion, and launch it.

The alternative, one call that creates and sends, means a network retry after a timeout could message forty thousand people twice. This shape makes that impossible.

The API can't edit a draft. If a draft is wrong, create a new one; a draft that is never launched sends nothing.

## Parameters

```params
[
  {"name": "name", "type": "string", "required": true, "description": "1–140 characters. Internal only: how you find the campaign later."},
  {"name": "channel", "type": "string", "required": false, "default": "whatsapp", "enum": ["whatsapp", "voice"], "description": "<code>whatsapp</code> creates a broadcast, <code>voice</code> a call batch. Any other value fails validation with <code>400</code>."},
  {"name": "audience", "type": "object", "required": true, "description": "Who the campaign reaches. See below."},
  {"name": "template", "type": "object", "required": false, "default": "null", "description": "Required for <code>whatsapp</code>. An approved template plus the values to fill it with."},
  {"name": "agent_id", "type": "string", "required": false, "default": "null", "description": "Required for <code>voice</code>. The agent that places the calls. Get it from <a href=\"/api/discovery\">GET /agents</a>."},
  {"name": "purpose", "type": "string", "required": false, "default": "null", "description": "Voice only. Why the agent is calling; reaches its opening line and instructions. Up to 500 characters. May carry <code>{{name}}</code>, <code>{{phone}}</code> and <code>{{email}}</code> (or the <code>{{contact.*}}</code> forms), filled per recipient, and <code>{{all}}</code> to hand the agent every detail on the contact. See <a href=\"/campaigns/call-scripts\">Call scripts and variables</a>."},
  {"name": "script", "type": "string", "required": false, "default": "null", "description": "Voice only. Up to 3,000 characters. The flow every call should follow, covered in order and in the agent's own words. Takes the same variables as <code>purpose</code>."},
  {"name": "capture", "type": "object", "required": false, "default": "null", "description": "Voice only. What to find out on every call: a goal, fields and a success rule, in the same format as on <code>POST /calls</code>. See <a href=\"/api/call-outcomes\">Call outcomes</a>. Results are on <code>/outcomes</code>."},
  {"name": "call_back_incomplete", "type": "boolean", "required": false, "default": "false", "description": "Voice only. Call a person back once when their call ended partway with answers still missing. The call-back carries what they already said."},
  {"name": "schedule", "type": "object", "required": false, "description": "Start time, pace and sending window. See below."},
  {"name": "consent", "type": "object", "required": false, "default": "null", "description": "Optional. A lawful basis to record with the campaign. Not needed to launch."},
  {"name": "sender_id", "type": "string", "required": false, "default": "null", "description": "WhatsApp only. Which connected sender to use, from <a href=\"/api/discovery\">GET /channels</a>. Omitted, the workspace's default sender."},
  {"name": "metadata", "type": "object", "required": false, "default": "{}", "description": "Stored with the campaign for your own reference. Not returned by the API, so keep your own mapping from <code>campaign_id</code>."}
]
```

Sending `script`, `capture` or `call_back_incomplete` on a WhatsApp campaign is a `400`. A `{{variable}}` in `purpose` or `script` that the audience can't fill, such as `{{csv.plan}}` on a contact audience, is a `400` with `reason: "unknown_variable"` and the `field`.

### `audience`

```params
[
  {"name": "kind", "type": "string", "required": false, "default": "contact_ids", "enum": ["contact_ids", "segment"], "description": "Uploaded lists are console-only: an uploaded file is a claim about people we want a named person behind."},
  {"name": "contact_ids", "type": "string[]", "required": false, "default": "[]", "description": "For <code>contact_ids</code>. Up to 50,000 ids from <a href=\"/api/contacts\">Contacts</a>. Ids from another workspace are dropped."},
  {"name": "tags", "type": "string[]", "required": false, "default": "[]", "description": "For <code>segment</code>. Up to 20. A contact needs <strong>every</strong> tag listed. Resolved at evaluate and launch, not at create."}
]
```

Each contact is reached on their phone number, or their WhatsApp id when there's no phone. Contacts who opted out of the channel, people on the opt-out list, duplicates and unusable numbers are removed before the count; `evaluate` itemises them.

### `template`

```params
[
  {"name": "name", "type": "string", "required": true, "description": "The template's name, up to 512 characters. It has to be approved at launch time."},
  {"name": "language", "type": "string", "required": false, "default": "null", "description": "Language code such as <code>en</code> or <code>hi</code>. Omitted, the English variant is preferred."},
  {"name": "variables", "type": "string[]", "required": false, "default": "[]", "description": "Up to 20, one per placeholder, in order. A literal, or a token: <code>{{contact.name}}</code>, <code>{{contact.phone}}</code>, <code>{{contact.email}}</code>."}
]
```

::: danger A token with no value skips that recipient
It doesn't send a message with a gap in it. `Hi , your AMC renewal is due` is worse than no message, so the recipient is left out with `missing_variable` and the field that was missing.
:::

### `consent`

```params
[
  {"name": "basis", "type": "string", "required": true, "enum": ["existing_customer", "explicit_opt_in", "inbound_enquiry", "service_message"], "description": "Why you may contact these people."},
  {"name": "note", "type": "string", "required": false, "default": "null", "description": "Up to 400 characters. Stored with the basis."}
]
```

A launch doesn't need a consent statement. If you send one, it is pinned at launch with the time, the recipient count and a checksum of the exact list, and it can't be changed afterwards. `consent_basis` on the campaign shows it. See [Consent and attestation](/campaigns/sending-rules#consent-and-attestation).

### `schedule`

Every field is optional. **A field you leave out comes from your workspace's campaign settings** (the [Sending rules](/campaigns/sending-rules#workspace-sending-rules) page in the console), not from a fixed default.

```params
[
  {"name": "start_at", "type": "string", "required": false, "default": "null", "description": "ISO 8601. In the future at launch, the campaign is <code>scheduled</code> until then. Omitted or past, it starts when you launch."},
  {"name": "timezone", "type": "string", "required": false, "default": "workspace", "description": "An IANA zone such as <code>Asia/Kolkata</code>, up to 64 characters. The zone the sending window is read in. Omitted, your campaign settings' zone, which is your workspace's time zone unless you changed it."},
  {"name": "throttle_per_minute", "type": "integer", "required": false, "default": "workspace", "description": "1–600 sends per minute. Omitted, your campaign settings' default (60 unless changed)."},
  {"name": "send_window_start_hour", "type": "integer", "required": false, "default": "workspace", "description": "0–23. May narrow the 09:00 floor, never widen it; an earlier hour is clamped."},
  {"name": "send_window_end_hour", "type": "integer", "required": false, "default": "workspace", "description": "1–24. May narrow the 21:00 ceiling. Calls are held to 10:00–19:00 regardless."}
]
```

`evaluate` reports a `quiet_hours` finding when your hours were clamped. Anything queued outside the window waits for the next open hour. See [Quiet hours](/campaigns/sending-rules#quiet-hours).

## Creating one

::: codegroup

```bash cURL
curl -X POST https://services.amomic.in/api/v1/campaigns \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: amc-renewals-2026-10" \
  -d '{
    "name": "AMC renewals, October",
    "channel": "whatsapp",
    "audience": { "kind": "segment", "tags": ["amc-2026", "split-ac"] },
    "template": {
      "name": "amc_renewal_due",
      "language": "en",
      "variables": ["{{contact.name}}", "31 Oct 2026"]
    },
    "schedule": { "throttle_per_minute": 30, "send_window_start_hour": 10 }
  }'
```

```javascript Node.js
const BASE = "https://services.amomic.in/api/v1";

const response = await fetch(`${BASE}/campaigns`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "amc-renewals-2026-10",
  },
  body: JSON.stringify({
    name: "AMC renewals, October",
    channel: "whatsapp",
    audience: { kind: "segment", tags: ["amc-2026", "split-ac"] },
    template: {
      name: "amc_renewal_due",
      language: "en",
      variables: ["{{contact.name}}", "31 Oct 2026"],
    },
    schedule: { throttle_per_minute: 30, send_window_start_hour: 10 },
  }),
});

const campaign = await response.json();
if (!response.ok) throw new Error(`${campaign.error.code}: ${campaign.error.message}`);
console.log(campaign.campaign_id, campaign.version); // a draft, version 1
```

```python Python
import os

import requests

BASE = "https://services.amomic.in/api/v1"

response = requests.post(
    f"{BASE}/campaigns",
    headers={
        "Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}",
        "Idempotency-Key": "amc-renewals-2026-10",
    },
    json={
        "name": "AMC renewals, October",
        "channel": "whatsapp",
        "audience": {"kind": "segment", "tags": ["amc-2026", "split-ac"]},
        "template": {
            "name": "amc_renewal_due",
            "language": "en",
            "variables": ["{{contact.name}}", "31 Oct 2026"],
        },
        "schedule": {"throttle_per_minute": 30, "send_window_start_hour": 10},
    },
    timeout=30,
)
response.raise_for_status()
campaign = response.json()  # a draft, version 1
```

:::

### The campaign object

Every campaign route except `evaluate`, `recipients` and the outcome reports returns this shape:

```json title="201 Created"
{
  "campaign_id": "cmp_01JRBA3F6H9K2M5P8R1T4W7Y0C",
  "name": "AMC renewals, October",
  "channel": "whatsapp",
  "type": "whatsapp_broadcast",
  "status": "draft",
  "version": 1,
  "recipients": 0,
  "sent": 0,
  "delivered": 0,
  "read": 0,
  "failed": 0,
  "skipped": 0,
  "excluded": {},
  "consent_basis": null,
  "created_at": "2026-10-14T05:58:31.604127+00:00",
  "launched_at": null,
  "completed_at": null,
  "capture": null
}
```

| Field | Type | Notes |
|---|---|---|
| `campaign_id` | string | `cmp_` followed by a time-ordered id. |
| `channel` | string | `whatsapp` or `voice`. |
| `type` | string | `whatsapp_broadcast` or `voice_batch`. |
| `status` | string | See [Campaign statuses](#campaign-statuses). |
| `version` | integer | Goes up on every change. `launch`, `pause`, `resume` and `cancel` need the current value. |
| `recipients` | integer | People staged at launch. `0` on a draft: the audience isn't resolved until evaluate or launch, because a segment resolved at create time would be stale by the time you send. |
| `sent`, `delivered`, `read`, `failed`, `skipped` | integer | Running counters, kept up to date as the campaign sends. |
| `excluded` | object | People removed at launch, by reason, such as `{"suppressed": 9}`. Empty on a draft. |
| `consent_basis` | string \| null | The basis you sent, if any. |
| `created_at`, `launched_at`, `completed_at` | string \| null | ISO 8601 with offset. `completed_at` is also set when a campaign is cancelled. |
| `capture` | object \| null | Call campaigns: the capture, with field keys filled in. |

### Campaign statuses

| `status` | Meaning |
|---|---|
| `draft` | Created, not launched. Nothing sent. |
| `scheduled` | Launched with a future `start_at`. |
| `running` | Sending. |
| `paused` | Stopped by a person or your integration; can be resumed. |
| `completed` | Every recipient reached a final state. |
| `failed` | Stopped by a failure it couldn't recover from. |
| `cancelled` | Stopped for good. Outstanding recipients were skipped. |
| `archived` | Archived after it finished. Left out of the list unless you ask for `status=archived`. |

## Listing and reading campaigns

```endpoint
GET /api/v1/campaigns
```

| Query | Default | Notes |
|---|---|---|
| `status` | none | One of the statuses above. Omitted, every campaign except `archived`. |
| `limit` | `25` | 1–100. |
| `cursor` | none | The `next_cursor` from the previous page. Opaque; a damaged cursor is a `400`. |

Most recently changed first. Returns `{"items": [campaign, …], "next_cursor": "…"}`. The list includes campaigns an app created for you, such as Duesly reminders. See [Pagination](/api/pagination).

```endpoint
GET /api/v1/campaigns/{campaign_id}
```

Returns one campaign, or `404 not_found` with `Campaign not found.`

## Check before you send

```endpoint
POST /api/v1/campaigns/{campaign_id}/evaluate
```

Runs the launch checks against the audience as it is right now and returns what would happen, without sending anything or changing the campaign. No body. It works with a `campaigns:read` key, so a reporting integration can ask "would this launch?" without being able to launch it.

```json title="200 OK"
{
  "eligible": 3788,
  "total_rows": 4012,
  "excluded": { "channel_consent_missing": 141, "suppressed": 52, "duplicate_recipient": 19, "invalid_destination": 12 },
  "invalid_rows": [
    { "contact_id": "cnt_01JQZ6H2K5N8Q1S4V7X0A3D6FG", "value": "98450 3127", "reason": "invalid_destination" },
    { "value": "Rhea Kapoor", "reason": "missing_variable", "field": "name" }
  ],
  "sample": [
    { "to": "+919811047352", "name": "Arjun Malhotra", "variables": { "1": "Arjun Malhotra", "2": "31 Oct 2026" } }
  ],
  "findings": [
    {
      "code": "suppressed",
      "severity": "info",
      "message": "52 recipients opted out and were removed.",
      "fix": "Opt-outs are permanent and cannot be overridden from here.",
      "docs": "https://docs.amomic.in/campaigns/sending-rules#opt-outs-and-suppression",
      "count": 52
    }
  ],
  "checksum": "5d2e81c4f0a9…"
}
```

| Field | Notes |
|---|---|
| `eligible` | Who would be messaged. Pass it as `confirm_recipients` when you launch. |
| `total_rows` | Everyone the audience matched, before exclusions. |
| `excluded` | Removed, by reason. The reasons are explained in [Every refusal](/campaigns/sending-rules#every-refusal-and-what-it-means). |
| `invalid_rows` | Up to 200 rows that couldn't be used, with the reason. A `missing_variable` row names the `field`. |
| `sample` | Up to 5 recipients with their filled-in variables, keyed by placeholder number. |
| `findings` | Each check's verdict: `code`, `severity`, `message`, `fix`, `docs`, `count`. |
| `checksum` | A fingerprint of the exact list. |

A `severity` of `block` means the launch would be refused; `warn` and `info` are things you should know. Two refusals are only checked at launch, not here: `channel_dormant` and `allowance_exhausted`. The `allowance_spill` notice also only appears at launch.

::: tip Log the docs URL
When a refusal appears in your logs, log `docs` with it. It's the difference between a support conversation and someone reading the page that explains it.
:::

## Launching

```endpoint
POST /api/v1/campaigns/{campaign_id}/launch
```

```params
[
  {"name": "expected_version", "type": "integer", "required": true, "description": "The <code>version</code> you last read, 1 or more. A mismatch is a <code>409</code> rather than launching something that changed underneath you."},
  {"name": "confirm_recipients", "type": "integer", "required": false, "default": "null", "description": "The count you expect, 0 or more. A mismatch is a <code>409</code> instead of messaging a different number of people."}
]
```

::: codegroup

```bash cURL
curl -X POST https://services.amomic.in/api/v1/campaigns/cmp_01JRBA3F6H9K2M5P8R1T4W7Y0C/launch \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expected_version": 1, "confirm_recipients": 3788}'
```

```javascript Node.js
async function evaluateAndLaunch(campaignId) {
  const base = `https://services.amomic.in/api/v1/campaigns/${campaignId}`;
  const headers = {
    Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
    "Content-Type": "application/json",
  };

  const campaign = await (await fetch(base, { headers })).json();
  const check = await (await fetch(`${base}/evaluate`, { method: "POST", headers })).json();

  const blocks = check.findings.filter((f) => f.severity === "block");
  if (blocks.length) throw new Error(`${blocks[0].code}: ${blocks[0].fix} (${blocks[0].docs})`);

  const response = await fetch(`${base}/launch`, {
    method: "POST",
    headers,
    body: JSON.stringify({
      expected_version: campaign.version,
      confirm_recipients: check.eligible,
    }),
  });
  const body = await response.json();
  if (!response.ok) {
    const { code, message, details } = body.error;
    throw new Error(`${code} ${details.reason ?? ""}: ${message} ${details.docs ?? ""}`);
  }
  return body; // status "running" or "scheduled"
}
```

```python Python
import os

import requests

HEADERS = {"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"}


def evaluate_and_launch(campaign_id: str) -> dict:
    base = f"https://services.amomic.in/api/v1/campaigns/{campaign_id}"
    campaign = requests.get(base, headers=HEADERS, timeout=30).json()
    check = requests.post(f"{base}/evaluate", headers=HEADERS, timeout=60).json()

    blocks = [f for f in check["findings"] if f["severity"] == "block"]
    if blocks:
        raise RuntimeError(f"{blocks[0]['code']}: {blocks[0]['fix']} ({blocks[0]['docs']})")

    response = requests.post(
        f"{base}/launch",
        headers=HEADERS,
        json={"expected_version": campaign["version"], "confirm_recipients": check["eligible"]},
        timeout=60,
    )
    if not response.ok:
        error = response.json()["error"]
        details = error.get("details") or {}
        raise RuntimeError(f"{error['code']} {details.get('reason')}: {error['message']} {details.get('docs', '')}")
    return response.json()  # status "running" or "scheduled"
```

:::

Send `confirm_recipients` from the `eligible` you got back from `evaluate`. It's the cheapest protection there is against a segment that grew between the two calls.

A successful launch returns the campaign with `status` `running`, or `scheduled` if `start_at` is in the future, and fires `campaign.launched`. Launching a `paused` campaign resumes it instead; the audience isn't rebuilt.

### When a guardrail refuses

Launch runs its checks in this order, and stops at the first that refuses:

1. The campaign has to be a `draft` or `paused`. Otherwise `412` with `campaign_frozen`.
2. The channel has to still be on your plan. Otherwise `412` with `channel_dormant`.
3. There has to be plan allowance or prepaid credit left for this channel. Otherwise `412` with `allowance_exhausted`.
4. Every check `evaluate` runs. A `block` finding is a `412` with that finding's code.
5. `confirm_recipients`, if sent, has to equal the eligible count. Otherwise `409` with `version_conflict` and `details.eligible`.
6. `expected_version` has to match. Otherwise `409` with `version_conflict`, `details.expected_version` and `details.current_version`.

A guardrail refusal looks like this:

```json title="412 Precondition Failed"
{
  "error": {
    "code": "failed_precondition",
    "message": "Template 'amc_renewal_due' is not approved by Meta yet.",
    "details": {
      "reason": "template_not_approved",
      "docs": "https://docs.amomic.in/channels/whatsapp-templates#status",
      "fix": "Wait for approval (usually minutes, sometimes a day), or pick an approved template.",
      "findings": [
        { "code": "template_not_approved", "severity": "block", "message": "Template 'amc_renewal_due' is not approved by Meta yet.", "fix": "Wait for approval (usually minutes, sometimes a day), or pick an approved template.", "docs": "https://docs.amomic.in/channels/whatsapp-templates#status", "count": null }
      ]
    },
    "request_id": "req_01JRBA7J0L3N6Q9S2U5W8Y1B4D",
    "correlation_id": "cor_01JRBA7J0L3N6Q9S2U5W8Y1B4D"
  }
}
```

`details.reason` is stable and safe to branch on. `error.message` and `details.fix` are written for people and may improve. `details.findings` is the full list from the checks, so you can show every problem at once rather than one per attempt. The refusals from steps 1–3 carry `reason` and sometimes `docs`, but no `findings`.

The guardrail reasons you can meet at launch are `unsupported_channel`, `channel_not_connected`, `sender_blocked`, `audience_empty`, `audience_too_large` (over 50,000), `plan_limit` (over your plan's recipients per campaign; see [Campaign recipients](/operate/limits#campaign-recipients)), `template_not_found`, `template_not_approved`, `template_media_missing` and `voice_daily_cap`. Each is explained in [Every refusal, and what it means](/campaigns/sending-rules#every-refusal-and-what-it-means). There is no request field that switches any of them off.

If your contacts can't be read while the audience is built, the request fails with `502 provider_error`. It's safe to retry: nothing was staged.

## Controlling a running campaign

All three take `expected_version`. `pause` and `cancel` also accept an optional `reason`, up to 300 characters, recorded in the campaign's history.

| Call | Allowed from | Result |
|---|---|---|
| `POST …/pause` | `scheduled`, `running` | `paused`. Anything else is `412` with `campaign_frozen`. |
| `POST …/resume` | `paused` | `running`. Anything else is `412` with `campaign_frozen`. |
| `POST …/cancel` | anything not finished | `cancelled`. Everyone not yet reached is skipped with `campaign_cancelled`, and the counters are recounted. On a campaign that is already `completed` or `cancelled`, returns it unchanged. |

::: codegroup

```bash cURL
curl -X POST https://services.amomic.in/api/v1/campaigns/cmp_01JRBA3F6H9K2M5P8R1T4W7Y0C/pause \
  -H "Authorization: Bearer $AMOMIC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"expected_version": 3, "reason": "Wrong renewal date in the template"}'
```

```javascript Node.js
const response = await fetch(
  "https://services.amomic.in/api/v1/campaigns/cmp_01JRBA3F6H9K2M5P8R1T4W7Y0C/pause",
  {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.AMOMIC_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ expected_version: 3, reason: "Wrong renewal date in the template" }),
  },
);
const campaign = await response.json(); // status "paused", version 4
```

```python Python
import os

import requests

response = requests.post(
    "https://services.amomic.in/api/v1/campaigns/cmp_01JRBA3F6H9K2M5P8R1T4W7Y0C/pause",
    headers={"Authorization": f"Bearer {os.environ['AMOMIC_API_KEY']}"},
    json={"expected_version": 3, "reason": "Wrong renewal date in the template"},
    timeout=30,
)
campaign = response.json()  # status "paused", version 4
```

:::

Each returns the campaign with its new `version`. A running campaign's `version` moves on its own as sends are recorded, so read the campaign immediately before you pause it.

**Resume never duplicates.** Every recipient is an individual record claimed before it's sent, so nobody already messaged can be messaged again, by a resume, a retry, or two of your servers calling at once. Something already handed to WhatsApp or already ringing when you pause or cancel can't be recalled.

## Per-recipient results

```endpoint
GET /api/v1/campaigns/{campaign_id}/recipients
```

| Query | Default | Notes |
|---|---|---|
| `status` | none | One of `queued`, `sending`, `sent`, `delivered`, `read`, `failed`, `skipped`. |
| `limit` | `100` | 1–200. |
| `cursor` | none | The `next_cursor` from the previous page. |

Most recently changed first. A draft has no recipients yet; they are staged at launch.

```json title="200 OK"
{
  "items": [
    {
      "recipient_id": "crp_8e2b47d1f06a93c5b28e41d07f6a",
      "to": "+919811047352",
      "name": "Arjun Malhotra",
      "contact_id": "cnt_01JR9X4C7F2H5K8M1P4S7V0YBD",
      "status": "read",
      "reason": null,
      "message_id": "wamid.HBgMOTE5ODExMDQ3MzUyFQIAERgSMEI4…",
      "sent_at": "2026-10-14T06:10:44.281930+00:00",
      "outcome": null,
      "last_call_id": null
    },
    {
      "recipient_id": "crp_31c9f0e8a74b26d1e5c08f93a2b7",
      "to": "+919702264518",
      "name": "Farhan Qureshi",
      "contact_id": "cnt_01JR2B8E1G4J7L0N3Q6S9U2W5Y",
      "status": "skipped",
      "reason": "weekly_contact_cap",
      "message_id": null,
      "sent_at": null,
      "outcome": null,
      "last_call_id": null
    }
  ],
  "next_cursor": "eyJ1cGRhdGVkX2F0IjoiMjAyNi0xMC0xNFQwNjoxMDo0NC4yODE5MzAr…"
}
```

| `status` | Meaning |
|---|---|
| `queued` | Waiting to be sent. |
| `sending` | Being sent now. |
| `sent` | Handed to WhatsApp, or the call was placed. |
| `delivered`, `read` | WhatsApp only: delivered to the phone, then opened. |
| `failed` | Tried and failed. `reason` says why. |
| `skipped` | A rule left this person out before anything was sent. `reason` says which. |

The API returns `reason` only. The provider's own error code and detail are on the campaign's page in the console and in its exports. Every reason is listed in [Why someone wasn't reached](/campaigns/failure-reasons).

On a call campaign, each item also has `last_call_id` and `outcome`: that person's answers, merged across every call made to them. `outcome` is `null` until a call to them has been read.

```json title="A recipient on a call campaign"
{
  "recipient_id": "crp_7d1e93b0c46f28a5e17d90b3c4f8",
  "to": "+919845031276",
  "name": "Kavya Iyer",
  "contact_id": "cnt_01JRB7M9W2E5K8P3T6Y1V4QZAH",
  "status": "sent",
  "reason": null,
  "message_id": null,
  "sent_at": "2026-10-13T10:42:17.506214+00:00",
  "last_call_id": "call_01JRA4D7F0H3K6M9P2R5T8W1Y4",
  "outcome": {
    "disposition": "completed",
    "goal_met": "yes",
    "success": true,
    "summary": "Confirmed the AMC renewal and chose a Saturday morning service visit.",
    "sentiment": "positive",
    "next_step": "none",
    "fields": { "renew_amc": "yes", "visit_slot": "Saturday morning" },
    "captured": 2,
    "expected": 3,
    "calls": 2,
    "updated_at": "2026-10-13T10:49:02.118000+00:00"
  }
}
```

`calls` is how many calls to this person have been read. Two means a first call dropped and a second one finished the job; their answers are merged into this one record.

## Outcomes

A call campaign created with a `capture` reads every call against it. You get the same report the console's [Outcomes tab](/campaigns/outcomes) shows. The capture format and the per-call result are on [Call outcomes](/api/call-outcomes).

```endpoint
GET /api/v1/campaigns/{campaign_id}/outcomes
```

```json title="200 OK (trimmed)"
{
  "campaign_id": "cmp_01JRA2C5E8G1J4L7N0Q3S6U9W2",
  "name": "AMC renewal calls, October",
  "status": "running",
  "capture": { "goal": "Renew the AMC and book the service visit", "fields": [ … ], "success": { "field": "renew_amc", "equals": "yes" } },
  "totals": {
    "recipients": 486, "called": 461, "answered": 297, "debriefed": 294,
    "completed": 231, "partial": 36, "gave_answers": 267,
    "success": 204, "success_rate": 0.6869, "completion_rate": 0.7778, "answer_rate": 0.6443,
    "avg_capture": 0.84, "unread": 3
  },
  "funnel": [
    { "key": "recipients", "label": "In the audience", "value": 486 },
    { "key": "called", "label": "Called", "value": 461 },
    { "key": "answered", "label": "Picked up", "value": 297 },
    { "key": "gave_answers", "label": "Gave answers", "value": 267 },
    { "key": "completed", "label": "Completed", "value": 231 },
    { "key": "success", "label": "Success", "value": 204 }
  ],
  "dispositions": [ { "key": "completed", "label": "Completed", "count": 231 }, … ],
  "fields": [
    { "key": "renew_amc", "label": "Renew AMC", "type": "yes_no", "display": "auto", "required": true, "answered": 267, "yes": 204, "no": 63 },
    { "key": "visit_slot", "label": "Visit slot", "type": "choice", "display": "auto", "required": false, "answered": 198,
      "options": [ { "label": "Weekday morning", "count": 52 }, { "label": "Weekday evening", "count": 39 }, { "label": "Saturday morning", "count": 107 } ] }
  ],
  "timeline": [ { "at": "2026-10-13T10:00", "completed": 41, "partial": 7, "other": 22 }, … ]
}
```

- Rates are fractions from 0 to 1. `success_rate` and `completion_rate` are out of the calls that were picked up; `answer_rate` is out of the calls placed. A rate is `null` until there is something to divide by, and `success` is `null` when the capture has no success rule.
- `timeline` counts readings by hour when the readings span less than a day and a half, and by day (`YYYY-MM-DD`) after that.
- `avg_capture` is the average share of fields answered, from 0 to 1, across the people who picked up.
- `unread` counts answered calls that haven't been read yet. They're retried automatically.
- Each entry in `fields` summarises one field in a shape that depends on its type: `yes`/`no` counts, `options` counts, `avg`/`min`/`max`/`median` and `buckets` for numbers and ratings, day `buckets` for dates, and the latest `samples` for text.
- The report is cached for 15 seconds. It doesn't list individual people; page through `/recipients` for that.

```endpoint
GET /api/v1/campaigns/{campaign_id}/outcomes.csv
```

The console's export: a UTF-8 CSV with one row per person, including people not called yet. Columns are Name, Phone, Email, Outcome, Goal met, Success (when there's a success rule), one column per field labelled as in your capture, then Answers captured, Summary, Next step, Call attempts, Talk time (s), Call ID, and the columns from an uploaded list. It needs `campaigns:write`, not `campaigns:read`: the file carries emails and every column of an uploaded list, which the recipients endpoint doesn't. Every download is recorded on the campaign's history against the API key that made it.

Both endpoints return `412 failed_precondition` with `reason: "not_a_call_campaign"` for a WhatsApp campaign.

## Failures

```responses
[
  {"status": 400, "code": "invalid_argument", "reason": "template_not_found", "description": "Create: a WhatsApp campaign without a `template`."},
  {"status": 400, "code": "invalid_argument", "reason": "invalid_argument", "description": "Create: a voice campaign without `agent_id`, or `script`/`capture`/`call_back_incomplete` on WhatsApp."},
  {"status": 400, "code": "invalid_argument", "reason": "unknown_variable", "description": "Create: a `{{variable}}` in `purpose` or `script` the audience can't fill. `details.field` names it."},
  {"status": 400, "code": "invalid_argument", "reason": "", "description": "`Request validation failed.` A field outside its limits, an unknown `channel`, or a bad time zone. `details.errors` names it. Also a damaged `cursor`."},
  {"status": 401, "code": "unauthenticated", "reason": "", "description": "The key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which."},
  {"status": 403, "code": "permission_denied", "reason": "missing_scope", "description": "The key lacks `campaigns:read` or `campaigns:write`. `details.required_scope` names it."},
  {"status": 403, "code": "permission_denied", "reason": "workspace_suspended", "description": "The workspace is suspended. Restore billing to use its keys."},
  {"status": 404, "code": "not_found", "reason": "", "description": "`Campaign not found.`"},
  {"status": 409, "code": "conflict", "reason": "version_conflict", "description": "A stale `expected_version`, or `confirm_recipients` didn't match the eligible count (`details.eligible`). Read again and decide again."},
  {"status": 409, "code": "conflict", "reason": "idempotency_key_reuse", "description": "Create: the same `Idempotency-Key` with a different body."},
  {"status": 412, "code": "failed_precondition", "reason": "campaign_frozen", "description": "The action doesn't fit the campaign's status: launching a running campaign, pausing a draft, resuming one that isn't paused."},
  {"status": 412, "code": "failed_precondition", "reason": "channel_dormant", "description": "Launch: a plan change took this channel away."},
  {"status": 412, "code": "failed_precondition", "reason": "allowance_exhausted", "description": "Launch: no plan allowance and no prepaid credits left for this channel. Top up, then launch again. See [Credits](/operate/credits#what-happens-when-you-run-out)."},
  {"status": 412, "code": "failed_precondition", "reason": "plan_limit", "description": "Launch: the audience is over your plan's recipients per campaign. Narrow it or upgrade."},
  {"status": 412, "code": "failed_precondition", "reason": "…", "description": "Launch: any other guardrail. `details` carries `reason`, `fix`, `docs` and `findings`."},
  {"status": 412, "code": "failed_precondition", "reason": "not_a_call_campaign", "description": "`/outcomes` or `/outcomes.csv` on a WhatsApp campaign."},
  {"status": 429, "code": "rate_limited", "reason": "rate_limited", "description": "120 requests per minute per key. See [Rate limits](/api/rate-limits)."},
  {"status": 502, "code": "provider_error", "reason": "provider_error", "description": "Evaluate or launch: your contacts couldn't be read to build the audience. Nothing was staged; retry."}
]
```

## Events

Subscribe rather than poll:

| Event | When |
|---|---|
| `campaign.launched` | A campaign started sending, with the recipient count. |
| `campaign.completed` | Every recipient reached a final state, with the counters. |
| `call.outcome` | One call in a call campaign has been read. Carries `campaign_id` and the answers. |

There is no event for pause, resume or cancel. See [Webhooks](/webhooks/overview).

## Idempotency

`POST /campaigns` honours `Idempotency-Key`: a retry replays the first result instead of creating a second draft. The other campaign writes don't need one. `launch`, `pause`, `resume` and `cancel` are guarded by `expected_version`, so a retry of a call that already worked gets a `409` instead of acting twice. See [Idempotency](/api/idempotency).
