SMS API OTP Node.js Tutorial

SMS OTP Verification: How to Build It in Node.js

By SendAPI Engineering · Last updated March 12, 2026 · 6 min read

Step-by-step guide to implementing SMS OTP verification in a Node.js app using SendAPI. Covers sending codes, verifying input, expiry, and rate limiting.

What is OTP Verification?

OTP (One-Time Password) verification is a form of two-factor authentication (2FA) where a short-lived numeric code is sent to a user's phone via SMS. The user enters the code to prove they control the phone number.

It's used for:

  • Phone number verification at signup
  • Login 2FA for security-sensitive apps
  • Transaction confirmation in fintech and e-commerce
  • Password reset as an alternative to email links

Why OTP Verification Matters: The Data

Two-factor authentication dramatically reduces account compromise. According to Google's Security Blog, enabling 2FA blocks 99.9% of automated bot attacks and 99% of bulk phishing attacks. A 2023 Microsoft report found that accounts with MFA enabled are 99.22% less likely to be compromised than accounts relying on passwords alone.

SMS OTP remains the most widely adopted 2FA method for consumer apps: it requires no app install, works on any phone with a SIM card, and achieves activation rates up to 3x higher than TOTP authenticator apps in consumer-facing products. The global OTP market is projected to reach $4.6 billion by 2030 (Grand View Research, 2024), driven by fintech, e-commerce, and healthcare compliance requirements.

Why SMS OTP vs Authenticator Apps?

SMS OTP works for any user with a phone number — no app install required. For consumer products, the activation rate is significantly higher than TOTP authenticator apps (Google Authenticator, Authy).

The tradeoff: SMS OTP is vulnerable to SIM-swap attacks in high-risk scenarios. For those cases, consider upgrading to TOTP or hardware keys.

Setting Up SendAPI

Install the SendAPI Node.js SDK:

npm install @sendapi/node

Initialize the client:

import SendAPI from '@sendapi/node'

const client = new SendAPI({ apiKey: process.env.SENDAPI_KEY })

Step 1: Send the OTP

Call the /verify/send endpoint with the user's phone number:

// POST /auth/send-otp
app.post('/auth/send-otp', async (req, res) => {
  const { phoneNumber } = req.body

  try {
    const response = await client.verify.send({
      to: phoneNumber,
      channel: 'sms',
      codeLength: 6,
      expiresIn: 300, // 5 minutes in seconds
    })

    // Store the session token (maps to the OTP internally)
    // Never store the code itself server-side
    res.json({ sessionToken: response.sessionToken })
  } catch (err) {
    res.status(400).json({ error: err.message })
  }
})

The user receives an SMS like:

> "Your SendAPI verification code is 847291. Expires in 5 minutes."

Step 2: Verify the Code

When the user submits the code from their phone:

// POST /auth/verify-otp
app.post('/auth/verify-otp', async (req, res) => {
  const { sessionToken, code } = req.body

  try {
    const result = await client.verify.check({
      sessionToken,
      code,
    })

    if (result.valid) {
      // OTP is correct — issue your auth token / session
      const authToken = await createSession(result.phoneNumber)
      res.json({ success: true, token: authToken })
    } else {
      res.status(401).json({ error: 'Invalid or expired code' })
    }
  } catch (err) {
    res.status(400).json({ error: err.message })
  }
})

Rate Limiting & Fraud Prevention

OTP endpoints are a common abuse target. SendAPI handles several protections automatically:

  • Code expiry — configurable, default 10 minutes
  • Attempt limiting — max 3 wrong guesses before the session is invalidated
  • Send rate limiting — per phone number (prevents OTP flooding attacks)
  • Delivery receipts — webhook events for delivered, failed, undelivered

You should also add your own application-level rate limiting:

import rateLimit from 'express-rate-limit'

const otpLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // max 5 OTP requests per IP per window
  message: 'Too many OTP requests. Please try again later.',
})

app.post('/auth/send-otp', otpLimiter, async (req, res) => { ... })

Handling Edge Cases

Phone number already verified

Check your database before sending an OTP to avoid re-verifying already-verified users:

const user = await db.users.findOne({ phone: phoneNumber })
if (user?.phoneVerified) {
  return res.status(400).json({ error: 'Phone already verified' })
}

International numbers

Always accept E.164 format (+14155552671). Validate on the client before sending:

import { parsePhoneNumber, isValidPhoneNumber } from 'libphonenumber-js'

if (!isValidPhoneNumber(phoneNumber)) {
  return res.status(400).json({ error: 'Invalid phone number' })
}
const e164 = parsePhoneNumber(phoneNumber).format('E.164')

Resend flow

Allow resending after a cooldown period, not immediately:

// Check last send timestamp before allowing resend
const lastSent = cache.get(`otp_sent:${phoneNumber}`)
if (lastSent && Date.now() - lastSent < 60000) {
  return res.status(429).json({ error: 'Please wait 60 seconds before resending' })
}

Full Flow Diagram

User enters phone → POST /auth/send-otp
                         ↓
                   SendAPI sends SMS
                         ↓
User enters 6-digit code → POST /auth/verify-otp
                                ↓
                   SendAPI validates code
                                ↓
                   Issue session / JWT

Next Steps