Recipes
Complete, working examples for the things people build most. Each one assumes
your key is in WAKALI_API_KEY.
Booking confirmation
Sent the moment a booking is made. Short, specific, and it doesn’t block the booking itself.
Node.js
async function sendSms(recipients, message) {
const res = await fetch("https://api.wakalisms.com/sms/send", {
method: "POST",
headers: {
"X-API-Key": process.env.WAKALI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ recipients, message }),
});
const body = await res.json();
if (!res.ok) throw new Error(body.detail ?? "SMS failed");
return body;
}
export async function confirmBooking(booking) {
const when = booking.startsAt.toLocaleString("en-KE", {
weekday: "short", day: "numeric", month: "short", hour: "2-digit", minute: "2-digit",
});
// Never let a messaging problem fail the booking itself.
try {
await sendSms(
[booking.customerPhone],
`Hi ${booking.customerName}, your booking at ${booking.venue} is confirmed for ${when}. Ref ${booking.reference}.`,
);
} catch (err) {
console.error("Booking SMS failed", booking.reference, err);
}
}Send the SMS after the booking is committed, and don’t make the booking depend on it. If messaging is down, people should still be able to book.
Payment notification
Same shape, with one addition worth copying: paying twice is much worse than messaging twice, so key the send on the transaction.
const alreadyNotified = new Set(); // use your database, not memory
export async function notifyPayment(payment) {
if (alreadyNotified.has(payment.id)) return;
alreadyNotified.add(payment.id);
await sendSms(
[payment.phone],
`Payment received: KES ${payment.amount.toLocaleString()} for ${payment.reference}. ` +
`New balance KES ${payment.balance.toLocaleString()}. Thank you.`,
);
}If /sms/send times out you can’t tell whether it landed. Check before
resending:
const res = await fetch(
`https://api.wakalisms.com/sms/messages?phone=${encodeURIComponent(phone)}&page_size=1`,
{ headers: { "X-API-Key": process.env.WAKALI_API_KEY } },
);
const { data } = await res.json();
const sentRecently = data[0] && Date.now() - new Date(data[0].created_at) < 60_000;One-time passcode
Short expiry, no branding clutter, and never log the code.
import { randomInt } from "node:crypto";
export async function sendOtp(phone) {
const code = String(randomInt(0, 1_000_000)).padStart(6, "0");
await sendSms([phone], `${code} is your verification code. It expires in 10 minutes.`);
// Store a hash with an expiry — never the code itself.
await db.otp.create({
phone,
hash: sha256(code + phone),
expiresAt: new Date(Date.now() + 10 * 60_000),
attempts: 0,
});
}Worth doing: cap attempts (five is plenty), expire in ten minutes, rate-limit per number and per account, and keep the message under 160 characters so it’s one part.
Bulk campaign to a contact group
For anything beyond a handful of recipients, use a campaign rather than
looping over /sms/send. It’s one request, it bills once, and merge tags are
resolved per person.
curl -X POST https://api.wakalisms.com/campaigns -H "X-API-Key: YOUR_KEY" -H "Content-Type: application/json" -d '{"name":"October reminder","message":"Hi {first_name}, your balance of KES {amount} is due on Friday.","group_ids":["GROUP_ID"]}'const res = await fetch("https://api.wakalisms.com/campaigns", {
method: "POST",
headers: {
"X-API-Key": process.env.WAKALI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
name: "October reminder",
message: "Hi {first_name}, your balance of KES {amount} is due on Friday.",
group_ids: [groupId],
scheduled_at: "2026-11-01T09:00:00Z", // omit to send now
}),
});Resolve merge tags or lose money. {first_name} contains braces, which
aren’t in the GSM-7 alphabet, so an unresolved tag makes the whole message
Unicode and halves how much fits in one part. We resolve tags before
billing, so campaigns are charged on the real text — but if you build the
message yourself and leave a stray {, you’ll pay for it.
Contacts missing a value for a tag are recorded as skipped rather than sent
a broken message. Fix the contact and retry just those from the campaign.
Checking coverage first
Before a large send, see whether your Sender ID can reach everyone:
const res = await fetch("https://api.wakalisms.com/sms/coverage", {
method: "POST",
headers: {
"X-API-Key": process.env.WAKALI_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ recipients: numbers }),
});
const { unreachable_count, warning } = await res.json();
if (unreachable_count) console.warn(warning);It costs nothing and sends nothing. See Sender IDs.
A small client worth writing once
// wakali.js
const BASE = "https://api.wakalisms.com";
export class Wakali {
constructor(apiKey = process.env.WAKALI_API_KEY) {
if (!apiKey) throw new Error("WAKALI_API_KEY is not set");
this.apiKey = apiKey;
}
async request(path, { method = "GET", body } = {}) {
const res = await fetch(BASE + path, {
method,
headers: {
"X-API-Key": this.apiKey,
...(body && { "Content-Type": "application/json" }),
},
...(body && { body: JSON.stringify(body) }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = Array.isArray(data.detail)
? data.detail.map(e => e.msg).join("; ")
: data.detail;
const err = new Error(detail || `Request failed (${res.status})`);
err.status = res.status;
throw err;
}
return data;
}
send(recipients, message, senderId) {
return this.request("/sms/send", {
method: "POST",
body: { recipients: [].concat(recipients), message, sender_id: senderId },
});
}
messages(params = {}) {
return this.request("/sms/messages?" + new URLSearchParams(params));
}
wallet() {
return this.request("/wallet");
}
}const wakali = new Wakali();
await wakali.send("+254712345678", "Your booking is confirmed.");