SMS + WhatsApp API · Africa

Send SMS and WhatsApp across Africa from one API.

POST a message with your organization's key. O3Notify picks the carrier for the destination: a local operator where we have one (Orange in Guinea today), Twilio everywhere else, Meta's Cloud API for WhatsApp. Then it tells you the moment the message lands. One key, one bill.

Private while we onboard the first teams. No SDK to install. Any HTTP client, a bearer key, one JSON body.

POST /v1/messages202 Accepted
curl https://notify.exeapps.io/v1/messages \ -H "Authorization: Bearer o3n_…" \ -H "Content-Type: application/json" \ -d '{ "channel": "sms", "to": "+224622000000", "body": "Your order 8812 is ready for pickup." }'
HTTP/1.1 202 Acceptedcontent-type: application/json { "id": "msg_tz4kp8r2m1x7q6nc3a9wbf5j", "status": "queued" }

Message lifecycle

Four states. Every change is an event.

The 202 is an acceptance, not a delivery. Poll GET /v1/messages/:id, or configure a webhook and stop polling.

  1. queued
  2. sent
  3. deliveredfailed
queued
Accepted. The row insert is the enqueue; there is no second queue to drift out of sync with.
sent
A worker claimed it, picked the carrier, submitted it and stored the carrier's own id.
delivered
The carrier's delivery receipt arrived, by callback or by poll.
failed
The carrier refused it, or the receipt said so. error_code carries the carrier's reason.

Provider routing

Routing is a table, not a black box.

Every SMS gets its country from the recipient's E.164 number, then the worker reads routing_rules: rows the operator edits, never code it redeploys. Three things decide the carrier, in this order:

  1. 1

    Destination country

    Rules for the number's own country come first. The * catch-all is a rule too, so every country is routable even with no rule of its own.

  2. 2

    Availability

    A carrier whose circuit breaker is open is skipped for the next candidate. A blip at one carrier is a reroute, not a failed message.

  3. 3

    Price

    Within the same priority, the cheaper route wins.

WhatsApp never touches the table. channel: "whatsapp" goes straight to Meta's Cloud API, which reaches every country the same way at the same price.

Route a number
The same rules the worker reads, with an outage you can flip.
channel
to
orange circuit (flip to simulate an outage)
to
+224 622 00 00 00
country
GN · Guinea, from E.164
candidates
orange (GN, 10)twilio (*, 90)
provider
orange
countryproviderprioritycircuit
GNorange10closed
*twilio90closed

routing_rules as seeded. Priority orders candidates; price breaks ties.

Channels

SMS where it's local. WhatsApp without the Meta paperwork.

channel: "sms"

Up to 1,600 characters, concatenated. Where we have a local carrier, messages go out through it: Guinean numbers through Orange's SMS gateway today. Everything else goes through Twilio. An optional sender, an alphanumeric id of up to 11 characters, is used wherever the destination honours it and falls back to the platform default where it doesn't.

to
E.164, any country with a rule
body
1–1600 characters
sender
optional, ≤ 11 characters

channel: "whatsapp"

Business-initiated WhatsApp has to use a Meta-approved template. Yours is already filed: your body is delivered inside O3Notify's utility template with your organization's name on it, so you never submit a template or wait on a business review. Translations in English and French (language: "fr").

what the recipient sees

Message from Acme Bank:

Your order 8812 is ready for pickup.

Sent via O3Notify.

One-time codes take the dedicated authentication template instead: pass template: { name: "o3notify_otp", params: ["482913"] } and the recipient gets the code with a copy button.

Webhooks

Signed, retried, never silent.

Set one URL for your organization. Every status change is POSTed to it with four fields and a signature over the raw body. Verify it with your endpoint's secret and one HMAC:

expected = "sha256=" + hex(HMAC_SHA256(secret, raw_body))

Delivery is attempted up to five times, with retries after 1 minute, 5 minutes, 30 minutes and 2 hours. After the fifth failure the job is marked dead: no endless retries against an endpoint that is down, and the status is still on GET /v1/messages/:id.

→ your endpointstatus changed
POST https://api.example.com/o3notifyX-O3Notify-Signature: sha256=9b1f4c…Content-Type: application/json { "message_id": "msg_tz4kp8r2m1x7q6nc3a9wbf5j", "recipient": "+224622000000", "channel": "sms", "status": "delivered"}

Not open to the public yet.

We are onboarding a few teams at a time. Tell us what you send and where, and we will reach out when your workspace is ready. Your first request will be the one at the top of this page.

Join the waiting list