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:

phonefirst_nameamount_duedue_dateconsent
0712 345 678WanjiruKES 4,5005 Novyes
+256 772 123456BrianUGX 120,0007 Novyes
08031234567ChiamakaNGN 15,0008 Novyes
  • 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:

CountryCodeLocal mobile as writtenE.164Note
Nigeria+2340803 123 4567+2348031234567Drop the 0; 10 digits remain
Kenya+2540712 345 678+254712345678Drop the 0; 07 and 01 numbers both exist
Uganda+2560772 123 456+256772123456Drop the 0
Tanzania+2550754 123 456+255754123456Drop the 0
Rwanda+2500788 123 456+250788123456Drop the 0
Zambia+2600977 123 456+260977123456Drop the 0
Ghana+233024 123 4567+233241234567Drop the 0
South Africa+27082 123 4567+27821234567Drop the 0
Egypt+20010 1234 5678+201012345678Drop the 0
Cote d'Ivoire+22507 07 12 34 56+2250707123456Keep the 0: numbers have been 10 digits since 2021
Senegal+22177 123 45 67+221771234567No trunk 0 to drop
Cameroon+2376 71 23 45 67+237671234567No 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

  1. 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").
  2. 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.
  3. Set the default country for local numbers.
  4. 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

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.