Add SMS to Your App in Under 10 Minutes with Wesendall
A practical, copy-paste guide to sending SMS — single, bulk, and OTP verification codes — from your own backend using the Wesendall REST API. HTTP Basic auth, one reusable client, a full endpoint reference (send, groups, history, balance, Mobile Money top-ups), and a working OTP flow, all in under ten minutes.
Add SMS to Your App in Under 10 Minutes with Wesendall
Every app eventually needs to send a text message. A one-time code at login. An "your order shipped" alert. A payment reminder. In East Africa, SMS still lands where push notifications and email quietly don't — everybody reads their texts. This guide takes you from zero to a working send in under ten minutes using Wesendall, a simple REST API for sending SMS, running OTP flows, and topping up a wallet with Mobile Money — all from your own backend.
The whole thing, in one breath
Wesendall is a plain REST API. You authenticate with an API key + secret over HTTP Basic auth, every request carries your walletId, and you're billed per 160-character unit in UGX. That's the entire mental model. No SDK to install, no webhooks to configure before your first send, no OAuth dance.
Here's the ten-minute plan:
- Minute 0–3 — sign up, fund the wallet, create an API key.
- Minute 3–5 — drop three secrets into your environment.
- Minute 5–8 — paste in one reusable client function.
- Minute 8–10 — send your first message (and wire up OTP if you need it).
Let's go.
- Base URL:
https://www.wesendall.com/api/v1 - Auth: HTTP Basic (API key + API secret)
- Currency: UGX, billed per 160-character unit
- Sign up: wesendall.com/register
Step 1 — Get your three secrets (3 minutes)
- Create an account at wesendall.com/register and verify your phone number.
- Fund your wallet. Open Dashboard → Account Balance and top up via Mobile Money (MTN / Airtel). You can't send on an empty wallet.
- Create an API key at Dashboard → API (
/dashboard/integration). Click Create key and you'll get three values:- an API key — your public identifier (looks like
sk_live_OEL52...), used as the Basic-Auth username. - an API secret — shown once. Copy it now; it's never displayed again. This is the Basic-Auth password.
- your Wallet ID — a stable identifier shown on the same page that scopes every request to your wallet.
- an API key — your public identifier (looks like
That's it for the dashboard. There's also a live Sandbox at /sandbox where you can fire real requests and copy generated code, plus full docs at /docs if you want to poke around later.
Step 2 — Set your environment variables (2 minutes)
You need exactly three secrets plus the base URL. Drop them into your .env:
WESENDALL_API_KEY=sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
WESENDALL_API_SECRET=your_api_secret_shown_once
WESENDALL_WALLET_ID=your_wallet_id
WESENDALL_BASE_URL=https://www.wesendall.com/api/v1| Variable | What it is | Where to find it |
|---|---|---|
WESENDALL_API_KEY | Public key — the Basic-Auth username | Dashboard → API |
WESENDALL_API_SECRET | Secret — the Basic-Auth password (shown once) | Shown on key creation |
WESENDALL_WALLET_ID | Scopes every request to your wallet | Dashboard → API |
WESENDALL_BASE_URL | API root | Fixed value above |
One rule that matters: the secret grants full spend on your wallet. Keep it server-side only — never ship it to a browser, mobile app, or any
NEXT_PUBLIC_*variable. Every SMS send goes through your own backend. More on this at the end.
Step 3 — One reusable client (3 minutes)
You don't need a package. Drop this helper into your backend once and reuse it for every endpoint. It attaches the auth header, injects your walletId, and turns API errors into thrown exceptions so your calling code stays clean.
// wesendall.js (Node 18+ has global fetch)
const BASE = process.env.WESENDALL_BASE_URL; // https://www.wesendall.com/api/v1
const WALLET_ID = process.env.WESENDALL_WALLET_ID;
const authHeader =
"Basic " +
Buffer.from(
`${process.env.WESENDALL_API_KEY}:${process.env.WESENDALL_API_SECRET}`
).toString("base64");
export async function wesendall(path, { method = "GET", body, query } = {}) {
const url = new URL(BASE + path);
// walletId is always required — inject it for GETs as a query param.
const q = { ...(method === "GET" ? { walletId: WALLET_ID } : {}), ...query };
for (const [k, v] of Object.entries(q)) {
if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
}
const res = await fetch(url, {
method,
headers: {
Authorization: authHeader,
"Content-Type": "application/json",
Accept: "application/json",
},
// ...and inject it into the JSON body for POSTs.
body: body ? JSON.stringify({ walletId: WALLET_ID, ...body }) : undefined,
});
const json = await res.json();
if (!json.success) {
throw Object.assign(new Error(json.message || "Wesendall request failed"), {
status: res.status,
code: json.error,
});
}
return json.data;
}Two details worth internalizing, because they're the only two things people get wrong:
walletIdis required on every call. ForPOSTit lives in the JSON body; forGETit's a query parameter. The client above handles both so you never think about it again.- Every response is a JSON envelope with a
successboolean. On success the payload sits underdata; on failure you get asuccess: falseobject with amessageand a stable machine-readableerrorcode. The helper unwrapsdatafor you and throws on failure.
Step 4 — Send your first message (under 2 minutes)
This is the payoff. One call:
import { wesendall } from "./wesendall.js";
const sent = await wesendall("/sms/send", {
method: "POST",
body: {
message: "Your order #1234 has shipped.",
recipient: "+256700000000",
},
});
console.log(sent);
// { wallet_id, total_recipients, successful, failed, sms_units,
// total_cost, currency: "UGX", remaining_balance, results: [...] }If that returned an object, you're done — you're sending SMS from your app. Congratulations, that was the ten minutes.
recipient is flexible: pass a single phone string, an array of strings, or a comma/space-separated string — up to 10,000 recipients in one call. Phone numbers can be +256XXXXXXXXX, 256XXXXXXXXX, or 0XXXXXXXXX; the server normalizes them and drops anything malformed.
// Bulk: same call, an array of numbers. The wallet is charged once
// for the batch, and auto-refunded if the provider rejects it.
await wesendall("/sms/send", {
method: "POST",
body: {
message: "Karibu! 20% off all plans this weekend. Reply STOP to opt out.",
recipient: ["+256700000000", "256701234567", "0759000000"],
},
});Bonus: a real OTP flow (the most common reason you're here)
Most people integrating SMS want login verification codes. Here's the important design decision: Wesendall is the delivery layer only. You generate and verify the code yourself, which keeps the secret out of the SMS provider entirely. That's not extra work — it's about fifteen lines.
import crypto from "crypto";
import { wesendall } from "./wesendall.js";
// In production use Redis or your DB, not an in-memory Map.
const store = new Map(); // phone -> { code, expiresAt, attempts }
// 1) Generate a 6-digit code, store it with a short TTL, and text it.
export async function sendOtp(phone) {
const code = crypto.randomInt(100000, 1000000); // CSPRNG, 6 digits
store.set(phone, { code, expiresAt: Date.now() + 10 * 60_000, attempts: 0 });
await wesendall("/sms/send", {
method: "POST",
body: {
recipient: phone,
message: `Your verification code is ${code}. It expires in 10 minutes.`,
},
});
}
// 2) Verify: check the value, the expiry, and the attempt count.
export function verifyOtp(phone, submitted) {
const rec = store.get(phone);
if (!rec) return { ok: false, reason: "not_found" };
if (Date.now() > rec.expiresAt) return { ok: false, reason: "expired" };
if (rec.attempts >= 5) return { ok: false, reason: "too_many_attempts" };
rec.attempts++;
if (rec.code !== Number(submitted)) return { ok: false, reason: "mismatch" };
store.delete(phone); // single-use — burn it on success
return { ok: true };
}The OTP rules that keep you safe and cheap: 6 digits, 10-minute expiry, single-use (delete it the moment it succeeds), lock after ~5 attempts, and rate-limit sends per phone (say 1 per 30 seconds, 5 per hour). Without that last one, someone can hammer your endpoint and burn your wallet balance.
The complete endpoint reference
You've met /sms/send. Here's the rest of the surface — nine endpoints in total. Every path is relative to https://www.wesendall.com/api/v1, and every example uses the same wesendall() client from Step 3, so walletId and auth are already handled for you.
| Method | Path | Purpose |
|---|---|---|
POST | /sms/send | Send to one number, an array, or a list |
POST | /sms/group | Send to a saved contact group |
GET | /sms/history | Paginated send history |
GET | /account/balance | Wallet balance + per-SMS cost |
POST | /account/topup | Start a Mobile Money top-up |
POST | /account/topup/verify | Confirm a top-up + credit the wallet |
GET | /groups | List contact groups |
POST | /groups | Create a contact group |
POST | /groups/{groupId}/contacts | Bulk-add contacts to a group |
Check your balance — GET /account/balance
The one you'll poll from an admin dashboard to know when to top up.
const balance = await wesendall("/account/balance");
// { wallet_id, balance: 79401, currency: "UGX", cost_per_sms: 35 }Send to a saved group — POST /sms/group
For an audience you message repeatedly, create a group once and send to it by ID instead of passing thousands of numbers every time. The send is de-duplicated by phone automatically.
await wesendall("/sms/group", {
method: "POST",
body: { groupId: "grp_abc123", message: "Q2 promo: 20% off this Friday" },
});
// { wallet_id, group_id, total_recipients, successful, failed,
// sms_units, total_cost, currency: "UGX", remaining_balance }List & create groups — GET /groups and POST /groups
// List every group and its contact count
const { groups } = await wesendall("/groups");
// groups: [{ id, title, description, contact_count, created_at }]
// Create a new one
const group = await wesendall("/groups", {
method: "POST",
body: { title: "Q2 Promo", description: "April–June list" },
});
// { id, title, description, contact_count: 0, created_at }Fill a group with contacts — POST /groups/{groupId}/contacts
Bulk-add up to 10,000 contacts in one call. name is optional.
await wesendall(`/groups/${group.id}/contacts`, {
method: "POST",
body: {
contacts: [
{ phone: "+256700000000", name: "Sarah" },
{ phone: "+256701234567" },
],
},
});
// { wallet_id, group_id, added, skipped }Read your send history — GET /sms/history
Paginated: page (default 1) and per_page (1–100, default 50).
const history = await wesendall("/sms/history", {
query: { page: 1, per_page: 50 },
});
// { wallet_id, transactions: [{ id, recipient, message, cost, currency,
// sent_at, created_at }], pagination: { current_page, per_page, total,
// last_page, from, to } }Top up the wallet with Mobile Money — /account/topup → /account/topup/verify
This is a two-step flow: you initiate a charge, the payer approves the Mobile Money prompt on their phone, then you poll verify until it settles. Important: poll verify, not the balance endpoint — verify reports the exact state and is what actually credits the wallet (exactly once).
async function topUp(amount, payerPhone) {
// 1) Start the charge. amount is UGX (500 – 10,000,000).
const { reference } = await wesendall("/account/topup", {
method: "POST",
body: { amount, phone_number: payerPhone, description: "Wallet top-up" },
});
// The payer now approves the Mobile Money prompt on their phone.
// 2) Poll verify until it resolves.
const deadline = Date.now() + 2 * 60_000; // give them ~2 minutes
while (Date.now() < deadline) {
const r = await wesendall("/account/topup/verify", {
method: "POST",
body: { reference },
});
if (r.status === "completed") return { ok: true, balance: r.balance };
if (r.status === "failed") return { ok: false, reason: "payment_failed" };
await new Promise((res) => setTimeout(res, 5000)); // poll every 5s
}
return { ok: false, reason: "timeout" }; // still pending — check later
}The three statuses you'll see back from verify:
completed— paid; the wallet is credited (credited: truethe first time).pending— not yet approved on the payer's phone; poll again shortly.failed— the charge didn't go through.
That's the whole API. Sends, groups, history, balance, and self-service top-ups — all through the one client function you pasted in Step 3.
Handle the errors you'll actually see
Failures come back as a success: false envelope with a stable error code. Switch on the code, never on the human-readable message.
| HTTP | error | Meaning | What to do |
|---|---|---|---|
| 401 | invalid_credentials / key_revoked | Bad or revoked key | Don't retry; fix credentials |
| 403 | wallet_forbidden | Key doesn't own that walletId | A bug — fix the wallet ID |
| 400 | missing_wallet | walletId not supplied | Add walletId |
| 402 | insufficient_balance | Wallet too low | Top up; don't retry blindly |
| 422 | validation_failed | Bad phone, amount, or message | Fix the request |
| 502 | send_failed | Provider rejected (SMS already refunded) | Retry once with backoff |
| 500 | internal_error | Transient server error | Retry once with backoff |
Because the client throws with the error code attached, handling this is a clean switch:
try {
await wesendall("/sms/send", {
method: "POST",
body: { recipient, message },
});
} catch (err) {
switch (err.code) {
case "insufficient_balance":
// Alert yourself to top up — don't loop.
break;
case "send_failed":
case "internal_error":
// Safe to retry once — on a 502 the wallet was already refunded.
break;
default:
throw err; // auth / validation bugs — surface them immediately
}
}One reassuring detail: charges are atomic with refund-on-failure. Your wallet is debited up front, and if the provider rejects the send you're automatically refunded. You never pay for a message that didn't leave.
What it costs
- 35 UGX per 160-character SMS unit. A 320-character message is 2 units. Sending a 1-unit message to 100 people costs 3,500 UGX.
- Message length: up to 1,600 characters (10 units).
- Recipients per send: up to 10,000 in a single call.
- Coverage: Ugandan networks (MTN, Airtel, and others) via
+256numbers.
Read remaining_balance off any send response to know when it's time to top up — or automate the whole thing with the Mobile Money /account/topup flow covered above, so your wallet refills itself before it ever runs dry.
Five security habits (30 seconds to read, saves you real money)
- Server-side only. The secret is full spend authority. Never put it in a browser, mobile app, or
NEXT_PUBLIC_*var. Proxy every send through your backend. - Rotate on leak. If a secret is exposed, revoke the key in Dashboard → API and mint a new one.
- One key per environment/service so you can revoke a single integration without breaking everything else.
- Rate-limit your OTP and top-up endpoints — the API bills per send, so an unthrottled route is a spend risk.
- Never log the secret or the full
Authorizationheader.
Where to go next
You now have the full Wesendall surface working from your own backend — single sends, bulk campaigns, saved contact groups, send history, wallet balance, self-service Mobile Money top-ups, and a proper OTP flow — all through the one wesendall() client, in less time than it takes to read the pricing page of most providers.
If you're building with an AI agent, Wesendall makes it even faster — there's a prompt generator that produces a ready-to-paste prompt for your exact stack, and a machine-readable LLM reference you can feed to any model.
Go send something.


