Request OTP
Generate and send secure one-time passwords with configurable length, type, expiry, and automatic rate limiting. Perfect for authentication, transaction verification, and account recovery flows.
Multiple PIN Types
NUMERIC, ALPHANUMERIC, or ALPHABETIC codes, 4-10 characters long
Flexible Expiry
Custom expiry from 1 minute up to 24 hours with automatic invalidation
Rate Limiting
Phone-based and IP-based limits to prevent OTP spam and abuse
Metadata Support
Attach custom data (userId, sessionId) for seamless integration
Advanced security measures for sensitive operations
- Encrypted at rest (AES-256-GCM)
- Automatic code expiration
- Brute force protection
- Idempotency-Key support
Authentication Required
Enhanced OTP Features
- Template placeholders for personalized messages
- Automatic retry limiting to prevent brute force
- Idempotency-Key header to safely retry a request
- Sandbox/test-mode numbers for safe development
- Webhook support for send/delivery status
One Endpoint, Any Channel
POST /v1/otp/request now takes an optional channel field ("sms" | "voice" | "whatsapp" | "email", defaulting to "sms") — or an array of channels tried in order until one send succeeds, e.g. ["whatsapp","sms"]. See Channel & Fallback below. The channel-specific URLs — /request/voice, /request/whatsapp, /request/email — still work exactly as before and always stay available; they're just no longer the primary way to reach a channel (Email uses the recipient.value (or legacy phone) field for the email address; WhatsApp requires the business to have an approved authentication template — or falls back to Sendexa's platform number — otherwise returns WHATSAPP_NOT_CONFIGURED).10 min
Configurable 1-1440 (minutes or hours)
3
Per OTP by default
3 / hr
Per phone number
4-10
Chars, any PIN type
Request Body
{"recipient": { "value": "0555539152" },"sender": "YourBrand","channel": "sms","message": "Your verification code is {code}. Valid for {amount} {duration}.","code": { "length": 6, "type": "NUMERIC" },"expiry": { "amount": 10, "duration": "minutes" },"validation": { "maxRetries": 3 },"metadata": { "userId": "usr_12345", "action": "login" },"security": { "ipAddress": "192.168.1.1" }}
channel is optional (defaults to "sms") and can also be an array for fallback — see Channel & Fallback below.
Channel & Fallback
channel picks how the code is delivered. Omit it and you get SMS, exactly like today. Pass a single value to pin one channel. Pass an arrayand Sendexa tries each channel in order, immediately, until one send succeeds — useful for "prefer WhatsApp, but fall back to SMS if this business/number can't use WhatsApp." Only the channel that actually sends is billed; the others show up as failed attempts in the response.
{"recipient": { "value": "0555539152" },"sender": "YourBrand","channel": "whatsapp"}
One code, any winning channel
/v1/otp/verifydoesn't need to know which channel delivered it.PIN Type Comparison
| Type | Length | Security | Use Case | Example |
|---|---|---|---|---|
NUMERIC | 4-10 digits | Medium | General purpose, easiest to enter | 123456 |
ALPHANUMERIC | 4-10 chars | High | Financial transactions, high-security | A7B9X2K4 |
ALPHABETIC | 4-10 letters | Low-Medium | Voice-based verification | ABCDEF |
Expiry Configuration
Range: 1-1440 minutes
Quick verifications
Range: 1-1440 hours
Extended sessions (pass duration: "hours")
Message Template Variables
{code}Generated OTP Code
Required placeholder - will be replaced with actual PIN
{amount}Expiry Amount
Numeric value from expiry.amount
{duration}Expiry Duration
"minutes" or "hours" from expiry.duration
Rate Limits & Security
3 requests
Counts requests + resends together
30 seconds
Applies to /resend, not the first request
1-10 attempts
Configurable, defaults to 3
Response
{"success": true,"message": "OTP sent successfully","data": {"id": "otp_123456789_abc","phone": "233555539152","recipient": { "type": "phone", "value": "233555539152" },"channel": "SMS","pinLength": 6,"pinType": "NUMERIC","expiry": {"amount": 10,"duration": "minutes","expiresAt": "2024-01-15T10:40:00.000Z"},"maxValidationAttempts": 3,"createdAt": "2024-01-15T10:30:00.000Z","metadata": {"userId": "usr_12345"}}}
data.channelAttempts only appears when you passed a fallback array — e.g. [{ "channel": "WHATSAPP", "success": false, "error": "WHATSAPP_NOT_CONFIGURED" }, { "channel": "SMS", "success": true }].
Try It Yourself
https://api.sendexa.co/v1/otp/requestImplementation Examples
// Complete OTP request flow with error handlingclass OTPManager {constructor(apiKey, apiSecret) {this.auth = 'Basic ' + btoa(apiKey + ':' + apiSecret);this.baseUrl = 'https://api.sendexa.co/v1';}async requestOTP(phone, options = {}) {const {from = 'YourBrand',message = 'Your verification code is {code}',pinLength = 6,pinType = 'NUMERIC',expiry = { amount: 10, duration: 'minutes' },maxAttempts = 3,metadata = {}} = options;try {const response = await fetch(`${this.baseUrl}/otp/request`, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': this.auth},body: JSON.stringify({phone: this.formatPhone(phone),from,message,pinLength,pinType,expiry,maxAmountOfValidationRetries: maxAttempts,metadata})});const data = await response.json();if (!response.ok) {// data.error is a flat string code, e.g. "RATE_LIMIT_EXCEEDED" — no nested details object.throw new OTPError(data.message, data.error);}return {success: true,otpId: data.data.id,expiresAt: data.data.expiry.expiresAt,...data.data};} catch (error) {if (error.code === 'RATE_LIMIT_EXCEEDED' || error.code === 'IP_RATE_LIMIT_EXCEEDED') {// Fixed rolling-hour window — no retryAfter/resetAt is returned, so// just surface the message and let the caller retry later.return {success: false,rateLimited: true,message: error.message};}// Note: there's no "active OTP already exists" error — a new request// silently invalidates any still-pending OTP for that phone.throw error;}}formatPhone(phone) {// Remove any non-digitsconst cleaned = phone.replace(/D/g, '');// Convert to international format if neededif (cleaned.startsWith('0')) {return '233' + cleaned.substring(1);}return cleaned;}}class OTPError extends Error {constructor(message, code) {super(message);this.code = code;}}// Usage with retry logicasync function initiateLogin(phone) {const otpManager = new OTPManager('api_key', 'api_secret');const result = await otpManager.requestOTP(phone, {from: 'MyApp',pinLength: 6,expiry: { amount: 5, duration: 'minutes' },metadata: { action: 'login' }});if (result.success) {// Store OTP ID for verificationsessionStorage.setItem('otpId', result.otpId);sessionStorage.setItem('expiresAt', result.expiresAt);// Start countdown timerstartOTPTimer(result.expiresAt);return { success: true };} else if (result.rateLimited) {return {success: false,message: result.message};}}
Security Best Practices
Rate Limiting
Implement exponential backoff and never allow more than 3 requests per hour
Short Expiry
Use 5-10 minute expiry for most use cases. Shorter is more secure.
Attempt Limiting
Set max attempts to 3-5 to prevent brute force attacks
Metadata Tracking
Store userId, IP, and device info in metadata for audit trails
Error Handling Guide
Phone Rate Limit Hit
3 requests/hour per phone number, no retryAfter is returned — surface the message and let the user retry later.
IP Abuse Signal
The same IP requested OTPs for 10+ distinct phone numbers in the last hour. Only triggers if you pass ipAddress in the request.
WhatsApp channel unavailable
Returned by /request/whatsapp when the business has no active WABA + approved authentication template. Fall back to SMS/Voice/Email.