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 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
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
| 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 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
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"
}'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;
}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
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
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. 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.
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 actually 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.