Integrations
Send SMS with Node.js
Node 18 and later include fetch, so no package is needed. The client below sends promotional and transactional SMS with an Idempotency-Key, throws a typed error carrying error.code, and pairs with a webhook receiver that verifies the timestamped signature.
Setup
There is no Sendozi SDK. Node 18 or later is all you need, with these values in the environment:
SENDOZI_API_KEY=sk_test_... # sk_live_... in production
SENDOZI_SENDER=YourBrand # approved for promotional
SENDOZI_TRANSACTIONAL_SENDER=YourBrandOTP
SENDOZI_WEBHOOK_SECRET=whsec_...The client
const BASE_URL = "https://api.sendozi.com/v1";
const RETRYABLE = new Set([429, 500, 502, 503, 504]);
export class SendoziError extends Error {
constructor(status, error, requestId) {
super(`${error.code}: ${error.message}`);
this.name = "SendoziError";
this.status = status;
this.code = error.code;
this.resolution = error.resolution;
this.requestId = requestId;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* One API call. A POST with an idempotencyKey is retried on network errors,
* 429 and 5xx, with the same key and the same bytes each time.
*/
async function call(method, path, { body, idempotencyKey, attempts = 4, timeoutMs = 30_000 } = {}) {
const payload = body === undefined ? undefined : JSON.stringify(body);
const canRetry = method === "GET" || Boolean(idempotencyKey);
for (let attempt = 1; ; attempt += 1) {
let response;
try {
response = await fetch(BASE_URL + path, {
method,
headers: {
Authorization: `Bearer ${process.env.SENDOZI_API_KEY}`,
...(payload ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: payload,
signal: AbortSignal.timeout(timeoutMs),
});
} catch (error) {
if (!canRetry || attempt >= attempts) throw error;
await sleep(2 ** attempt * 500);
continue;
}
const envelope = await response.json();
if (envelope.success) return envelope.data;
const error = new SendoziError(response.status, envelope.error, envelope.request_id);
if (!canRetry || !RETRYABLE.has(response.status) || attempt >= attempts) throw error;
const retryAfter = Number(response.headers.get("retry-after"));
await sleep(retryAfter > 0 ? retryAfter * 1000 : 2 ** attempt * 500 + Math.random() * 250);
}
}
export const sendozi = {
/** Promotional SMS to one number or many. Returns the queued batch. */
sendPromotional({ to, message, sender = process.env.SENDOZI_SENDER, idempotencyKey }) {
const many = Array.isArray(to);
return call("POST", many ? "/sms/bulk" : "/sms/send", {
body: { route: "promotional", sender, message, ...(many ? { recipients: to } : { recipient: to }) },
idempotencyKey,
timeoutMs: many && to.length > 10_000 ? 5 * 60_000 : 30_000,
});
},
/** One transactional SMS, in your own words. The key keeps a retry from sending twice. */
sendTransactional({ to, message, idempotencyKey, sender = process.env.SENDOZI_TRANSACTIONAL_SENDER }) {
return call("POST", "/sms/send", {
body: { route: "transactional", sender, recipient: to, message },
idempotencyKey,
});
},
getMessage: (id) => call("GET", `/messages/${encodeURIComponent(id)}`),
getBatch: (id) => call("GET", `/message-batches/${encodeURIComponent(id)}`),
cancelBatch: (id) => call("POST", `/message-batches/${encodeURIComponent(id)}/cancel`),
/** Every recipient of a batch, page by page. */
async *batchRecipients(id) {
let cursor;
do {
const query = new URLSearchParams({ limit: "500", ...(cursor ? { cursor } : {}) });
const page = await call("GET", `/message-batches/${encodeURIComponent(id)}/recipients?${query}`);
yield* page.data;
cursor = page.has_more ? page.next_cursor : undefined;
} while (cursor);
},
};- Numbers can be sent as 08012345678, 8012345678, 2348012345678 or +2348012345678; Sendozi normalises them. Invalid and repeated numbers are dropped and counted in
invalid_countandduplicates_removed, so check those if a person typed the number. cancelBatchsends no Idempotency-Key and is not retried; cancelling twice returns 409 batch_not_cancellable, which is harmless.
Using it
import { sendozi, SendoziError } from "./sendozi.js";
// Transactional: one person, your own wording.
const otp = await sendozi.sendTransactional({
to: user.phone,
message: `Your Acme code is ${code}. It expires in 10 minutes.`,
idempotencyKey: `login-${attempt.id}`, // stable across retries of this attempt
});
console.log(otp.message_id);
// Promotional, one number.
await sendozi.sendPromotional({
to: "08012345678",
message: "Acme: your 10% welcome discount is ready. Opt out: acme.ng/stop",
idempotencyKey: `welcome-offer-${user.id}`,
});
// Promotional, many numbers: one request, one batch.
try {
const batch = await sendozi.sendPromotional({
to: audience, // array of numbers
message: "Weekend sale: 20% off everything until Sunday. Opt out: acme.ng/stop",
idempotencyKey: "campaign-2026-09-26-weekend-sale",
});
console.log(batch.batch_id, batch.accepted_count, batch.invalid_count);
} catch (error) {
if (!(error instanceof SendoziError)) throw error;
if (error.code === "insufficient_balance") notifyFinance();
else console.error(error.code, error.resolution, error.requestId);
}Tracking a batch
const batch = await sendozi.getBatch("bat_5f45b8c07bd844c8");
console.log(batch.status, batch.progress);
if (!["queued", "processing"].includes(batch.status)) {
for await (const recipient of sendozi.batchRecipients(batch.batch_id)) {
if (recipient.status === "failed" || recipient.status === "skipped") {
console.log(recipient.recipient, recipient.error?.code, recipient.error?.message);
}
}
}Receiving webhooks
import crypto from "node:crypto";
/** True when the request is from Sendozi and less than five minutes old. */
export function verifySendozi(rawBody, timestamp, signature, secret = process.env.SENDOZI_WEBHOOK_SECRET) {
if (!timestamp || !signature) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
return signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}import express from "express";
import { verifySendozi } from "./verify.js";
const app = express();
app.post("/webhooks/sendozi", express.raw({ type: "application/json" }), async (req, res) => {
const ok = verifySendozi(req.body.toString("utf8"), req.get("x-sendozi-timestamp"), req.get("x-sendozi-signature"));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
await queue.add(event, { jobId: event.id }); // jobId dedupes repeats
res.status(200).end();
});// app/webhooks/sendozi/route.ts
import { verifySendozi } from "@/lib/verify";
export const runtime = "nodejs";
export async function POST(request: Request) {
const raw = await request.text(); // the exact bytes that were signed
const ok = verifySendozi(raw, request.headers.get("x-sendozi-timestamp"), request.headers.get("x-sendozi-signature"));
if (!ok) return new Response(null, { status: 401 });
await enqueue(JSON.parse(raw));
return new Response(null, { status: 200 });
}Return 5xx if you could not accept an event, so it is retried; any other 4xx stops the retries. See webhooks for every event and its payload.
TypeScript types
export type MessageStatus = "queued" | "accepted" | "delivered" | "failed" | "expired" | "cancelled";
export type BatchStatus = "queued" | "processing" | "completed" | "partially_completed" | "failed" | "cancelled";
export interface SendoziFailure { code: string; message: string }
export interface SendResult {
batch_id: string;
mode: "sandbox" | "production";
status: "queued";
requested_count: number;
accepted_count: number;
duplicates_removed: number;
invalid_count: number;
suppressed_count: number;
dnd_skipped_count: number;
estimated_cost_kobo: number;
reserved_cost_kobo: number;
message_id?: string; // when exactly one recipient was accepted
type: "single" | "bulk";
}
export interface SendoziMessage {
message_id: string;
mode: "sandbox" | "production";
sender: string;
recipient: string;
sms_type: "promotional" | "transactional";
status: MessageStatus;
status_timeline: { status: string; at: string }[];
customer_price_kobo: number;
error: SendoziFailure | null;
created_at: string;
sent_at: string | null;
delivered_at: string | null;
}
export interface SendoziWebhookEvent<T = Record<string, unknown>> {
id: string;
event: string;
created_at: string;
data: T;
}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. A package claiming to be a Sendozi SDK is not official.
- Why does my Node.js send return invalid_recipient?
- None of the numbers in the request is a valid Nigerian mobile number. Sendozi accepts 080..., 80..., 234... and +234... forms; check for a missing digit or a non-mobile number.
- How do I stop a retry sending the SMS twice in Node.js?
- Pass an Idempotency-Key built from your own event id and reuse it, with the same body, on every attempt. The client on this page does that for every send.
Related reading
Getting started
Quickstart: send your first SMS
Create a sandbox key, send your first SMS, read the queued batch it returns, follow the message to delivery, and switch to production. A complete first integration.
Sending
Sending SMS
POST /v1/sms/send and /v1/sms/bulk: every request field, the response, how recipients are cleaned, limits, the delivery window, and exactly how a send is priced and charged.
Sending
Transactional SMS: OTPs, alerts and receipts
What a transactional send needs: a Transactional Sender ID, one recipient, your own message and an Idempotency-Key. No templates to register. With a working OTP example.
Reliability
Webhooks
Register a webhook endpoint, the eight events and their payloads, verifying the signature over the raw body, how retries and pausing work, and a receiver that survives repeats and reordering.
Integrations
Send SMS with Python
A complete Python integration for Sendozi with httpx: promotional and transactional sends, safe retries with an Idempotency-Key, batch tracking, and a verified Django or FastAPI webhook receiver.