To send personalised bulk SMS from an Excel or CSV list, put one contact per row with a phone column and a column for each detail you want to merge (name, amount, date), convert every number to international E.164 format such as +254712345678, then import the file into your SMS dashboard, write the message with merge fields like {first_name}, and test, schedule and send. If the list comes from your own system, the same steps work through a bulk SMS API call.
Last reviewed: 28 October 2026. Rules and network practice change; check the linked sources for anything you rely on.
This walk-through uses the eSMS Africa dashboard at sms.esmsafrica.io, which reads CSV, .xlsx and .xls files and Google Sheets. The cleaning advice applies to any platform.
Step 1: prepare the spreadsheet
Start with one header row and one person per row:
| phone | first_name | amount_due | due_date | consent |
|---|---|---|---|---|
| 0712 345 678 | Wanjiru | KES 4,500 | 5 Nov | yes |
| +256 772 123456 | Brian | UGX 120,000 | 7 Nov | yes |
| 08031234567 | Chiamaka | NGN 15,000 | 8 Nov | yes |
- Format the phone column as Text before you paste or type numbers. Otherwise Excel drops leading zeros (0712... becomes 712...) and turns long numbers into scientific notation such as 2.56772E+11, which cannot be recovered. If that has happened, go back to the source data.
- Keep each detail in its own column. Column headers become merge field names: "Amount Due" becomes {amount_due}.
- Record consent. A column showing how and when each person agreed helps you filter promotional sends and answer complaints.
- Save as CSV UTF-8 if you export, so names with accents survive. The dashboard also reads .xlsx directly (the first sheet, up to 50 MB).
Step 2: clean numbers to E.164
E.164, the ITU international numbering format, is a plus sign, the country code and the national number without its trunk prefix, up to 15 digits in total. Most African numbers lose their leading 0 when you add the country code, but not all:
| Country | Code | Local mobile as written | E.164 | Note |
|---|---|---|---|---|
| Nigeria | +234 | 0803 123 4567 | +2348031234567 | Drop the 0; 10 digits remain |
| Kenya | +254 | 0712 345 678 | +254712345678 | Drop the 0; 07 and 01 numbers both exist |
| Uganda | +256 | 0772 123 456 | +256772123456 | Drop the 0 |
| Tanzania | +255 | 0754 123 456 | +255754123456 | Drop the 0 |
| Rwanda | +250 | 0788 123 456 | +250788123456 | Drop the 0 |
| Zambia | +260 | 0977 123 456 | +260977123456 | Drop the 0 |
| Ghana | +233 | 024 123 4567 | +233241234567 | Drop the 0 |
| South Africa | +27 | 082 123 4567 | +27821234567 | Drop the 0 |
| Egypt | +20 | 010 1234 5678 | +201012345678 | Drop the 0 |
| Cote d'Ivoire | +225 | 07 07 12 34 56 | +2250707123456 | Keep the 0: numbers have been 10 digits since 2021 |
| Senegal | +221 | 77 123 45 67 | +221771234567 | No trunk 0 to drop |
| Cameroon | +237 | 6 71 23 45 67 | +237671234567 | No trunk 0 to drop |
If your list is mostly one country, you have two options. The simplest is to leave numbers as they are and set a default country when you import: the dashboard then reads 0772 123 456 as a Ugandan number, while numbers that already have a country code (+256, 256 or 00256) work in any form. If you need clean numbers in the sheet itself, an Excel 365 formula like this one (for Uganda, with the number in A2) does it:
=LET(
n, SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(SUBSTITUTE(A2," ",""),"-",""),"(",""),")",""),
IF(LEFT(n,1)="+", n,
IF(LEFT(n,5)="00256", "+"&MID(n,3,20),
IF(LEFT(n,3)="256", "+"&n,
IF(LEFT(n,1)="0", "+256"&MID(n,2,20),
"+256"&n))))
)Change 256 to your country code. Do not use this for Cote d'Ivoire, where the leading 0 is part of the number. For mixed-country lists, add a country column and clean each country separately, or use the Python script below.
Step 3: import and check the results
- In the dashboard, start a campaign and upload the file (or paste numbers, or link a Google Sheet shared as "anyone with the link can view").
- Confirm the column mapping: the phone column is detected from headers such as phone, mobile or msisdn; pick the name column; every other column becomes a merge field.
- Set the default country for local numbers.
- Review the counts. Rows are flagged with a reason, such as scientific_notation, too_short, missing_country_code, landline, duplicate or opted_out, and you can download them as an error file to fix at source.
Duplicates and opted-out numbers are removed automatically. Numbers in countries where we are not live yet are kept but skipped and never charged.
Step 4: write the message with merge fields
Use your column names in braces. Add a fallback after a pipe for fields that may be empty:
Hi {first_name|Customer}, your balance of {amount_due} is due on {due_date}. Pay on M-Pesa paybill 123456. Thank you, Baraka Traders
A field that is empty for some recipients and has no fallback blocks the launch, so nobody receives a blank or a literal {amount_due}. Watch the length of the longest merged message, which the composer shows: a long name or amount can push it past 160 characters into a second segment, and one accented or curly character switches it to Unicode with a 70-character limit (see Unicode SMS for African languages). More on fields in personalising bulk messages.
Step 5: pick transactional or promotional, and the sender ID
Reminders, receipts and alerts are transactional. Offers and invitations are promotional: they need consent, an opt-out, and they respect quiet hours. Your sender ID must be approved in each destination country; where it is not yet, the campaign can fall back to that country's default sender. Register yours on the sender ID pages.
Step 6: schedule, throttle and respect quiet hours
- Schedule a date and time in the timezone you choose, up to 12 months ahead, or set a repeat (daily, weekly, monthly). Each repeat run re-reads the audience, so new contacts and new opt-outs are picked up.
- Throttle large sends to a number of messages per minute, useful if replies or calls to your office will follow.
- Quiet hours hold promotional messages between 21:00 and 08:00 in each recipient's local time by default. Some markets have their own rules, such as Rwanda's restriction on commercial messages between 7pm and 7am (explained here).
Step 7: approvals for team accounts
If several people send from one account, turn on approvals and set a threshold by number of recipients or cost. Campaigns above it wait for an approver, admin or owner, and the person who submitted a campaign cannot approve it themselves. This stops a wrong list or an unfinished message from reaching 50,000 people.
Step 8: opt-outs
Anyone on your opt-out list is blocked from future sends. If you send promotions from a two-way number or short code, STOP replies are recorded as opt-outs automatically. On a one-way alphanumeric sender ID recipients cannot reply, so give another route, such as a short link to an unsubscribe page or a number to call, and add those requests to the opt-out list. Do not write "reply STOP" on a sender that cannot receive replies.
Step 9: test, send and read the report
Send a test to your own phone first; it shows exactly what the first recipient will get. The review screen lists recipients, segments and cost per country, and blocks the send if something is wrong (no balance, unapproved sender without fallback, empty merge fields). After sending, the report shows delivered, failed and pending counts by country and network, failure reasons in plain words, and a per-recipient table you can export to CSV or Excel. You can retry failed recipients only.
Cost is recipients x segments x the rate per country: for example one segment is KES 1.08 to Kenya, UGX 35 to Uganda and NGN 9.1 to Nigeria at the entry tier. See the pricing page for every country.
The API alternative
If the list lives in your own system, skip the upload and send from code. This Python script reads the CSV, filters by consent, normalises numbers with the open-source phonenumbers library, and sends in chunks through the bulk endpoint, with {{name}} and {{amount}} filled per recipient:
# pip install phonenumbers requests
import csv, os, uuid, phonenumbers, requests
API = "https://sms.esmsafrica.io/api/messages/send-bulk"
KEY = os.environ["ESMS_API_KEY"] # esms_test_... simulates without charge
DEFAULT_REGION = "KE" # used only for numbers without a country code
def to_e164(raw, region=DEFAULT_REGION):
try:
num = phonenumbers.parse(str(raw).strip(), region)
except phonenumbers.NumberParseException:
return None
if not phonenumbers.is_valid_number(num):
return None
return phonenumbers.format_number(num, phonenumbers.PhoneNumberFormat.E164)
recipients, rejected = [], []
with open("customers.csv", newline="", encoding="utf-8-sig") as f: # utf-8-sig drops the Excel BOM
for row in csv.DictReader(f):
if row.get("consent", "").strip().lower() != "yes":
continue
phone = to_e164(row["phone"], row.get("country") or DEFAULT_REGION)
if not phone:
rejected.append(row["phone"])
continue
recipients.append({"to": phone, "name": row["first_name"],
"vars": {"amount": row["amount_due"], "due": row["due_date"]}})
text = "Hi {{name|Customer}}, your balance of {{amount}} is due on {{due}}. Pay on M-Pesa paybill 123456. Thank you, Baraka Traders"
for i in range(0, len(recipients), 1000): # send in chunks
chunk = recipients[i:i + 1000]
r = requests.post(API, timeout=60,
headers={"Authorization": f"Bearer {KEY}",
"Idempotency-Key": f"nov-reminders-{i}"}, # safe to retry this chunk
json={"recipients": chunk, "text": text, "sender_id": "BARAKA",
"skip_unavailable": True})
print(r.status_code, r.json())
print(f"{len(recipients)} queued, {len(rejected)} invalid numbers:", rejected[:10])Notes: each chunk is priced per recipient at its own country's rate; duplicates and opted-out numbers are dropped; skip_unavailable drops recipients in countries that are on request instead of rejecting the whole call; and the Idempotency-Key makes a retried chunk safe. Bulk calls are limited to 10 per minute per key, and scheduled bulk sends take saved contact lists rather than inline recipients. Test keys simulate everything without charge. Full reference: HTTP API docs; language guides for Python, Node.js and PHP.
Frequently asked questions
Why did Excel change my phone numbers?
Excel treats them as numbers, so it removes leading zeros and shows long ones in scientific notation. Format the column as Text before entering data. A number already shown as 2.56772E+11 has lost digits and must be re-exported from the source.
Do I need the + sign in my spreadsheet?
No. The dashboard accepts +256772123456, 256772123456 and 00256772123456. Local numbers such as 0772123456 work if you set a default country on import.
Can one file contain numbers from several countries?
Yes, as long as each number has its country code or you clean them per country. Each number is routed and priced by its own country.
How many rows can I upload?
Files up to 50 MB and roughly 500,000 rows. For larger lists, split the file or send through the API.
Can I send a different message to each person?
Yes. Every column becomes a merge field, so names, amounts, dates and reference numbers can differ per row within one template.
Sources
- ITU-T - Recommendation E.164, the international numbering plan
- ITU - National numbering plans
- Burkina24 - Cote d'Ivoire numbers move from 8 to 10 digits (31 January 2021)
- Google libphonenumber - phone number parsing and validation (Python port: phonenumbers)
- RURA - Regulation governing unsolicited commercial communications in Rwanda (PDF)
- NCC - Do-Not-Disturb (DND) service in Nigeria
- 3GPP - TS 23.038, GSM-7 and UCS-2 character sets
Have a list ready? Create an account, top up from USD 20 and import your file, or see ready-made examples for retailers in Kenya and schools in Nigeria. For the bigger picture, read our complete guide to bulk SMS in Africa.


