> ## Documentation Index
> Fetch the complete documentation index at: https://docs.robase.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# OTP verification

> A complete one-time password flow with DLR confirmation and fallback.

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

<CodeGroup>
  ```typescript Node.js theme={null}
  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;
  }
  ```

  ```python Python theme={null}
  import os, random, time, bcrypt
  from robase import Robase

  pm = Robase(os.environ["ROBASE_API_KEY"])

  def send_otp(phone: str, user_id: str) -> dict:
      code = f"{random.randint(100000, 999999)}"
      ttl = 5 * 60

      sms = pm.sms.send(
          to=phone,
          from_="SHUTTLERS",
          body=f"Your Shuttlers code is {code}. Valid for 5 minutes. Don't share it.",
          idempotency_key=f"otp-{user_id}-{int(time.time() // ttl)}",
      )

      db.otps.insert_one({
          "user_id": user_id,
          "sms_id": sms["id"],
          "code_hash": bcrypt.hashpw(code.encode(), bcrypt.gensalt()),
          "expires_at": time.time() + ttl,
          "consumed": False,
      })

      return {"sms_id": sms["id"], "expires_in_seconds": ttl}

  def verify_otp(user_id: str, submitted: str) -> bool:
      row = db.otps.find_one(
          {"user_id": user_id, "consumed": False, "expires_at": {"$gt": time.time()}},
          sort=[("created_at", -1)],
      )
      if not row: return False
      if not bcrypt.checkpw(submitted.encode(), row["code_hash"]): return False
      result = db.otps.update_one(
          {"_id": row["_id"], "consumed": False},
          {"$set": {"consumed": True, "consumed_at": time.time()}},
      )
      return result.modified_count == 1
  ```
</CodeGroup>

## 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:

```typescript theme={null}
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:

```typescript theme={null}
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](/guides/whatsapp-sms-fallback) for the pattern.

## Security checklist

<AccordionGroup>
  <Accordion title="Never log the plaintext code">
    Hash it with bcrypt/argon2 before storing. Observation of a log aggregator is enough to compromise every user's OTP.
  </Accordion>

  <Accordion title="Tie codes to a device / IP / session">
    If the verify attempt comes from a different IP than the original request, either require extra verification or block it.
  </Accordion>

  <Accordion title="Use constant-time comparison">
    `bcrypt.compare` / `hmac.compare_digest` — not `===`. Prevents timing attacks from leaking the code one character at a time.
  </Accordion>

  <Accordion title="Include the brand in the message">
    `"Your Shuttlers code is..."` trains users to recognize legit codes and distrust phishing attempts.
  </Accordion>

  <Accordion title="Never send the code and the full context in one message">
    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.)
  </Accordion>
</AccordionGroup>
