eSMS AfricaeSMS Africa
API ReferenceSMS API

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_key

Keys 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 | failed

A 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

MethodPathDescription
POST/v1/campaignsCreate a draft. "launch": true creates and launches. Honours Idempotency-Key.
GET/v1/campaignsList (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}/prepareBuild 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}/reviewblocking, warnings, info, can_launch, needs_approval and the estimate.
POST/v1/campaigns/{id}/testAPI keys: {to: [up to 3 numbers]}. Dashboard: your verified phone. Billed like one SMS.
POST/v1/campaigns/{id}/launchSend 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}/pausePause a scheduled or sending campaign.
POST/v1/campaigns/{id}/resumeResume a paused one; it continues where it stopped.
POST/v1/campaigns/{id}/cancelCancel; unsent recipients are never charged. Returns already_sent.
POST/v1/campaigns/{id}/retry-failedSend again to recipients whose last attempt failed (charged as a new send).
POST/v1/campaigns/{id}/duplicateCopy into a new draft.
GET/v1/campaigns/{id}/reportTotals, delivery rate, cost, time to deliver, failure reasons, by day, country and network.
GET/v1/campaigns/{id}/statsCompact message counts by status, spent, progress.
GET/v1/campaigns/{id}/recipientsPer-recipient status (status, country, q, page, limit).
GET/v1/campaigns/{id}/recipients/exportformat=csv (default) or xlsx, optional status.
GET/v1/campaigns/{id}/runsRuns 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"
}
FieldDescription
nameCampaign name.
textMessage template with merge fields (text_template is accepted too).
template_idStart from a saved template (copies its text, type and sender ID).
audienceSources: {"type": "import", "id"} (a processed import), {"type": "group", "id"}, {"type": "segment", "id"}, {"type": "numbers", "numbers": [...]}. Up to 50 sources.
recipientsInline [{to, name?, vars?, text?}] (up to 10,000); added as an import source. Invalid ones are returned in rejected.
contact_list_ids, segment_idShortcuts for group and segment sources.
default_countryFor local numbers in inline recipients.
message_typetransactional or promotional.
optout_textOverrides the workspace opt-out line for promotional messages.
sender_id, sender_fallbackSee the guide.
scheduleSee below.
throttle_per_minute1-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_urlReceives the signed campaign.completed event (public URL).
launchtrue to launch right after creating.

Schedule

FieldDescription
modenow, scheduled or recurring.
atISO 8601. Without an offset it is read in timezone (2026-11-01T09:00). For recurring, the first run. Up to 12 months ahead.
timezoneIANA name; default your workspace timezone.
recurrence.freqdaily, weekly, monthly or cron.
recurrence.timeHH:MM (not for cron).
recurrence.intervalEvery N days / weeks / months (1-52).
recurrence.weekdaysWeekly: 0 (Monday) to 6 (Sunday).
recurrence.monthdayMonthly: 1-31, or -1 for the last day.
recurrence.cronminute hour day month weekday, e.g. 0 9 * * 1-5; at most 12 runs an hour.
recurrence.until / countEnd 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

CodeKindMeaning
no_audience, no_recipients, no_messageblockingNothing to send.
missing_fieldblockingSome recipients have no value for a field without a fallback (field names it).
sender_not_approvedblockingSender ID not approved for some countries and sender_fallback is off.
insufficient_balanceblockingSend-now campaign costs more than the balance (a warning, low_balance, for scheduled and repeating ones).
content_blockedblockingThe message contains a phrase the network content filter rejects.
no_time, time_in_past, no_recurrenceblockingSchedule problems.
unicode, unavailable_country, too_long, invalid_numbers, no_test, low_balancewarningWorth checking; they don't block.
opted_out, duplicates, multi_segment, quiet_hours, approval_requiredinfoNotes.

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

StatusCodeMeaning
400invalid_audience, invalid_schedule, invalid_timezone, invalid_message_type, invalid_quiet_hours, invalid_callback_url, too_many_recipientsBad input.
404not_found, import_not_found, group_not_found, segment_not_found, template_not_foundUnknown id.
409import_not_readyProcess the import first.
409not_editable, not_launchable, busy, has_history, not_pending, not_paused, not_running, finishedNot allowed in the current status.
403self_approvalThe submitter can't approve their own campaign.
403role_forbiddenYour team role can't do this.
422launch_blockedBlocking checks failed; error.details.blocking lists them.
402insufficient_balanceTest 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"})

On this page