Bulk and personalised campaigns
Upload a CSV or Excel file, personalise every message with merge fields, and send now, on a schedule or on repeat - with per-recipient delivery reports.
Campaigns send an individual message to every person on a list: payment reminders with each customer's amount and due date, bank and transaction alerts, appointment reminders, delivery notices, one-time codes, promotions and event invites. Use the wizard in the dashboard (Campaigns > New campaign) or the API: Personalised batch for one-call sends, and Campaigns for the full flow.
1. Audience
Combine any of these sources in one campaign. Numbers are de-duplicated across all of them and opted-out numbers are removed.
| Source | How |
|---|---|
| File | CSV (comma, semicolon, tab or pipe; UTF-8, UTF-16 or Windows-1252), Excel .xlsx and .xls. Up to 50 MB and about 500,000 rows. The first sheet is read. |
| Paste | Numbers one per line or separated by commas or spaces, or rows copied from a spreadsheet with a header row. |
| Google Sheets | A link to a sheet shared as "Anyone with the link can view". A copy is read once. |
| Contacts | Your contact groups and segments. |
| Numbers | A short typed list (API: {"type": "numbers", "numbers": [...]}). |
Column mapping
After an upload you get the columns, a sample and a suggested mapping: the
phone column (by its header such as phone, mobile, msisdn, or by its
content), an optional name column, and every other column as a merge field.
Header names become field keys in lower case with underscores, so a column
called Amount Due is {amount_due} (you can rename keys).
Phone numbers
Every number is normalised to E.164 (+256772123456).
- Numbers with a country code work in any form:
+256 772-123-456,256772123456,00256772123456. - Local numbers like
0772 123 456need a default country (set it per import or in Settings > Messaging). - Excel numeric cells are read correctly. A number Excel already turned into scientific notation (
2.56772E+11) cannot be recovered: format the column as Text and export again.
Rows that can't be used are listed with a reason, and you can download them as an error file (CSV with row, value, reason, explanation):
| Reason | Meaning |
|---|---|
empty | No phone number in the row |
not_a_number | Letters or symbols |
scientific_notation | Excel scientific notation |
too_short / too_long | Not a phone number length |
missing_country_code | Local number with no default country |
invalid_number | Not a valid number for its country |
landline | A fixed line that cannot receive SMS |
duplicate | Already in this import (the row it duplicates is named) |
opted_out | The number replied STOP or is on your opt-out list |
Numbers in countries we don't send to yet are kept in the import but skipped (and never charged) when the campaign sends.
2. Message
Merge fields
| Syntax | Result |
|---|---|
{first_name} | The recipient's first_name field |
{name|Customer} | Customer when the field is empty or missing |
{{amount}} | Same as {amount} (the older double-brace style keeps working) |
Built-in fields: name, first_name, last_name (split from the name
column) and phone. A field that is empty for some recipients and has no
fallback blocks the launch, so nobody receives a blank or a literal
{amount}.
Length, encoding and cost
- The composer counts characters and segments for the template, and shows the longest real message after merge fields are filled in, because that is what you pay for: 160 characters per segment for GSM-7 (153 when a message has several segments), 70 for Unicode (67).
- A character outside GSM-7 (curly quotes, long dashes, emoji, some accented letters) switches that message to Unicode. The composer names the character and offers a GSM-7 replacement where one exists.
- Before you send, the review shows recipients, segments and the cost per country at your own prices (including any custom per-customer price), your balance, and anything that blocks the send.
Transactional or promotional
| Type | Behaviour |
|---|---|
| Transactional | Alerts, reminders, codes, receipts. Sent at any hour. |
| Promotional | Offers and invites. An opt-out line (default Reply STOP to opt out, editable in Settings > Messaging) is added unless the message already mentions STOP, and quiet hours apply. |
Sender ID
Your sender ID must be approved for each destination country. With
sender_fallback on (the default), countries where it isn't approved use the
country's default sender; with it off, the launch is blocked and the review
lists the problem.
3. Schedule
| Mode | Behaviour |
|---|---|
| Send now | Starts as soon as you launch. |
| Schedule | One date and time in a timezone you pick (default: your workspace timezone). A time without an offset is read in that timezone. Up to 12 months ahead. |
| Repeat | Daily, weekly (chosen weekdays), monthly (a day of the month or the last day), or a cron expression (minute hour day month weekday; at most 12 runs an hour). Ends never, on a date, or after a number of runs. Each run re-reads the audience (new contacts, opt-outs) and has its own report. |
Delivery controls:
- Throttle: messages per minute, to spread a large send.
- Quiet hours: no messages inside a window (default 21:00-08:00) in each recipient's local time; messages wait and go out when it ends. On by default for promotional messages, optional for transactional ones.
4. Review, test and send
- Send test to me: the first rendered message goes to your own verified phone (API: up to 3 numbers), billed like a normal SMS.
- Blocking checks stop the launch: no recipients, empty merge fields without a fallback, sender ID not approved (without fallback), balance too low for a send-now campaign, a phrase the network content filter blocks, a schedule in the past.
- Warnings do not block: Unicode messages, numbers in unavailable countries, messages over 10 segments (skipped), no test sent yet.
While a campaign sends you can pause, resume and cancel it (messages already handed to networks still deliver). After it finishes you can retry failed recipients only or duplicate it.
Approvals (maker-checker)
Turn on approvals in Settings > Messaging and set a threshold: a minimum
number of recipients and/or a minimum cost (with no threshold, every campaign
needs approval). A campaign over the threshold goes to pending_approval
when launched. An approver, admin or the owner approves or rejects
it with a note; the person who submitted it cannot approve it themselves.
Editing a campaign that is waiting for approval sends it back to draft. See
Team roles.
Billing
- Campaigns are charged per batch at send time. Nothing is reserved when you schedule.
- The balance is checked when you launch. A send-now campaign that costs more than your balance is blocked; a scheduled or repeating one shows a warning.
- If the balance runs out mid-send, the campaign pauses with
pause_reason: "insufficient_balance"and you are notified in the dashboard and by email. Top up and press Resume - it continues where it stopped. - A message the gateway rejects is refunded at once. Skipped recipients (opted out, invalid, unavailable country) are never charged.
- No double sends: each recipient is claimed and charged in one step and never claimed twice, so retries, restarts and API retries with the same
Idempotency-Keycan't send or bill a recipient twice.
Reports
Each campaign has a live report: sent, delivered, failed and pending counts,
delivery rate, money spent, median and p90 time to deliver, failure reasons in
plain words, charts by day, country and network, and a per-recipient table you
can filter and export as CSV or Excel. When sending finishes, a signed
campaign.completed webhook
can notify your system; per-message delivery reports carry the campaign_id.
Example
curl -X POST https://sms.esmsafrica.io/v1/messages/batch \
-H "Authorization: Bearer $ESMS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: october-dues-1" \
-d '{
"template": "Hi {first_name|there}, your balance of UGX {balance} is due on {due_date}.",
"recipients": [
{ "to": "+256772123456", "name": "Ada Obi", "vars": { "balance": "1,500", "due_date": "1 Nov" } },
{ "to": "0701234567", "vars": { "balance": "200", "due_date": "1 Nov" } }
],
"default_country": "UG",
"schedule": { "mode": "scheduled", "at": "2026-11-01T09:00", "timezone": "Africa/Kampala" }
}'// npm install esms-sms (SDK 1.2.0 or later)
import { Esms } from "esms-sms";
const esms = new Esms(process.env.ESMS_API_KEY);
const batch = await esms.messages.sendBatch({
template: "Hi {first_name|there}, your balance of UGX {balance} is due on {due_date}.",
recipients: [
{ to: "+256772123456", name: "Ada Obi", vars: { balance: "1,500", due_date: "1 Nov" } },
{ to: "0701234567", vars: { balance: "200", due_date: "1 Nov" } },
],
defaultCountry: "UG",
schedule: { mode: "scheduled", at: "2026-11-01T09:00", timezone: "Africa/Kampala" },
});# pip install esms-sms (SDK 1.2.0 or later)
from esms import Esms
esms = Esms(api_key=os.environ["ESMS_API_KEY"])
batch = esms.messages.send_batch(
template="Hi {first_name|there}, your balance of UGX {balance} is due on {due_date}.",
recipients=[
{"to": "+256772123456", "name": "Ada Obi", "vars": {"balance": "1,500", "due_date": "1 Nov"}},
{"to": "0701234567", "vars": {"balance": "200", "due_date": "1 Nov"}},
],
default_country="UG",
schedule={"mode": "scheduled", "at": "2026-11-01T09:00", "timezone": "Africa/Kampala"},
)// composer require esmsafrica/sms (SDK 1.2.0 or later)
$esms = new \Esms\Client(getenv('ESMS_API_KEY'));
$batch = $esms->messages->sendBatch([
'template' => 'Hi {first_name|there}, your balance of UGX {balance} is due on {due_date}.',
'recipients' => [
['to' => '+256772123456', 'name' => 'Ada Obi', 'vars' => ['balance' => '1,500', 'due_date' => '1 Nov']],
['to' => '0701234567', 'vars' => ['balance' => '200', 'due_date' => '1 Nov']],
],
'default_country' => 'UG',
'schedule' => ['mode' => 'scheduled', 'at' => '2026-11-01T09:00', 'timezone' => 'Africa/Kampala'],
]);// go get github.com/eSMS-Africa/[email protected]
client := esms.New(os.Getenv("ESMS_API_KEY"))
batch, err := client.Messages.SendBatch(ctx, esms.SendBatchParams{
Template: "Hi {first_name|there}, your balance of UGX {balance} is due on {due_date}.",
Recipients: []esms.BatchRecipient{
{To: "+256772123456", Name: "Ada Obi", Vars: map[string]string{"balance": "1,500", "due_date": "1 Nov"}},
{To: "0701234567", Vars: map[string]string{"balance": "200", "due_date": "1 Nov"}},
},
DefaultCountry: "UG",
Schedule: &esms.Schedule{Mode: "scheduled", At: "2026-11-01T09:00", Timezone: "Africa/Kampala"},
})