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 5 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 route: "transactional" so it travels the transactional 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.
Get the Sender ID approved
A live OTP needs a Sender ID approved for the transactional route. The wording is yours: there is no template to register. See transactional SMS.
Validate the number on your form
Sendozi accepts 08012345678, 8012345678 and +2348012345678 alike, but an invalid number is refused. Check it when the user types it, so they can correct it before a code is requested.

Writing the message

A good OTP message, and what each part is doing
Example
Your Acme code is 492811. It expires in 10 minutes. Do not share it 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 limits: 300 send requests a minute per workspace, and the per-minute and per-day limits on your transactional Sender ID's profile. They protect the platform. They do 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. Transactional sends require one. If the request times out and your client retries, Sendozi returns the original response instead of sending and charging a second message.

The key is scoped to your workspace, tied to the exact request body and kept for 24 hours. A resend of the code is a new attempt and needs a 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 '{
    "route": "transactional",
    "sender": "AcmeOTP",
    "recipient": "08012345678",
    "message": "Your Acme code is 492811. It expires in 10 minutes. Do not share it with anyone, including Acme staff."
  }'
Node.js
import { randomInt } from "node:crypto";

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

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

  const body = await response.json();
  if (!body.success) {
    // Log request_id: it is how support traces the send.
    throw new Error(`${body.error.code} (${body.request_id}): ${body.error.message}`);
  }
  return body.data.message_id;
}
Python
import os
import secrets
import httpx


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


def send_otp(phone: str, code: str, attempt_id: str) -> str:
    reference = f"verify-{attempt_id}"
    response = httpx.post(
        "https://api.sendozi.com/v1/sms/send",
        headers={
            "Authorization": f"Bearer {os.environ['SENDOZI_API_KEY']}",
            "Idempotency-Key": reference,
        },
        json={
            "route": "transactional",
            "sender": os.environ["SENDOZI_TRANSACTIONAL_SENDER"],
            "recipient": phone,
            "message": f"Your Acme code is {code}. It expires in 10 minutes. Do not share it with anyone, including Acme staff.",
        },
        timeout=15.0,
    )
    body = response.json()
    if not body.get("success"):
        error = body["error"]
        raise RuntimeError(f"{error['code']} ({body.get('request_id')}): {error['message']}")
    return body["data"]["message_id"]

When the OTP does not arrive

  1. 1

    Show the state honestly

    A 202 is not a delivery. 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. Send route: "transactional" from a Sender ID approved for that route, so the message travels the transactional 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 POST /v1/sms/send on the transactional route, in your own words. 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.