Skip to main content

Progressive OTP Verification

Verify OTPs across multiple channels step-by-step, allowing users to verify each channel at their own pace.

What is Progressive Verification?

Progressive verification allows users to verify their identity across multiple channels (email, SMS, WhatsApp) one at a time rather than all at once. This provides a more flexible and user-friendly experience.

Key Benefits

  • 🎯 Flexible User Experience - Users can verify channels in any order
  • ⏱️ Reduce Time Pressure - No need to check all channels simultaneously
  • 📱 Multi-Device Friendly - Verify email on desktop, SMS on mobile
  • 🔄 Fallback Options - Try email first, fall back to SMS if needed
  • ✅ Progressive Security - Add additional verification layers over time
  • 🚀 Same Session Token - Use one token for all verification steps

How It Works

When you send an OTP to multiple channels, each channel receives a unique OTP code. You can then verify these channels:

  1. All at once - Verify all channels in a single API call
  2. Step by step - Verify each channel in separate API calls
  3. Mix and match - Verify some channels together, others separately

The system remembers which channels have been verified, so you can verify them progressively over time using the same session token.

Use Cases

1. Check Email First, SMS Later

Scenario: User checks email immediately but won't see their SMS for a few minutes.

// Step 1: Send OTP to both channels
const { session_token } = await sendOTP({
channels: ['email', 'sms'],
recipients: {
email: 'user@example.com',
sms: '+1234567890'
}
});

// Step 2: User verifies email immediately
const result1 = await verifyOTP({
session_token,
otps: {
email: '123456' // Only email OTP
}
});

console.log(result1.verified_channels); // ['email']
console.log(result1.session_verified); // true (if verification_mode = 'any')

// Step 3: Minutes later, user verifies SMS (same token!)
const result2 = await verifyOTP({
session_token, // Same session token
otps: {
sms: '789012' // Only SMS OTP
}
});

console.log(result2.verified_channels); // ['email', 'sms']

2. Quick Access with Optional Additional Security

Scenario: Allow quick login via email, but require SMS for sensitive operations.

// Send OTP to email and SMS
const { session_token } = await sendOTP({
channels: ['email', 'sms'],
recipients: {
email: 'user@example.com',
sms: '+1234567890'
},
verification_mode: 'any' // Verify at least one
});

// Quick login: Verify email only
await verifyOTP({
session_token,
otps: { email: '123456' }
});

// User is now logged in!

// Later: For sensitive action (change password, transfer funds)
if (requiresHighSecurity) {
// Require SMS verification too
await verifyOTP({
session_token, // Same token
otps: { sms: '789012' }
});
}

3. Fallback Verification

Scenario: Try email first, fall back to SMS if user doesn't receive it.

const { session_token } = await sendOTP({
channels: ['email', 'sms'],
recipients: {
email: 'user@example.com',
sms: '+1234567890'
}
});

// Try email first
try {
await verifyOTP({
session_token,
otps: { email: userInput }
});
console.log('Email verified!');
} catch (error) {
// Email verification failed, prompt for SMS
console.log('Email OTP incorrect. Please use SMS OTP.');

await verifyOTP({
session_token, // Same token
otps: { sms: userInputSMS }
});
}

4. Multi-Device Verification

Scenario: User enters email OTP on desktop, SMS OTP on mobile.

// On Desktop: Send OTPs
const { session_token } = await sendOTP({
channels: ['email', 'sms'],
recipients: {
email: 'user@example.com',
sms: '+1234567890'
}
});

// Save session_token (e.g., in database, QR code, or redirect)

// Desktop: Verify email
await verifyOTP({
session_token,
otps: { email: '123456' }
});

// Mobile: Later verify SMS (retrieve session_token)
await verifyOTP({
session_token, // Retrieved from database/storage
otps: { sms: '789012' }
});

API Usage

Verify Single Channel

Verify just one channel at a time:

curl -X POST https://api.sendmator.com/api/v1/otp/verify \
-H "X-API-Key: sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"session_token": "eyJhbGc...",
"otps": {
"email": "123456"
}
}'

Verify Multiple Channels

Verify multiple channels in one call:

curl -X POST https://api.sendmator.com/api/v1/otp/verify \
-H "X-API-Key: sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"session_token": "eyJhbGc...",
"otps": {
"email": "123456",
"sms": "789012"
}
}'

Progressive Verification

Verify channels one by one:

# First call: Verify email
curl -X POST https://api.sendmator.com/api/v1/otp/verify \
-H "X-API-Key: sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"session_token": "eyJhbGc...",
"otps": {
"email": "123456"
}
}'

# Second call: Verify SMS (same token!)
curl -X POST https://api.sendmator.com/api/v1/otp/verify \
-H "X-API-Key: sk_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"session_token": "eyJhbGc...",
"otps": {
"sms": "789012"
}
}'

Response Structure

After First Verification (Email)

{
"verified": true,
"verification_results": {
"email": { "success": true }
},
"verified_channels": ["email"],
"session_verified": true,
"attempts_remaining": 3
}

After Second Verification (SMS)

{
"verified": true,
"verification_results": {
"sms": { "success": true }
},
"verified_channels": ["email", "sms"],
"session_verified": true,
"all_channels_verified": false
}

Key Fields:

  • verified_channels - All channels verified so far (cumulative)
  • verification_results - Results for current verification call
  • session_verified - Whether session meets verification requirements
  • all_channels_verified - Whether all sent channels are verified

Verification Modes

Control when a session is considered fully verified:

Mode: any (Default)

Session is verified when any one channel is verified.

await sendOTP({
channels: ['email', 'sms'],
verification_mode: 'any' // Default
});

// Verify email → session_verified = true ✅
// Verify SMS later → adds SMS to verified_channels

Use Cases:

  • Quick user authentication
  • Password reset flows
  • Account recovery

Mode: all

Session is verified when all channels are verified.

await sendOTP({
channels: ['email', 'sms', 'whatsapp'],
verification_mode: 'all'
});

// Verify email → session_verified = false (2 more needed)
// Verify SMS → session_verified = false (1 more needed)
// Verify WhatsApp → session_verified = true ✅

Use Cases:

  • High-security operations
  • Multi-factor authentication
  • Identity verification

Important Considerations

Session Expiry

  • OTP sessions expire after 10 minutes by default
  • All verification attempts must happen within this window
  • After expiry, you need to send a new OTP
const { session_token, expires_at } = await sendOTP({...});

// expires_at: "2026-03-18T12:30:00Z"

// ❌ After expiry
await verifyOTP({ session_token, otps: {...} });
// Error: "OTP has expired"

Attempt Limits

  • Each channel has its own attempt counter
  • Total attempts across all channels are limited (default: 5)
  • Session locks after max attempts exceeded
// Failed attempts on email: 2
// Failed attempts on SMS: 3
// Total: 5 attempts → Session locked 🔒

Already Verified Channels

You can verify an already-verified channel again without penalty:

// First verification
await verifyOTP({ session_token, otps: { email: '123456' } });
// verified_channels: ['email']

// Verify email again (no harm)
await verifyOTP({ session_token, otps: { email: '123456' } });
// verified_channels: ['email'] (still works)

Token Persistence

Store the session_token to enable progressive verification:

// Option 1: Store in database
await db.sessions.create({
user_id: user.id,
otp_session_token: session_token,
created_at: new Date()
});

// Option 2: Store in user session/cookies
res.cookie('otp_session', session_token, {
httpOnly: true,
maxAge: 10 * 60 * 1000 // 10 minutes
});

// Option 3: Return to client (if secure)
return { session_token };

Integration Examples

React Example

import { useState } from 'react';

function OTPVerification() {
const [sessionToken, setSessionToken] = useState<string>('');
const [verifiedChannels, setVerifiedChannels] = useState<string[]>([]);

// Send OTP
const handleSendOTP = async () => {
const response = await fetch('/api/v1/otp/send', {
method: 'POST',
headers: {
'X-API-Key': 'sk_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
channels: ['email', 'sms'],
recipients: {
email: 'user@example.com',
sms: '+1234567890'
}
})
});

const data = await response.json();
setSessionToken(data.session_token);
};

// Verify Email
const handleVerifyEmail = async (otp: string) => {
const response = await fetch('/api/v1/otp/verify', {
method: 'POST',
headers: {
'X-API-Key': 'sk_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_token: sessionToken,
otps: { email: otp }
})
});

const data = await response.json();
setVerifiedChannels(data.verified_channels);

if (data.session_verified) {
console.log('User authenticated!');
}
};

// Verify SMS (same token!)
const handleVerifySMS = async (otp: string) => {
const response = await fetch('/api/v1/otp/verify', {
method: 'POST',
headers: {
'X-API-Key': 'sk_live_...',
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_token: sessionToken, // Same token
otps: { sms: otp }
})
});

const data = await response.json();
setVerifiedChannels(data.verified_channels);
};

return (
<div>
<button onClick={handleSendOTP}>Send OTP</button>

{sessionToken && (
<>
<div>
<input
placeholder="Enter email OTP"
onChange={(e) => handleVerifyEmail(e.target.value)}
/>
{verifiedChannels.includes('email') && <span>✅ Email verified</span>}
</div>

<div>
<input
placeholder="Enter SMS OTP"
onChange={(e) => handleVerifySMS(e.target.value)}
/>
{verifiedChannels.includes('sms') && <span>SMS verified</span>}
</div>
</>
)}
</div>
);
}

Node.js Example

const express = require('express');
const app = express();

// Store session tokens (use Redis in production)
const sessions = new Map();

// Send OTP
app.post('/auth/send-otp', async (req, res) => {
const response = await fetch('https://api.sendmator.com/api/v1/otp/send', {
method: 'POST',
headers: {
'X-API-Key': process.env.SENDMATOR_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
channels: ['email', 'sms'],
recipients: {
email: req.body.email,
sms: req.body.phone
}
})
});

const data = await response.json();

// Store session token
sessions.set(req.session.id, {
token: data.session_token,
verified_channels: []
});

res.json({ success: true });
});

// Verify Email
app.post('/auth/verify-email', async (req, res) => {
const session = sessions.get(req.session.id);

const response = await fetch('https://api.sendmator.com/api/v1/otp/verify', {
method: 'POST',
headers: {
'X-API-Key': process.env.SENDMATOR_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_token: session.token,
otps: { email: req.body.otp }
})
});

const data = await response.json();
session.verified_channels = data.verified_channels;

if (data.session_verified) {
req.session.authenticated = true;
}

res.json(data);
});

// Verify SMS (later)
app.post('/auth/verify-sms', async (req, res) => {
const session = sessions.get(req.session.id);

const response = await fetch('https://api.sendmator.com/api/v1/otp/verify', {
method: 'POST',
headers: {
'X-API-Key': process.env.SENDMATOR_API_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
session_token: session.token, // Same token
otps: { sms: req.body.otp }
})
});

const data = await response.json();
session.verified_channels = data.verified_channels;

res.json(data);
});

Best Practices

1. Store Session Tokens Securely

// ✅ Good: Server-side storage
await redis.set(`otp:${userId}`, session_token, 'EX', 600);

// ✅ Good: HttpOnly cookie
res.cookie('otp_session', session_token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});

// ❌ Bad: Client-side localStorage (vulnerable to XSS)
localStorage.setItem('otp_token', session_token);

2. Show Verification Progress

// Show users which channels they've verified
const progress = {
email: verifiedChannels.includes('email'),
sms: verifiedChannels.includes('sms'),
whatsapp: verifiedChannels.includes('whatsapp')
};

// UI: ✅ Email verified | ⏳ SMS pending | ⏳ WhatsApp pending

3. Handle Expiry Gracefully

try {
await verifyOTP({ session_token, otps: {...} });
} catch (error) {
if (error.message.includes('expired')) {
// Prompt user to request new OTP
await sendOTP({...}); // New session
}
}

4. Clear Communication

// ✅ Good: Clear instructions
"We've sent OTP codes to your email and phone.
Verify your email now, and your phone later if needed."

// ❌ Bad: Confusing
"Enter OTP codes"