Skip to content

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.

Open Campaigns
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.

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.

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 #

namestringrequired

1–140 characters. Internal only: how you find the campaign later.

channelstringoptionaldefault whatsapp

whatsapp creates a broadcast, voice a call batch. Any other value fails validation with 400.

whatsappvoice
audienceobjectrequired

Who the campaign reaches. See below.

templateobjectoptionaldefault null

Required for whatsapp. An approved template plus the values to fill it with.

agent_idstringoptionaldefault null

Required for voice. The agent that places the calls. Get it from GET /agents.

purposestringoptionaldefault null

Voice only. Why the agent is calling; reaches its opening line and instructions. Up to 500 characters. May carry {{name}}, {{phone}} and {{email}} (or the {{contact.*}} forms), filled per recipient, and {{all}} to hand the agent every detail on the contact. See Call scripts and variables.

scriptstringoptionaldefault null

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

captureobjectoptionaldefault null

Voice only. What to find out on every call: a goal, fields and a success rule, in the same format as on POST /calls. See Call outcomes. Results are on /outcomes.

call_back_incompletebooleanoptionaldefault false

Voice only. Call a person back once when their call ended partway with answers still missing. The call-back carries what they already said.

scheduleobjectoptional

Start time, pace and sending window. See below.

consentobjectoptionaldefault null

Optional. A lawful basis to record with the campaign. Not needed to launch.

sender_idstringoptionaldefault null

WhatsApp only. Which connected sender to use, from GET /channels. Omitted, the workspace's default sender.

metadataobjectoptionaldefault {}

Stored with the campaign for your own reference. Not returned by the API, so keep your own mapping from campaign_id.

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 #

kindstringoptionaldefault contact_ids

Uploaded lists are console-only: an uploaded file is a claim about people we want a named person behind.

contact_idssegment
contact_idsstring[]optionaldefault []

For contact_ids. Up to 50,000 ids from Contacts. Ids from another workspace are dropped.

tagsstring[]optionaldefault []

For segment. Up to 20. A contact needs every 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 #

namestringrequired

The template's name, up to 512 characters. It has to be approved at launch time.

languagestringoptionaldefault null

Language code such as en or hi. Omitted, the English variant is preferred.

variablesstring[]optionaldefault []

Up to 20, one per placeholder, in order. A literal, or a token: {{contact.name}}, {{contact.phone}}, {{contact.email}}.

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.

basisstringrequired

Why you may contact these people.

existing_customerexplicit_opt_ininbound_enquiryservice_message
notestringoptionaldefault null

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.

schedule #

Every field is optional. A field you leave out comes from your workspace's campaign settings (the Sending rules page in the console), not from a fixed default.

start_atstringoptionaldefault null

ISO 8601. In the future at launch, the campaign is scheduled until then. Omitted or past, it starts when you launch.

timezonestringoptionaldefault workspace

An IANA zone such as Asia/Kolkata, 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.

throttle_per_minuteintegeroptionaldefault workspace

1–600 sends per minute. Omitted, your campaign settings' default (60 unless changed).

send_window_start_hourintegeroptionaldefault workspace

0–23. May narrow the 09:00 floor, never widen it; an earlier hour is clamped.

send_window_end_hourintegeroptionaldefault workspace

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.

Creating one #

Shell
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 }
}'

The campaign object #

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

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

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.

GET/api/v1/campaigns/{campaign_id}

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

Check before you send #

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.

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

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 #

POST/api/v1/campaigns/{campaign_id}/launch
expected_versionintegerrequired

The version you last read, 1 or more. A mismatch is a 409 rather than launching something that changed underneath you.

confirm_recipientsintegeroptionaldefault null

The count you expect, 0 or more. A mismatch is a 409 instead of messaging a different number of people.

Shell
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}'

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:

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), template_not_found, template_not_approved, template_media_missing and voice_daily_cap. Each is explained in 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.
Shell
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"}'

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 #

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.

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.

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.

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 shows. The capture format and the per-call result are on Call outcomes.

GET/api/v1/campaigns/{campaign_id}/outcomes
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.
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 #

400invalid_argumenttemplate_not_foundCreate: a WhatsApp campaign without a `template`.
400invalid_argumentinvalid_argumentCreate: a voice campaign without `agent_id`, or `script`/`capture`/`call_back_incomplete` on WhatsApp.
400invalid_argumentunknown_variableCreate: a `{{variable}}` in `purpose` or `script` the audience can't fill. `details.field` names it.
400invalid_argument`Request validation failed.` A field outside its limits, an unknown `channel`, or a bad time zone. `details.errors` names it. Also a damaged `cursor`.
401unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which.
403permission_deniedmissing_scopeThe key lacks `campaigns:read` or `campaigns:write`. `details.required_scope` names it.
403permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.
404not_found`Campaign not found.`
409conflictversion_conflictA stale `expected_version`, or `confirm_recipients` didn't match the eligible count (`details.eligible`). Read again and decide again.
409conflictidempotency_key_reuseCreate: the same `Idempotency-Key` with a different body.
412failed_preconditioncampaign_frozenThe action doesn't fit the campaign's status: launching a running campaign, pausing a draft, resuming one that isn't paused.
412failed_preconditionchannel_dormantLaunch: a plan change took this channel away.
412failed_preconditionallowance_exhaustedLaunch: 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).
412failed_preconditionplan_limitLaunch: the audience is over your plan's recipients per campaign. Narrow it or upgrade.
412failed_precondition…Launch: any other guardrail. `details` carries `reason`, `fix`, `docs` and `findings`.
412failed_preconditionnot_a_call_campaign`/outcomes` or `/outcomes.csv` on a WhatsApp campaign.
429rate_limitedrate_limited120 requests per minute per key. See [Rate limits](/api/rate-limits).
502provider_errorprovider_errorEvaluate 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.

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.