Campaigns
Create, preview, prepare, test, launch, approve, pause, resume, cancel and report on personalised bulk campaigns.
A campaign is a personalised bulk send with an audience, a message, a sender ID
and a schedule. The guide explains the concepts; this
page lists every endpoint. All paths work under /v1 and /api.
Authorization: Bearer esms_live_your_api_keyKeys need the send scope to create and launch, and query to read. An
esms_test_ key creates test campaigns that are simulated end to end
(no real SMS, no charge).
Lifecycle
draft -> (launch) -> pending_approval -> (approve) -> scheduled | sending
sending <-> paused (by you, or automatically when the balance runs out)
sending -> completed | cancelled | failedA repeating campaign stays scheduled and starts a run (a child campaign
with parent_id) at each occurrence; it becomes completed when the rule ends.
Endpoints
| Method | Path | Description |
|---|---|---|
POST | /v1/campaigns | Create a draft. "launch": true creates and launches. Honours Idempotency-Key. |
GET | /v1/campaigns | List (status comma list, q, tag, page, limit, include_runs). Returns campaigns, total, status_counts. |
GET | /v1/campaigns/{id} | Detail, plus progress while sending and the latest background job. |
PATCH | /v1/campaigns/{id} | Update a draft (partial). A scheduled campaign that has not started goes back to draft. |
DELETE | /v1/campaigns/{id} | Delete a campaign that never sent (409 has_history otherwise). |
POST | /v1/campaigns/{id}/preview | {text?, n?} - the first n (max 50) rendered messages and the longest message among the first 5,000 recipients. |
POST | /v1/campaigns/{id}/prepare | Build the recipient list and the exact estimate. status: "ready" with estimate + review, or "preparing" with a job_id for audiences over 20,000. |
GET | /v1/campaigns/{id}/review | blocking, warnings, info, can_launch, needs_approval and the estimate. |
POST | /v1/campaigns/{id}/test | API keys: {to: [up to 3 numbers]}. Dashboard: your verified phone. Billed like one SMS. |
POST | /v1/campaigns/{id}/launch | Send now, schedule, or submit for approval. |
POST | /v1/campaigns/{id}/approve | {note?} - approver, admin or owner; never the submitter. |
POST | /v1/campaigns/{id}/reject | {note?} - back to draft with the note. |
POST | /v1/campaigns/{id}/pause | Pause a scheduled or sending campaign. |
POST | /v1/campaigns/{id}/resume | Resume a paused one; it continues where it stopped. |
POST | /v1/campaigns/{id}/cancel | Cancel; unsent recipients are never charged. Returns already_sent. |
POST | /v1/campaigns/{id}/retry-failed | Send again to recipients whose last attempt failed (charged as a new send). |
POST | /v1/campaigns/{id}/duplicate | Copy into a new draft. |
GET | /v1/campaigns/{id}/report | Totals, delivery rate, cost, time to deliver, failure reasons, by day, country and network. |
GET | /v1/campaigns/{id}/stats | Compact message counts by status, spent, progress. |
GET | /v1/campaigns/{id}/recipients | Per-recipient status (status, country, q, page, limit). |
GET | /v1/campaigns/{id}/recipients/export | format=csv (default) or xlsx, optional status. |
GET | /v1/campaigns/{id}/runs | Runs of a repeating campaign. |
Create
{
"name": "October payment reminders",
"text": "Hi {first_name|there}, your payment of KES {amount} is due on {due_date}.",
"audience": [
{ "type": "import", "id": "imp_3f9c1a2b4d5e6f708192" },
{ "type": "group", "id": 12 }
],
"sender_id": "ACMEBANK",
"sender_fallback": true,
"message_type": "transactional",
"tags": ["october", "reminders"],
"schedule": {
"mode": "recurring",
"timezone": "Africa/Nairobi",
"recurrence": { "freq": "monthly", "monthday": 25, "time": "09:00" }
},
"throttle_per_minute": 600,
"quiet_hours": { "enabled": true, "start": "21:00", "end": "08:00" },
"callback_url": "https://example.com/hooks/campaigns"
}| Field | Description |
|---|---|
name | Campaign name. |
text | Message template with merge fields (text_template is accepted too). |
template_id | Start from a saved template (copies its text, type and sender ID). |
audience | Sources: {"type": "import", "id"} (a processed import), {"type": "group", "id"}, {"type": "segment", "id"}, {"type": "numbers", "numbers": [...]}. Up to 50 sources. |
recipients | Inline [{to, name?, vars?, text?}] (up to 10,000); added as an import source. Invalid ones are returned in rejected. |
contact_list_ids, segment_id | Shortcuts for group and segment sources. |
default_country | For local numbers in inline recipients. |
message_type | transactional or promotional. |
optout_text | Overrides the workspace opt-out line for promotional messages. |
sender_id, sender_fallback | See the guide. |
schedule | See below. |
throttle_per_minute | 1-20,000. |
quiet_hours | {enabled, start, end} (HH:MM). Without enabled, promotional campaigns follow the workspace quiet hours and transactional ones don't use them. |
callback_url | Receives the signed campaign.completed event (public URL). |
launch | true to launch right after creating. |
Schedule
| Field | Description |
|---|---|
mode | now, scheduled or recurring. |
at | ISO 8601. Without an offset it is read in timezone (2026-11-01T09:00). For recurring, the first run. Up to 12 months ahead. |
timezone | IANA name; default your workspace timezone. |
recurrence.freq | daily, weekly, monthly or cron. |
recurrence.time | HH:MM (not for cron). |
recurrence.interval | Every N days / weeks / months (1-52). |
recurrence.weekdays | Weekly: 0 (Monday) to 6 (Sunday). |
recurrence.monthday | Monthly: 1-31, or -1 for the last day. |
recurrence.cron | minute hour day month weekday, e.g. 0 9 * * 1-5; at most 12 runs an hour. |
recurrence.until / count | End on a date (YYYY-MM-DD) or after 1-1000 runs. |
Campaign object
{
"id": 482,
"name": "October payment reminders",
"status": "sending",
"source": "api",
"environment": "live",
"livemode": true,
"batch_id": "6f0f5c55-6a2e-4bb1-9c39-2a6f1b6c7d10",
"tags": ["october"],
"audience": [{ "type": "import", "id": "imp_3f9c1a2b4d5e6f708192" }],
"text": "Hi {first_name|there}, ...",
"message_type": "transactional",
"sender_id": "ACMEBANK",
"sender_fallback": true,
"schedule": { "mode": "now", "scheduled_at": null, "timezone": "Africa/Nairobi", "recurrence": null,
"recurrence_text": null, "next_run_at": null, "runs": 0 },
"throttle_per_minute": null,
"quiet_hours": null,
"approval": { "status": "not_required", "note": null, "submitted_by": null, "approved_by": null, "approved_at": null },
"totals": { "recipients": 12840, "sent": 9100, "failed": 41, "skipped": 18, "spent": 109.2,
"estimated_cost": 154.08, "currency": "USD" },
"estimate": { "recipients": 12840, "total_cost": 154.08, "currency": "USD", "countries": [ "..." ] },
"pause_reason": null,
"progress": { "total": 12858, "done": 9159, "pending": 3499, "percent": 71.2 },
"created_at": "2026-10-22T08:00:00+00:00",
"started_at": "2026-10-22T08:05:00+00:00",
"completed_at": null
}pause_reason is insufficient_balance when the campaign paused itself
because the next batch could not be paid for (you are notified in the
dashboard and by email). approval.status is not_required, pending,
approved or rejected.
Estimate (from prepare / review)
recipients, candidates, duplicates, invalid and skipped (counts by
reason), countries[] (country_code, country_name, recipients,
segments, unit_price, price_currency, sender, cost, operators),
total_segments, total_cost, currency, balance, sufficient_balance,
longest (segments, encoding, chars, phone, text), encodings
(GSM7 / UCS2 counts), missing_fields and optout_line. Costs are in your
wallet currency at your own prices.
Review checks
| Code | Kind | Meaning |
|---|---|---|
no_audience, no_recipients, no_message | blocking | Nothing to send. |
missing_field | blocking | Some recipients have no value for a field without a fallback (field names it). |
sender_not_approved | blocking | Sender ID not approved for some countries and sender_fallback is off. |
insufficient_balance | blocking | Send-now campaign costs more than the balance (a warning, low_balance, for scheduled and repeating ones). |
content_blocked | blocking | The message contains a phrase the network content filter rejects. |
no_time, time_in_past, no_recurrence | blocking | Schedule problems. |
unicode, unavailable_country, too_long, invalid_numbers, no_test, low_balance | warning | Worth checking; they don't block. |
opted_out, duplicates, multi_segment, quiet_hours, approval_required | info | Notes. |
Report
{
"totals": { "recipients": 12858, "pending": 0, "sending": 0, "sent": 120, "delivered": 12619,
"failed": 101, "skipped": 18, "cancelled": 0 },
"sent_total": 12840,
"delivery_rate": 99.2,
"cost": { "spent": 153.6, "currency": "USD", "estimated": 154.08 },
"time_to_deliver": { "samples": 12619, "median_seconds": 6.2, "p90_seconds": 21.0, "average_seconds": 9.8 },
"failure_reasons": [
{ "reason": "Not delivered by the network - the phone was off, out of coverage, or the SIM is inactive", "code": "UNDELIV", "count": 77 }
],
"by_day": [{ "date": "2026-10-22", "sent": 12840, "delivered": 12619, "failed": 101 }],
"by_country": [{ "key": "KE", "country_name": "Kenya", "recipients": 9000, "delivered": 8950, "failed": 30,
"sent": 20, "pending": 0, "skipped": 0, "delivery_rate": 99.7, "cost": 108.0 }],
"by_operator": [{ "key": "Safaricom", "recipients": 7000, "delivered": 6980, "failed": 12, "delivery_rate": 99.8 }],
"runs": null
}Recipient statuses: pending, sending, sent (handed to the network,
waiting for a delivery report), delivered, failed, skipped (opted out,
unavailable country, empty or over-long message; never charged), cancelled.
Each recipient row has phone, name, country, operator, status,
reason (plain words), segments, cost, attempts, message_id, text,
sent_at, delivered_at and vars.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_audience, invalid_schedule, invalid_timezone, invalid_message_type, invalid_quiet_hours, invalid_callback_url, too_many_recipients | Bad input. |
| 404 | not_found, import_not_found, group_not_found, segment_not_found, template_not_found | Unknown id. |
| 409 | import_not_ready | Process the import first. |
| 409 | not_editable, not_launchable, busy, has_history, not_pending, not_paused, not_running, finished | Not allowed in the current status. |
| 403 | self_approval | The submitter can't approve their own campaign. |
| 403 | role_forbidden | Your team role can't do this. |
| 422 | launch_blocked | Blocking checks failed; error.details.blocking lists them. |
| 402 | insufficient_balance | Test send with too little balance. |
Example: file to report
# 1. upload + process (see Imports), then:
curl -X POST https://sms.esmsafrica.io/v1/campaigns \
-H "Authorization: Bearer $ESMS_API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: october-dues" \
-d '{"name":"October dues","text":"Hi {first_name}, you owe {amount|0}.",
"audience":[{"type":"import","id":"imp_3f9c1a2b4d5e6f708192"}]}'
curl -X POST https://sms.esmsafrica.io/v1/campaigns/482/prepare -H "Authorization: Bearer $ESMS_API_KEY"
curl -X POST https://sms.esmsafrica.io/v1/campaigns/482/launch -H "Authorization: Bearer $ESMS_API_KEY"
curl https://sms.esmsafrica.io/v1/campaigns/482/report -H "Authorization: Bearer $ESMS_API_KEY"// SDK 1.2.0 or later
const c = await esms.campaigns.create({
name: "October dues", text: "Hi {first_name}, you owe {amount|0}.",
audience: [{ type: "import", id: imp.id }], idempotencyKey: "october-dues",
});
await esms.campaigns.prepare(c.id);
const { review } = await esms.campaigns.review(c.id);
if (review.can_launch) await esms.campaigns.launch(c.id);
const report = await esms.campaigns.report(c.id);
const failed = await esms.campaigns.recipients(c.id, { status: "failed" });# SDK 1.2.0 or later
c = esms.campaigns.create(name="October dues", text="Hi {first_name}, you owe {amount|0}.",
audience=[{"type": "import", "id": imp["id"]}], idempotency_key="october-dues")
esms.campaigns.prepare(c["id"])
if esms.campaigns.review(c["id"])["review"]["can_launch"]:
esms.campaigns.launch(c["id"])
report = esms.campaigns.report(c["id"])
failed = esms.campaigns.recipients(c["id"], status="failed")// SDK 1.2.0 or later
$c = $esms->campaigns->create(['name' => 'October dues', 'text' => 'Hi {first_name}, you owe {amount|0}.',
'audience' => [['type' => 'import', 'id' => $imp['id']]], 'idempotency_key' => 'october-dues']);
$esms->campaigns->prepare($c['id']);
if ($esms->campaigns->review($c['id'])['review']['can_launch']) {
$esms->campaigns->launch($c['id']);
}
$report = $esms->campaigns->report($c['id']);
$failed = $esms->campaigns->recipients($c['id'], ['status' => 'failed']);// SDK 1.2.0 or later
c, _ := client.Campaigns.Create(ctx, esms.JSON{"name": "October dues", "text": "Hi {first_name}, you owe {amount|0}.",
"audience": []esms.JSON{{"type": "import", "id": impID}}}, "october-dues")
id := int(c["id"].(float64))
client.Campaigns.Prepare(ctx, id)
client.Campaigns.Launch(ctx, id)
report, _ := client.Campaigns.Report(ctx, id)
failed, _ := client.Campaigns.Recipients(ctx, id, esms.ListQuery{"status": "failed"})Personalised batch
POST /v1/messages/batch - Send an individual message to many recipients in one call, with merge fields, scheduling and an Idempotency-Key.
Imports
Upload CSV or Excel files, paste numbers or link a Google Sheet; map columns; validate, normalise to E.164, dedupe and drop opted-out numbers in the background.