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.
/api/v1/campaignsA 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 #
namestringrequired1–140 characters. Internal only: how you find the campaign later.
channelstringoptionaldefault whatsappwhatsapp creates a broadcast, voice a call batch. Any other value fails validation with 400.
whatsappvoiceaudienceobjectrequiredWho the campaign reaches. See below.
templateobjectoptionaldefault nullRequired for whatsapp. An approved template plus the values to fill it with.
agent_idstringoptionaldefault nullRequired for voice. The agent that places the calls. Get it from GET /agents.
purposestringoptionaldefault nullVoice 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 nullVoice 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 nullVoice 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 falseVoice only. Call a person back once when their call ended partway with answers still missing. The call-back carries what they already said.
scheduleobjectoptionalStart time, pace and sending window. See below.
consentobjectoptionaldefault nullOptional. A lawful basis to record with the campaign. Not needed to launch.
sender_idstringoptionaldefault nullWhatsApp 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_idsUploaded lists are console-only: an uploaded file is a claim about people we want a named person behind.
contact_idssegmentcontact_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 #
namestringrequiredThe template's name, up to 512 characters. It has to be approved at launch time.
languagestringoptionaldefault nullLanguage 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.
consent #
basisstringrequiredWhy you may contact these people.
existing_customerexplicit_opt_ininbound_enquiryservice_messagenotestringoptionaldefault nullUp 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 nullISO 8601. In the future at launch, the campaign is scheduled until then. Omitted or past, it starts when you launch.
timezonestringoptionaldefault workspaceAn 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 workspace1–600 sends per minute. Omitted, your campaign settings' default (60 unless changed).
send_window_start_hourintegeroptionaldefault workspace0–23. May narrow the 09:00 floor, never widen it; an earlier hour is clamped.
send_window_end_hourintegeroptionaldefault workspace1–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 #
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:
{"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 #
/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/v1/campaigns/{campaign_id}Returns one campaign, or 404 not_found with Campaign not found.
Check before you send #
/api/v1/campaigns/{campaign_id}/evaluateRuns 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.
{"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 #
/api/v1/campaigns/{campaign_id}/launchexpected_versionintegerrequiredThe version you last read, 1 or more. A mismatch is a 409 rather than launching something that changed underneath you.
confirm_recipientsintegeroptionaldefault nullThe count you expect, 0 or more. A mismatch is a 409 instead of messaging a different number of people.
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:
- The campaign has to be a
draftorpaused. Otherwise412withcampaign_frozen. - The channel has to still be on your plan. Otherwise
412withchannel_dormant. - There has to be plan allowance or prepaid credit left for this channel. Otherwise
412withallowance_exhausted. - Every check
evaluateruns. Ablockfinding is a412with that finding's code. confirm_recipients, if sent, has to equal the eligible count. Otherwise409withversion_conflictanddetails.eligible.expected_versionhas to match. Otherwise409withversion_conflict,details.expected_versionanddetails.current_version.
A guardrail refusal looks like this:
{"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. |
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 #
/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.
{"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.
{"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.
/api/v1/campaigns/{campaign_id}/outcomes{"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_rateandcompletion_rateare out of the calls that were picked up;answer_rateis out of the calls placed. A rate isnulluntil there is something to divide by, andsuccessisnullwhen the capture has no success rule. timelinecounts readings by hour when the readings span less than a day and a half, and by day (YYYY-MM-DD) after that.avg_captureis the average share of fields answered, from 0 to 1, across the people who picked up.unreadcounts answered calls that haven't been read yet. They're retried automatically.- Each entry in
fieldssummarises one field in a shape that depends on its type:yes/nocounts,optionscounts,avg/min/max/medianandbucketsfor numbers and ratings, daybucketsfor dates, and the latestsamplesfor text. - The report is cached for 15 seconds. It doesn't list individual people; page through
/recipientsfor that.
/api/v1/campaigns/{campaign_id}/outcomes.csvThe 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 #
invalid_argumenttemplate_not_foundCreate: a WhatsApp campaign without a `template`.invalid_argumentinvalid_argumentCreate: a voice campaign without `agent_id`, or `script`/`capture`/`call_back_incomplete` on WhatsApp.invalid_argumentunknown_variableCreate: a `{{variable}}` in `purpose` or `script` the audience can't fill. `details.field` names it.invalid_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`.unauthenticatedThe key is missing, malformed, unknown, revoked, past its roll grace period, or for the wrong environment. `details.reason` says which.permission_deniedmissing_scopeThe key lacks `campaigns:read` or `campaigns:write`. `details.required_scope` names it.permission_deniedworkspace_suspendedThe workspace is suspended. Restore billing to use its keys.not_found`Campaign not found.`conflictversion_conflictA stale `expected_version`, or `confirm_recipients` didn't match the eligible count (`details.eligible`). Read again and decide again.conflictidempotency_key_reuseCreate: the same `Idempotency-Key` with a different body.failed_preconditioncampaign_frozenThe action doesn't fit the campaign's status: launching a running campaign, pausing a draft, resuming one that isn't paused.failed_preconditionchannel_dormantLaunch: a plan change took this channel away.failed_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).failed_preconditionplan_limitLaunch: the audience is over your plan's recipients per campaign. Narrow it or upgrade.failed_precondition…Launch: any other guardrail. `details` carries `reason`, `fix`, `docs` and `findings`.failed_preconditionnot_a_call_campaign`/outcomes` or `/outcomes.csv` on a WhatsApp campaign.rate_limitedrate_limited120 requests per minute per key. See [Rate limits](/api/rate-limits).provider_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.