Skip to main content
OTP (one-time password) verification is the most common SMS use-case. This guide shows an end-to-end flow: generate, send, verify, and — crucially — handle the edge cases most teams get wrong.

The flow

The hard parts aren’t the happy path — they’re:
  1. Delivery isn’t instant. African carriers sometimes take 30+ seconds to confirm.
  2. Delivery can fail silently. Your user stares at an empty inbox while you assume success.
  3. Replay attacks. Without a short TTL + single-use enforcement, old codes work forever.

Reference implementation

Handle the DLR webhook

When sms.delivered fires, update the UI. When sms.failed fires, offer a fallback immediately — don’t make the user wait for the 5-minute TTL to time out:

Rate limiting the sender

Without per-user rate limits on sendOtp, an abuser can drain your SMS budget by triggering resends:
Pair with:
  • Max 5 OTPs per user per hour — prevents a leaked user ID from burning your budget.
  • Max 3 verification attempts per code — prevents brute-force.
  • Different codes per resend — never reuse the same 6 digits across resends.

WhatsApp / email fallback

Users whose SMS fails benefit from a fallback channel. See the WhatsApp + SMS fallback guide for the pattern.

Security checklist

Hash it with bcrypt/argon2 before storing. Observation of a log aggregator is enough to compromise every user’s OTP.
If the verify attempt comes from a different IP than the original request, either require extra verification or block it.
bcrypt.compare / hmac.compare_digest — not ===. Prevents timing attacks from leaking the code one character at a time.
"Your Shuttlers code is..." trains users to recognize legit codes and distrust phishing attempts.
Bad: "Your code is 123456. Use it to approve the ₦50,000 transfer to John Doe at Bank XYZ."Good: "Your code is 123456. Don't share it with anyone." (Context lives in your app UI, not the SMS.)