Skip to content
Sendozi

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.

By SendoziUpdated 2 min read

Setup

There is no Sendozi SDK. Node 18 or later is all you need, with these values in the environment:

.env
.env
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

sendozi.js
JavaScript (ESM)
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_count and duplicates_removed, so check those if a person typed the number.
  • cancelBatch sends no Idempotency-Key and is not retried; cancelling twice returns 409 batch_not_cancellable, which is harmless.

Using it

An OTP, a one-off offer and a campaign
JavaScript
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

Progress, then every failure
JavaScript
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

Verify the timestamped signature over the raw body, then answer at once
verify.js
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));
}
Express
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();
});
Next.js route handler
// 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

The shapes the client returns
TypeScript
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.

Run it against sandbox

Create an account, take a sk_test_ key and drop the client into your project.