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

 User enters phone  →  Generate 6-digit OTP  →  pm.sms.send(to, body)

                                       Status: queued

                              [webhook: sms.delivered or sms.failed]

                    User enters code  →  Compare  →  Sign them in
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

import { Robase } from '@robase/node';
import { randomBytes } from 'node:crypto';

const pm = new Robase(process.env.ROBASE_API_KEY!);

async function sendOtp(phone: string, userId: string) {
  const code = String(Math.floor(100000 + Math.random() * 900000));
  const ttl = 5 * 60 * 1000; // 5 minutes

  // 1. Store the code — single-use, TTL'd, tied to a sms_id.
  const sms = await pm.sms.send(
    {
      to: phone,
      from: 'SHUTTLERS',
      body: `Your Shuttlers code is ${code}. Valid for 5 minutes. Don't share it.`,
    },
    `otp-${userId}-${Math.floor(Date.now() / ttl)}`  // idempotency bucket
  );

  await db.otps.insert({
    user_id: userId,
    sms_id: sms.id,
    code_hash: await bcrypt.hash(code, 10),  // never store plaintext
    expires_at: new Date(Date.now() + ttl),
    consumed: false,
  });

  return { sms_id: sms.id, expires_in_seconds: ttl / 1000 };
}

async function verifyOtp(userId: string, submitted: string): Promise<boolean> {
  const row = await db.otps.findOne({
    user_id: userId,
    consumed: false,
    expires_at: { $gt: new Date() },
  }).sort({ created_at: -1 });

  if (!row) return false;
  if (!(await bcrypt.compare(submitted, row.code_hash))) return false;

  // Mark consumed atomically — prevents racing the same code twice.
  const result = await db.otps.updateOne(
    { _id: row._id, consumed: false },
    { $set: { consumed: true, consumed_at: new Date() } },
  );
  return result.modifiedCount === 1;
}

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:
app.post('/webhooks/robase', async (req, res) => {
  const event = await verifyAndParse(req);

  if (event.type === 'sms.delivered') {
    await db.otps.updateOne({ sms_id: event.data.sms_id }, { $set: { delivered: true } });
  }

  if (event.type === 'sms.failed' || event.type === 'sms.rejected') {
    const otp = await db.otps.findOne({ sms_id: event.data.sms_id });
    if (otp) {
      // Notify the user via push / email / WhatsApp fallback.
      await fallbackDeliver(otp.user_id, event.data.error_message);
    }
  }

  res.status(200).end();
});

Rate limiting the sender

Without per-user rate limits on sendOtp, an abuser can drain your SMS budget by triggering resends:
const sentRecently = await redis.get(`otp:sent:${userId}`);
if (sentRecently) {
  throw new Error('Please wait 60s before requesting another code.');
}
await redis.setex(`otp:sent:${userId}`, 60, '1');
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.)