Skip to content
Sendozi

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.

By SendoziUpdated 1 min read

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.

Environment
.env
SENDOZI_API_KEY=sk_test_your_sandbox_key
SENDOZI_SENDER_ID=Sendozi

A small client

sendozi.js - one module, everything the rest of the app needs
JavaScript (ESM)
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

An OTP and a campaign
JavaScript
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;
  }
}
TypeScript types
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

Retry transient failures only, always with the same key
JavaScript
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

Express receiver. Raw body, constant-time comparison, fast acknowledgement.
Express
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));
});
Next.js route handler
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.

Run it against sandbox

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