Skip to content
Sendozi

Developers

OTP SMS in Nigeria: designing verification that works

An OTP is a short-lived code sent to prove control of a phone number. In Nigeria the delivery decisions that matter are using the transactional route, keeping the message to one page, giving the code a short expiry, and making retries idempotent so a slow network does not send and charge twice.

By SendoziUpdated 4 min read

What is an OTP SMS?

A one-time password is a short numeric code, valid for a few minutes and for a single use, sent to a phone number to prove the person holds that number. It is used for signup verification, login step-up, password reset and transaction confirmation.

SMS OTP is not the strongest second factor available - an authenticator app or a passkey is stronger, because SMS can be intercepted through SIM swap. It is, however, the factor most Nigerian users can actually complete, which is why it remains the default for consumer products here. Use it knowingly: for high-value actions, pair it with something else.

The four decisions that determine delivery

Route it as transactional
An OTP is service traffic, not marketing. Send it with sms_type: "transactional" so it travels the corporate route and is not filtered as marketing on a DND-active number.
Keep it to one page
Under 160 characters. A two-page OTP costs twice as much for no benefit, and long OTP messages read as spam.
Use an approved sender ID
Recipients judge an OTP by its sender. An unregistered name is refused outright with sender_id_not_approved.
Normalise the number first
The API accepts +234 format only. A user typing 08012345678 must be normalised before the request, or the send fails validation.

Writing the message

A good OTP message, and what each part is doing
Example
Your Acme verification code is 492811. It expires in 10 minutes.
Do not share this code with anyone, including Acme staff.
  • Name the service, so a user with several pending codes can tell them apart.
  • State the expiry, so a stale code is understood rather than retried.
  • Include the anti-sharing warning - it measurably reduces social-engineering losses.
  • Put the code early in the message: some handsets surface only the first line in a notification.
  • Never include a link in an OTP message. It trains users to click links in security messages.

Expiry, retries and rate limits

ControlSensible defaultWhy
Code length6 digitsOne million combinations, comfortably readable, standard for autofill.
Expiry5 to 10 minutesLong enough for a slow SMS, short enough that an intercepted code is stale.
Attempts per code3 to 5Then invalidate the code, not just the attempt.
Resend cooldown60 secondsStops users double-tapping and paying for two pages.
Resends per number per hour3 to 5The main defence against someone burning your wallet through a signup form.
Distinct numbers per IP per hourA low double-digit numberCatches enumeration and SMS-pumping abuse early.

Sendozi applies its own limit of 300 send requests per minute per workspace, which protects the platform. It does not protect your wallet from your own signup form: that limit is yours to set.

Idempotency: not sending twice

Send an Idempotency-Key derived from the verification attempt, not a random value. If the request times out and your client retries, Sendozi replays the original response instead of sending and charging a second message.

The key is scoped to your workspace, fingerprinted against the exact request body and retained for 24 hours. Reusing it with a different body returns 409 idempotency_conflict, which is the behaviour you want: it means a genuine second OTP needs a genuinely new key. See the idempotency reference for the full matrix.

A working integration

Requesting an OTP. The verification attempt id becomes the idempotency key.
cURL
curl -X POST https://api.sendozi.com/v1/sms/send \
  -H "Authorization: Bearer $SENDOZI_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: verify-8f21c4-attempt-1" \
  -d '{
    "sender": "Acme",
    "recipient": "+2348012345678",
    "message": "Your Acme verification code is 492811. It expires in 10 minutes. Do not share it with anyone.",
    "sms_type": "transactional"
  }'
Node.js
import { randomInt } from "node:crypto";

const SENDOZI_API_KEY = process.env.SENDOZI_API_KEY;
const SENDER_ID = process.env.SENDOZI_SENDER_ID;

export function createOtpCode() {
  return String(randomInt(0, 1_000_000)).padStart(6, "0");
}

export async function sendOtp({ phone, code, attemptId }) {
  const response = await fetch("https://api.sendozi.com/v1/sms/send", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${SENDOZI_API_KEY}`,
      "Content-Type": "application/json",
      // Derived from the attempt, so a network retry replays rather than resends.
      "Idempotency-Key": `otp-${attemptId}`,
    },
    body: JSON.stringify({
      sender: SENDER_ID,
      recipient: phone, // already normalised to +234...
      message: `Your Acme verification code is ${code}. It expires in 10 minutes. Do not share it with anyone.`,
      sms_type: "transactional",
    }),
  });

  const body = await response.json();

  if (!response.ok || !body.success) {
    // Log request_id: it is how support traces the send.
    throw new Error(`${body.error?.code ?? "send_failed"} (${body.request_id}): ${body.error?.message ?? ""}`);
  }

  return body.data;
}
Python
import os
import secrets
import httpx

API_KEY = os.environ["SENDOZI_API_KEY"]
SENDER_ID = os.environ["SENDOZI_SENDER_ID"]


def create_otp_code() -> str:
    return f"{secrets.randbelow(1_000_000):06d}"


def send_otp(phone: str, code: str, attempt_id: str) -> dict:
    response = httpx.post(
        "https://api.sendozi.com/v1/sms/send",
        headers={
            "Authorization": f"Bearer {API_KEY}",
            "Content-Type": "application/json",
            "Idempotency-Key": f"otp-{attempt_id}",
        },
        json={
            "sender": SENDER_ID,
            "recipient": phone,
            "message": f"Your Acme verification code is {code}. It expires in 10 minutes. Do not share it with anyone.",
            "sms_type": "transactional",
        },
        timeout=15.0,
    )
    body = response.json()
    if not body.get("success"):
        error = body.get("error", {})
        raise RuntimeError(f"{error.get('code')} ({body.get('request_id')}): {error.get('message')}")
    return body["data"]

When the OTP does not arrive

  1. 1

    Show the state honestly

    Accepted is not delivered. Tell the user the code is on its way and give the resend a visible countdown rather than pretending it has arrived.

  2. 2

    Listen for the delivery webhook

    Register an endpoint for message.delivered and message.failed and record the outcome against the attempt. Polling for a single OTP is wasteful and slow.

  3. 3

    Offer one alternative path

    Email verification or a support route. A user who cannot receive SMS is otherwise permanently stuck at your front door.

  4. 4

    Read the error code before retrying

    insufficient_balance, sender_id_not_approved and invalid_recipient will fail identically on retry. Only transient errors deserve a second attempt.

Frequently asked questions

Do OTP messages reach DND numbers in Nigeria?
Yes, when they are sent as transactional traffic. Set sms_type: "transactional" so the message travels the corporate route rather than the promotional one, which is filtered for DND-active subscribers.
How long should an SMS OTP be valid?
Five to ten minutes suits Nigerian delivery conditions. Shorter frustrates users on a slow network; longer widens the window in which an intercepted code is still useful.
How do I stop the same OTP being sent twice?
Send an Idempotency-Key derived from the verification attempt. Sendozi replays the stored response for a repeat of the same key and body, so a client retry does not produce a second message or a second charge.
Does Sendozi have a dedicated OTP endpoint?
No. OTPs are sent through the normal SMS endpoint with the transactional route selected. There is no separate product and no separate price.
Can I verify an OTP through the Sendozi API?
No. Sendozi delivers the message; generating, storing, expiring and checking the code stays in your application, where the session it protects lives.

Build it against the sandbox

A sk_test_ key runs the same validation without calling a provider or debiting the wallet.