Skip to Content
GuidesRecipes

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.

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.");
Last updated on