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.
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
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
| Control | Sensible default | Why |
|---|---|---|
| Code length | 6 digits | One million combinations, comfortably readable, standard for autofill. |
| Expiry | 5 to 10 minutes | Long enough for a slow SMS, short enough that an intercepted code is stale. |
| Attempts per code | 3 to 5 | Then invalidate the code, not just the attempt. |
| Resend cooldown | 60 seconds | Stops users double-tapping and paying for two pages. |
| Resends per number per hour | 3 to 5 | The main defence against someone burning your wallet through a signup form. |
| Distinct numbers per IP per hour | A low double-digit number | Catches 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
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."
}'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;
}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
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
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
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
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.
Related reading
SMS
DND and SMS delivery in Nigeria
What Nigeria's Do Not Disturb register is, why promotional SMS fails on DND-active numbers, which traffic legitimately travels a transactional route, and how to diagnose a failed send.
Developers
Nigerian phone number formats for developers
How Nigerian mobile numbers are structured, how to convert 0803 local format to +234 E.164, the validation traps that break SMS delivery, and normalisation code in several languages.
Developers
SMS delivery reports and webhooks
What each SMS delivery status means, why accepted is not delivered, how to consume signed delivery webhooks instead of polling, and how to reconcile a campaign afterwards.
SMS
Transactional vs promotional SMS in Nigeria
The difference between transactional and promotional SMS, how each route behaves against the Nigerian DND register, a side-by-side comparison, and the grey cases teams get wrong.