How to Implement OTP Verification in Your App (Complete Guide)
By SendAPI Engineering · Last updated April 4, 2026 · 9 min read
A complete guide to adding OTP phone verification to any app. Covers SMS and WhatsApp channels, expiry, rate limiting, and full code examples in Node.js, Python, and PHP.
Why OTP Verification Matters
OTP (One-Time Password) verification is the backbone of phone number confirmation, two-factor authentication, and transaction approval flows. It proves a user controls a specific phone number — without requiring an app install, a password manager, or any setup on their end.
Key stats worth knowing:
- Accounts with 2FA enabled are 99.22% less likely to be compromised (Microsoft Security, 2023)
- SMS OTP achieves activation rates up to 3x higher than authenticator apps for consumer products (no app install required)
- WhatsApp OTP is increasingly preferred in markets where WhatsApp is the primary messaging channel — LATAM, Middle East, Southeast Asia, Europe
What you will build: a complete OTP flow — send a code, verify it, handle expiry, and add rate limiting — with examples in Node.js, Python, and PHP.
---
How the SendAPI Verify Flow Works
1. Your server calls /verify/send → SendAPI sends the code → returns a token
2. User enters the code in your UI
3. Your server calls /verify/check with the token + code
4. SendAPI returns { valid: true } or { valid: false }
You never handle the code yourself. SendAPI generates it, delivers it, and validates it. Your server only stores the token.
---
Sending an OTP — Node.js
npm install @sendapi/node dotenv
import 'dotenv/config'
import SendAPI from '@sendapi/node'
const client = new SendAPI(process.env.SENDAPI_KEY)
// Step 1: Send the OTP
async function sendOtp(phoneNumber) {
const result = await client.verify.send({
to: phoneNumber, // E.164 format, e.g. '+14155552671'
channel: 'sms', // 'sms', 'whatsapp', or 'auto'
length: 6, // code length: 4–8 digits
ttl: 300, // expiry in seconds (5 minutes)
})
// Store result.token in your session or database
// Never expose it to the client
return result.token
}
// Step 2: Verify the code the user entered
async function verifyOtp(token, code) {
const result = await client.verify.check({ token, code })
return result.valid // true or false
}
Channel options:
sms— sends via SMS (uses top-up credits)whatsapp— sends via WhatsApp (requires an active WhatsApp session on your plan)auto— SendAPI routes to whichever channel is most likely to deliver
---
Sending an OTP — Python
pip install requests python-dotenv
import os
import requests
from dotenv import load_dotenv
load_dotenv()
BASE_URL = 'https://api.sendapi.co/api/v1'
HEADERS = {
'Authorization': f'Bearer {os.getenv("SENDAPI_KEY")}',
'Content-Type': 'application/json',
}
def send_otp(phone_number: str) -> str:
response = requests.post(f'{BASE_URL}/verify/send', json={
'to': phone_number,
'channel': 'sms',
'length': 6,
'ttl': 300,
}, headers=HEADERS)
response.raise_for_status()
return response.json()['data']['token']
def verify_otp(token: str, code: str) -> bool:
response = requests.post(f'{BASE_URL}/verify/check', json={
'token': token,
'code': code,
}, headers=HEADERS)
response.raise_for_status()
return response.json()['data']['valid']
---
Sending an OTP — PHP / Laravel
composer require guzzlehttp/guzzle
<?php
use GuzzleHttp\Client;
class OtpService
{
private Client $http;
private string $baseUrl = 'https://api.sendapi.co/api/v1';
public function __construct()
{
$this->http = new Client([
'headers' => [
'Authorization' => 'Bearer ' . env('SENDAPI_KEY'),
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]);
}
public function sendOtp(string $phone): string
{
$response = $this->http->post("{$this->baseUrl}/verify/send", [
'json' => [
'to' => $phone,
'channel' => 'sms',
'length' => 6,
'ttl' => 300,
],
]);
$data = json_decode($response->getBody(), true);
return $data['data']['token'];
}
public function verifyOtp(string $token, string $code): bool
{
$response = $this->http->post("{$this->baseUrl}/verify/check", [
'json' => [
'token' => $token,
'code' => $code,
],
]);
$data = json_decode($response->getBody(), true);
return $data['data']['valid'] === true;
}
}
---
Putting It Together: Express.js Example
Here is a complete registration flow with OTP verification:
import express from 'express'
import 'dotenv/config'
import SendAPI from '@sendapi/node'
const app = express()
app.use(express.json())
const client = new SendAPI(process.env.SENDAPI_KEY)
// In-memory store for demo — use Redis or your DB in production
const pendingVerifications = new Map()
// POST /auth/send-otp
app.post('/auth/send-otp', async (req, res) => {
const { phone } = req.body
if (!phone) {
return res.status(400).json({ error: 'Phone number required' })
}
try {
const result = await client.verify.send({
to: phone,
channel: 'sms',
length: 6,
ttl: 300,
})
// Store the token server-side, keyed by phone
pendingVerifications.set(phone, result.token)
res.json({ message: 'OTP sent', expires_in: 300 })
} catch (err) {
res.status(500).json({ error: 'Failed to send OTP' })
}
})
// POST /auth/verify-otp
app.post('/auth/verify-otp', async (req, res) => {
const { phone, code } = req.body
const token = pendingVerifications.get(phone)
if (!token) {
return res.status(400).json({ error: 'No pending verification for this number' })
}
try {
const result = await client.verify.check({ token, code })
if (!result.valid) {
return res.status(400).json({ error: 'Invalid or expired code' })
}
// Code is valid — mark phone as verified, create session, etc.
pendingVerifications.delete(phone)
res.json({ verified: true })
} catch (err) {
res.status(500).json({ error: 'Verification failed' })
}
})
app.listen(3000, () => console.log('Server running on port 3000'))
---
Rate Limiting to Prevent Abuse
Without rate limiting, your OTP endpoint can be abused to flood users with messages or exhaust your SMS credits. Add basic protection:
// Using express-rate-limit
import rateLimit from 'express-rate-limit'
const otpLimiter = rateLimit({
windowMs: 10 * 60 * 1000, // 10 minutes
max: 3, // max 3 OTP requests per IP per window
message: { error: 'Too many OTP requests. Please wait before trying again.' },
standardHeaders: true,
legacyHeaders: false,
})
app.post('/auth/send-otp', otpLimiter, async (req, res) => {
// ... handler
})
---
SMS vs WhatsApp OTP: When to Use Which
| Factor | SMS | WhatsApp |
|--------|-----|---------|
| Works without internet | ✅ Yes | ❌ No |
| No app install needed | ✅ Yes | ✅ Yes (if already installed) |
| Best in emerging markets | ✅ Strong | ✅ Strong (high WhatsApp penetration) |
| Delivery in areas with poor carrier coverage | ⚠️ Variable | ✅ Better (uses data) |
| Cost | Uses SMS credits | Included in monthly plan |
| Open rate | High | Very high |
Recommendation: Use auto channel for the best of both — SendAPI will route to the channel most likely to deliver based on the recipient's region.
---
What Happens When a Code Expires?
If a user does not enter their code before the ttl expires, verify/check returns { valid: false }. Simply issue a new code:
// Resend OTP
app.post('/auth/resend-otp', otpLimiter, async (req, res) => {
const { phone } = req.body
const result = await client.verify.send({
to: phone,
channel: 'sms',
length: 6,
ttl: 300,
})
pendingVerifications.set(phone, result.token)
res.json({ message: 'New OTP sent', expires_in: 300 })
})
---
The Same Plan Covers WhatsApp, SMS, and Email
Once your OTP flow is working, the same API key and subscription covers:
- WhatsApp messaging — included in your monthly plan
- SMS OTP — top-up credits, pay for what you use
- Transactional email — included in your monthly plan
No extra accounts. No per-channel sign-ups. Starting at $9.99/month.