Integrations
Send SMS with Node.js
Node 18 and later ship fetch, so no HTTP dependency is needed. POST to /v1/sms/send with an Authorization header, a +234 recipient and an sms_type, and branch on error.code rather than the HTTP status alone.
Setup
There is no Sendozi SDK to install. Node 18 and later include fetch, so the only prerequisite is an API key in the environment.
SENDOZI_API_KEY=sk_test_your_sandbox_key
SENDOZI_SENDER_ID=SendoziA small client
const BASE_URL = "https://api.sendozi.com";
const NIGERIAN_MOBILE = /^\+234[789][01]\d{8}$/;
export class SendoziError extends Error {
constructor({ code, message, resolution }, requestId, status) {
super(message);
this.name = "SendoziError";
this.code = code;
this.resolution = resolution;
this.requestId = requestId;
this.status = status;
}
}
/** Converts 08012345678 and 2348012345678 to +2348012345678, or returns null. */
export function normalisePhone(input) {
let value = String(input).trim().replace(/[\s()\-.]/g, "");
if (value.startsWith("00")) value = `+${value.slice(2)}`;
if (/^0\d{10}$/.test(value)) value = `+234${value.slice(1)}`;
else if (/^234\d{10}$/.test(value)) value = `+${value}`;
else if (/^[789][01]\d{8}$/.test(value)) value = `+234${value}`;
return NIGERIAN_MOBILE.test(value) ? value : null;
}
async function request(path, { body, idempotencyKey, signal } = {}) {
const response = await fetch(`${BASE_URL}${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${process.env.SENDOZI_API_KEY}`,
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
signal,
});
const envelope = await response.json();
if (!envelope.success) {
throw new SendoziError(envelope.error, envelope.request_id, response.status);
}
return envelope.data;
}
export const sendozi = {
/** One recipient. idempotencyKey should come from your own event id. */
sendSms({ to, message, type = "transactional", sender = process.env.SENDOZI_SENDER_ID, idempotencyKey }) {
const recipient = normalisePhone(to);
if (!recipient) throw new TypeError(`Not a Nigerian mobile number: ${to}`);
return request("/v1/sms/send", {
body: { sender, recipient, message, sms_type: type },
idempotencyKey,
});
},
/** Many recipients in one request - one unit of rate limit, not many. */
sendBulkSms({ to, message, type = "promotional", sender = process.env.SENDOZI_SENDER_ID, idempotencyKey }) {
const recipients = to.map(normalisePhone);
const invalid = to.filter((_, index) => recipients[index] === null);
if (invalid.length) throw new TypeError(`Invalid numbers: ${invalid.join(", ")}`);
return request("/v1/sms/bulk", {
body: { sender, recipients, message, sms_type: type },
idempotencyKey,
});
},
getMessage(id) {
return request(`/v1/messages/${encodeURIComponent(id)}`);
},
};Using it
import { sendozi, SendoziError } from "./sendozi.js";
try {
// The key is derived from the verification attempt, so a retry replays.
const message = await sendozi.sendSms({
to: "08012345678", // normalised for you
message: "Your Acme code is 492811. It expires in 10 minutes.",
type: "transactional",
idempotencyKey: `otp-${attempt.id}`,
});
console.log(message.message_id, message.status, message.customer_price_kobo);
} catch (error) {
if (error instanceof SendoziError) {
// Branch on the code, never on the message text.
console.error(error.code, error.resolution, "request:", error.requestId);
} else {
throw error;
}
}export type SmsType = "transactional" | "promotional";
export type MessageStatus = "accepted" | "submitted" | "delivered" | "failed";
export interface SendoziMessage {
message_id: string;
channel: "sms";
mode: "sandbox" | "production";
sender: string;
recipient: string;
status: MessageStatus;
status_timeline: { status: MessageStatus; at: string }[];
customer_price_kobo: number;
request_id: string;
created_at: string;
sent_at: string | null;
delivered_at: string | null;
}
export interface SendoziEnvelope<T> {
success: boolean;
data?: T;
error?: { code: string; message: string; resolution: string };
request_id: string;
}Retrying safely
const PERMANENT = new Set([
"invalid_request", "invalid_recipient", "message_policy_violation",
"unauthorized", "forbidden", "kyc_required", "sender_id_not_approved",
"channel_not_active", "recipient_suppressed", "api_key_blocked",
"wallet_frozen", "insufficient_balance", "idempotency_conflict",
]);
export async function sendWithRetry(input, attempts = 4) {
let delay = 500;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
return await sendozi.sendSms(input);
} catch (error) {
const permanent = error instanceof SendoziError && PERMANENT.has(error.code);
if (permanent || attempt === attempts) throw error;
await new Promise((resolve) => setTimeout(resolve, delay + Math.random() * 250));
delay *= 2;
}
}
}Receiving delivery events
import express from "express";
import crypto from "node:crypto";
const app = express();
app.post("/hooks/sendozi", express.raw({ type: "application/json" }), (req, res) => {
const provided = Buffer.from(req.get("x-sendozi-signature") ?? "");
const computed = Buffer.from(
crypto.createHmac("sha256", process.env.SENDOZI_WEBHOOK_SECRET).update(req.body).digest("hex"),
);
if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
res.status(200).end();
void recordDelivery(event).catch((error) => console.error("delivery", error));
});import crypto from "node:crypto";
export const runtime = "nodejs";
export async function POST(request: Request) {
// request.text() gives the raw body, which is what was signed.
const raw = await request.text();
const provided = Buffer.from(request.headers.get("x-sendozi-signature") ?? "");
const computed = Buffer.from(
crypto.createHmac("sha256", process.env.SENDOZI_WEBHOOK_SECRET!).update(raw).digest("hex"),
);
if (provided.length !== computed.length || !crypto.timingSafeEqual(provided, computed)) {
return new Response(null, { status: 401 });
}
await enqueueDelivery(JSON.parse(raw));
return new Response(null, { status: 200 });
}Frequently asked questions
- Is there an official Sendozi Node.js SDK?
- No. The API is plain HTTPS with JSON, and Node 18 and later include fetch, so the client on this page is all you need. Anything published as a Sendozi npm package is not official.
- Why is my Node.js send returning invalid_recipient?
- Almost always a number in local 08... format. The API accepts +234 format only, so normalise before sending - the client above does it for you.
- How do I stop a retry sending the SMS twice?
- Pass an Idempotency-Key derived from your own event id and reuse it on every retry attempt. Sendozi replays the stored response instead of sending again.
Related reading
Getting started
Quickstart: send your first SMS
Get an API key, send a sandbox SMS, read the response envelope, register a delivery webhook and move to production. A complete first integration in one page.
Channels
SMS API reference
Endpoints, request fields, routing, batching, sender ID management and page-based cost for sending SMS to Nigerian numbers through the Sendozi API.
Reliability
Idempotency
How Idempotency-Key works on Sendozi send endpoints, what happens on a repeat with the same or a different body, how long keys are retained, and how to choose a key.
Reliability
Delivery webhooks
Register a webhook endpoint, receive message.delivered and message.failed events, verify the signature over the raw body, and build a receiver that survives retries and reordering.
Integrations
Send SMS with Python
A complete Python integration for sending SMS to Nigerian numbers: a small client with httpx, phone normalisation, idempotent retries, typed errors and a Django or FastAPI webhook receiver.