eSMS AfricaeSMS Africa
SMS Platform

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.

SourceHow
FileCSV (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.
PasteNumbers one per line or separated by commas or spaces, or rows copied from a spreadsheet with a header row.
Google SheetsA link to a sheet shared as "Anyone with the link can view". A copy is read once.
ContactsYour contact groups and segments.
NumbersA 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 456 need 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):

ReasonMeaning
emptyNo phone number in the row
not_a_numberLetters or symbols
scientific_notationExcel scientific notation
too_short / too_longNot a phone number length
missing_country_codeLocal number with no default country
invalid_numberNot a valid number for its country
landlineA fixed line that cannot receive SMS
duplicateAlready in this import (the row it duplicates is named)
opted_outThe 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

SyntaxResult
{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

TypeBehaviour
TransactionalAlerts, reminders, codes, receipts. Sent at any hour.
PromotionalOffers 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

ModeBehaviour
Send nowStarts as soon as you launch.
ScheduleOne 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.
RepeatDaily, 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-Key can'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"},
})

On this page