Africa's Talking Integration Guide

Technology: africas-talking · Category: comms · Last reviewed: 2026-08-23

Source: https://tech-stack.codeamanilabs.org/guide/africas-talking

Insight:

Africa's Talking is the reach-everyone channel — USSD works on feature phones with no data or smartphone, which SMS and WhatsApp can't claim. The trap: AT wants +254… (with +) while Daraja wants 254… (no +) — normalize per-API. Pairs with M-Pesa for full USSD → pay → SMS flows.

 █████╗ ███████╗██████╗ ██╗ ██████╗ █████╗ ███████╗
██╔══██╗██╔════╝██╔══██╗██║██╔════╝██╔══██╗██╔════╝
███████║█████╗  ██████╔╝██║██║     ███████║███████╗
██╔══██║██╔══╝  ██╔══██╗██║██║     ██╔══██║╚════██║
██║  ██║██║     ██║  ██║██║╚██████╗██║  ██║███████║
╚═╝  ╚═╝╚═╝     ╚═╝  ╚═╝╚═╝ ╚═════╝╚═╝  ╚═╝╚══════╝

████████╗ █████╗ ██╗     ██╗  ██╗██╗███╗   ██╗ ██████╗
╚══██╔══╝██╔══██╗██║     ██║ ██╔╝██║████╗  ██║██╔════╝
   ██║   ███████║██║     █████╔╝ ██║██╔██╗ ██║██║  ███╗
   ██║   ██╔══██║██║     ██╔═██╗ ██║██║╚██╗██║██║   ██║
   ██║   ██║  ██║███████╗██║  ██╗██║██║ ╚████║╚██████╔╝
   ╚═╝   ╚═╝  ╚═╝╚══════╝╚═╝  ╚═╝╚═╝╚═╝  ╚═══╝ ╚═════╝

Africa's Talking Integration Guide

Focus: Pan-African communications — SMS, USSD, Voice, Airtime, Mobile Data, and WhatsApp — reaching 300M+ subscribers across Africa. USSD is the standout: it works on feature phones with no smartphone or data plan, making it codeAmani's reach-everyone channel alongside M-Pesa.

Overview

Africa's Talking (AT) is a REST API with official africastalking SDKs for Node.js and Python. Authenticate with your app username + apiKey (header apiKey). A free sandbox (username literally sandbox) mirrors production for testing. Services exposed by the SDK: SMS, VOICE, AIRTIME, MOBILE_DATA, USSD, TOKEN, INSIGHTS, WHATSAPP, APPLICATION.

Here is the big picture at a glance — one authenticated REST API fanning out to every channel that reaches your users:

flowchart LR
  APP["Your app<br/>username + apiKey"] -->|"REST"| AT["Africa's Talking API"]
  AT --> SMS["SMS<br/>OTP · alerts · bulk"]
  AT --> USSD["USSD<br/>feature phones · no data"]
  AT --> VOICE["Voice · Airtime<br/>Mobile Data"]
  SMS --> USER["Subscriber"]
  USSD --> USER
  VOICE --> USER
  USER -->|"delivery callback"| APP

Official Documentation

Resource URL
Developer portal https://developers.africastalking.com/
SMS overview https://developers.africastalking.com/docs/sms/overview
USSD overview https://developers.africastalking.com/docs/ussd/overview
Voice overview https://developers.africastalking.com/docs/voice/overview
Voice actions https://developers.africastalking.com/docs/voice/actions/overview
Airtime sending https://developers.africastalking.com/docs/airtime/sending
Mobile Data sending https://developers.africastalking.com/docs/data/sending
Auth headers https://developers.africastalking.com/docs/request_headers
Node SDK https://github.com/africastalkingltd/africastalking-node.js
Python SDK https://github.com/AfricasTalkingLtd/africastalking-python

1. Credentials

Get username + apiKey from the dashboard. For local dev use the sandbox (username sandbox, sandbox API key).

AT_USERNAME=sandbox          # your live app username in production
AT_API_KEY=...               # server-side only — never ship to the browser
Environment Base URL
Live https://api.africastalking.com/version1
Sandbox https://api.sandbox.africastalking.com/version1

2. Install + initialize (Node)

npm install --save africastalking      # JS/TS
pip install africastalking             # Python
const client = require("africastalking")({
  apiKey: process.env.AT_API_KEY,
  username: process.env.AT_USERNAME, // 'sandbox' for testing
});
const { SMS, USSD } = client;

3. Send SMS

await SMS.send({
  to: ["+254711XXXYYY", "+254733YYYZZZ"], // note: +254… (international, WITH +)
  message: "Your codeAmani OTP is 123456",
  // senderId: "MYSENDERID",  // registered short code / alphanumeric sender ID
});

Raw REST equivalent — the current bulk endpoint takes a JSON body with a phoneNumbers array and a required senderId:

curl -X POST https://api.africastalking.com/version1/messaging/bulk \
  -H "apiKey: $AT_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"username":"my_app","phoneNumbers":["+254711XXXYYY"],"message":"Hello","senderId":"ABC"}'

Two SMS endpoints. POST /version1/messaging/bulk is the current JSON endpoint (phoneNumbers array, senderId required). The legacy POST /version1/messaging still works and is what the Node/Python SDKs call under the hood: application/x-www-form-urlencoded, to as a comma-separated string, optional from that defaults to AFRICASTKNG. In the sandbox, the default sender ID only delivers test SMS to Kenyan Airtel numbers — for any other network/region you must register (and get approval for) a sender ID first.

The response SMSMessageData.Recipients[].statusCode reports per-number status (101 Sent, 403 InvalidPhoneNumber, 405 InsufficientBalance, plus 100 Processed, 102 Queued, 401 RiskHold, 402 InvalidSenderId, 406 UserInBlacklist, 409 DoNotDisturbRejection, etc.) — status/statusCode mean accepted for sending, not delivered; use a delivery callback for delivery state.

4. Handle a USSD session

AT POSTs sessionId, phoneNumber, networkCode, serviceCode, text to your callback URL. Reply with plain text — CON keeps the session open, END closes it. The Node SDK wraps this as Express middleware:

Picture the back-and-forth — each dial POSTs to you, and your CON/END reply decides whether the menu keeps going or wraps up:

sequenceDiagram
  participant U as "User · feature phone"
  participant AT as "Africa's Talking"
  participant S as "Your callback URL"
  U->>AT: "Dial service code"
  AT->>S: "POST sessionId · phoneNumber · text"
  S-->>AT: "CON Welcome menu"
  AT-->>U: "Show menu - session open"
  U->>AT: "Reply 1"
  AT->>S: "POST text - 1"
  S-->>AT: "END Balance- KES 250.00"
  AT-->>U: "Show result - session closed"
const express = require("express");
const app = express();

app.post("/ussd", USSD((params, sendResponse) => {
  const { text, phoneNumber } = params; // text = "" first hit, then "1", "2*50", …
  if (text === "") {
    sendResponse({ response: "Welcome\n1. Balance\n2. Buy airtime", endSession: false });
  } else if (text === "1") {
    sendResponse({ response: "Balance: KES 250.00", endSession: true });
  } else {
    sendResponse({ response: "Done!", endSession: true });
  }
}));

Register the callback URL (HTTPS) for your service code in the dashboard. For local testing, tunnel with ngrok http 3000 (same as the M-Pesa callback workflow).

5. Voice, Airtime & Mobile Data

Beyond SMS and USSD, the same username + apiKey unlocks three more reach-everyone channels. Note the separate base hosts: Voice lives on voice.africastalking.com, Mobile Data on bundles.africastalking.com, while Airtime stays on the version1 REST host.

flowchart LR
  APP["Your app<br/>username + apiKey"] --> V["Voice<br/>voice.africastalking.com"]
  APP --> A["Airtime<br/>version1/airtime/send"]
  APP --> D["Mobile Data<br/>bundles.../data/request"]
  V -->|"outbound call"| U["Subscriber"]
  U -->|"inbound · POST"| CB["Your voice<br/>callback URL"]
  CB -->|"XML actions"| V
  A --> U
  D --> U

Airtime — send a top-up

Initialize the service and send airtime to one or more recipients (up to 1,000 per request). Each recipient needs a phoneNumber, currencyCode, and amount. Optional maxNumRetry is a count of hours to keep retrying failed sends (retries every 60s; default retry window 8h), and idempotencyKey guards against duplicate top-ups. Note AT auto-rejects near-identical airtime requests sent within a 5-minute window — set an Idempotency-Key (SDK: idempotencyKey) when you genuinely need to push a repeat top-up through that window.

const { AIRTIME } = client;

await AIRTIME.send({
  recipients: [
    { phoneNumber: "+254711XXXYYY", currencyCode: "KES", amount: 50 },
    { phoneNumber: "+254733YYYZZZ", currencyCode: "KES", amount: 100 },
  ],
  maxNumRetry: 3,                       // optional: retry failed deliveries for 3 hours
  idempotencyKey: "airtime-txn-001",    // optional: dedupe duplicate sends
});

Raw REST equivalent (note the Idempotency-Key header and amount as a "KES 100.00" string):

curl -X POST https://api.africastalking.com/version1/airtime/send \
  -H "apiKey: $AT_API_KEY" -H "Content-Type: application/json" -H "Accept: application/json" \
  -H "Idempotency-Key: airtime-txn-001" \
  -d '{"username":"my_app","maxNumRetry":2,"recipients":[{"phoneNumber":"+254711XXXYYY","amount":"KES 110.00"}]}'

The response responses[].status is Sent/Success (accepted), not delivered — like SMS, the final state arrives via the airtime status callback. numSent, totalAmount, and totalDiscount summarize the batch.

Voice — outbound call, then actions via callback

Outbound voice is queue-then-callback, not inline. POST to voice.africastalking.com/call to queue the call — the body is application/x-www-form-urlencoded with username, from (your AT number), to (a comma-separated string of recipients), and optional clientRequestId. The /call endpoint does not accept a callActions list; the call flow is decided only when AT connects the call and POSTs a notification to your registered voice callback URL, where you reply with XML voice actions (see below):

curl -X POST https://voice.africastalking.com/call \
  -H "apiKey: $AT_API_KEY" -H "Accept: application/json" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode "username=my_app" \
  --data-urlencode "from=+254730XXXXXX" \
  --data-urlencode "to=+254711XXXYYY"
# response entries[].status: Queued | InvalidPhoneNumber | DestinationNotSupported | InsufficientCredit (+ sessionId)

Inbound / IVR (and the outbound call flow): when AT POSTs to your registered voice callback URL, reply with XML voice actions. The Node SDK's VOICE.ActionBuilder builds that XML with a fluent chain — here a menu that collects a keypad digit:

const { ActionBuilder } = client.VOICE;

app.post("/voice", express.urlencoded({ extended: false }), (req, res) => {
  const xml = new ActionBuilder()
    .say("Welcome to codeAmani. Press 1 for balance, 2 for support.")
    .getDigits(
      { say: { text: "Enter your choice followed by hash." } },
      { numDigits: 1, finishOnKey: "#", timeout: 10, callbackUrl: "https://myapp.com/voice/handle" }
    )
    .build();
  res.set("Content-Type", "application/xml");
  res.send(xml); // <Response><Say>…</Say><GetDigits …/></Response>
});

Available actions: Say, Play, GetDigits, Dial, Record, Enqueue, Dequeue, Redirect, Reject, Conference. The outbound /call response returns an entries[] list with per-number status (Queued, InvalidPhoneNumber, DestinationNotSupported, InsufficientCredit) and a sessionId.

Mobile Data — send a bundle

Send data bundles via MOBILE_DATA.send (REST: POST bundles.africastalking.com/mobile/data/request). Each recipient needs quantity, unit (MB or GB), and validity (Day, Week, BiWeek, Month, or Quarterly). productName must match the data product configured on your AT account.

const { MOBILE_DATA } = client;

await MOBILE_DATA.send({
  productName: "Mobile Data",            // must match your AT product name exactly
  recipients: [
    { phoneNumber: "+254711XXXYYY", quantity: 500, unit: "MB", validity: "Month",
      metadata: { customerId: "CUS001", reason: "loyalty-reward" } },
    { phoneNumber: "+254733YYYZZZ", quantity: 1, unit: "GB", validity: "Week",
      metadata: { customerId: "CUS002" } },
  ],
  idempotencyKey: "data-txn-001",        // optional: dedupe duplicate sends
});

The response entries[] gives per-number provider, status (Queued), transactionId, and value (the KES cost). Final delivery state arrives via the mobile data status callback.

Gotcha — three different hosts, two amount formats. Voice and Mobile Data do not use the version1 REST host: Voice is voice.africastalking.com/call and Mobile Data is bundles.africastalking.com/mobile/data/request (sandbox: voice.sandbox… / bundles.sandbox…). Airtime also flips amount format depending on layer: the SDK takes a numeric amount plus separate currencyCode (amount: 50, currencyCode: "KES"), but the raw REST body wants a single "KES 100.00" string. Mixing these up is the most common 4xx. As with SMS, every Queued/Sent status means accepted, not delivered — wire up the per-service status callbacks for the real outcome.

codeAmani notes

Official docs: