Verify OTP
Securely verify one-time passwords with comprehensive attempt tracking, expiry validation, and automatic code invalidation. Built with security best practices to prevent brute force attacks.
Attempt Tracking
Automatic tracking of verification attempts with configurable limits
Expiry Validation
Codes automatically expire and cannot be verified after expiry
Auto-Invalidation
Successful verification immediately invalidates the code
Hashed Storage
Codes are hashed at rest and compared against the submitted code
Security Features
Codes are SHA-256 hashed at rest — the plaintext code is only ever readable long enough to send it, then re-encrypted for USSD fallback display. Failed attempts are rate-limited per OTP (configurable, default 3); after the limit, that OTP is permanently blocked and a new one must be requested.
Authentication Required
Security Features
- Maximum attempts enforced per OTP (configurable, default: 3)
- Codes automatically invalidated after successful verification
- Expiry validation prevents use of stale codes
- Codes are hashed at rest; verification hashes the submitted code and compares against the stored hash
Sandbox Codes
1555000####) or email (@sandbox.sendexa.test) always verify with the fixed code 000000 — no real send, no charge.3
Configurable up to 10
4-10
Chars, any PIN type
Success only
No charge on a failed attempt
90 days
Code cleared ~1h after it dies
Request Body
{"phone": "0555539152","code": "123456","id": "otp_123456789_abc"}
OTP Lifecycle
Requested
T+0OTP generated and sent
Pending
T+0 to T+expiryWaiting for verification
Attempt 1
T+30sFirst verification attempt
Attempt 2
T+45sSecond attempt if needed
Verified/Success
T+50sCorrect code entered
OTP States
OTP created, waiting for verification
Successfully verified, code invalidated
Time window passed, cannot verify
Max attempts hit or superseded by a newer request
Attempt Handling
Allow retry
INVALID_OTP with data.attemptsRemaining
Lock OTP
MAX_ATTEMPTS_EXCEEDED — request a new code
Response
{"success": true,"message": "OTP verified successfully","data": {"verified": true,"phone": "233555539152","verifiedAt": "2024-01-15T10:30:45.000Z","id": "otp_123456789_abc","pinLength": 6,"pinType": "NUMERIC","metadata": {"userId": "usr_12345","action": "login"}}}
Try It Yourself
https://api.sendexa.co/v1/otp/verifyImplementation Examples
// Secure OTP verification with attempt trackingclass OTPVerifier {constructor(apiKey, apiSecret) {this.auth = 'Basic ' + btoa(apiKey + ':' + apiSecret);this.baseUrl = 'https://api.sendexa.co/v1';this.attempts = new Map(); // Track attempts per OTP}// phone is always required by the API; id narrows to one specific OTP.async verifyOTP(phone, code, id) {const requestBody = { phone, code, id };try {const response = await fetch(`${this.baseUrl}/otp/verify`, {method: 'POST',headers: {'Content-Type': 'application/json','Authorization': this.auth},body: JSON.stringify(requestBody)});const data = await response.json();if (response.ok) {// Clear attempt tracking on successthis.clearAttempts(phone);return {success: true,verified: true,data: data.data};}// data.error is a flat string code — no nested details object.switch (data.error) {case 'INVALID_OTP': {const remaining = data.data?.attemptsRemaining;this.trackAttempt(phone, remaining);return {success: false,invalid: true,remainingAttempts: remaining,message: remaining === 1? 'Invalid code. One attempt remaining!': `Invalid code. ${remaining} attempts remaining.`};}case 'MAX_ATTEMPTS_EXCEEDED':// 403 — this OTP is now permanently blocked.this.lockOTP(phone);return {success: false,locked: true,message: 'Too many failed attempts. Please request a new code.'};case 'OTP_EXPIRED':return {success: false,expired: true,message: 'This code has expired. Please request a new one.'};default:return {success: false,error: data.error,message: data.message};}} catch (error) {return {success: false,error: 'NETWORK_ERROR',message: 'Verification failed. Please try again.'};}}trackAttempt(phone, remaining) {this.attempts.set(phone, {remaining,lastAttempt: new Date()});}clearAttempts(phone) {this.attempts.delete(phone);}lockOTP(phone) {this.attempts.set(phone, {locked: true,lastAttempt: new Date()});}getAttemptStatus(phone) {return this.attempts.get(phone) || null;}}// Usage with React componentfunction OTPInput({ phone, onSuccess, onError }) {const [code, setCode] = useState('');const [verifying, setVerifying] = useState(false);const [remainingAttempts, setRemainingAttempts] = useState(null);const [error, setError] = useState('');const verifier = useRef(new OTPVerifier('api_key', 'api_secret'));const handleVerify = async () => {if (code.length < 4) return;setVerifying(true);setError('');const result = await verifier.current.verifyOTP(phone, code);setVerifying(false);if (result.success) {setCode('');onSuccess?.(result.data);} else {if (result.remainingAttempts !== undefined) {setRemainingAttempts(result.remainingAttempts);setError(result.message);} else if (result.locked || result.expired) {setError(result.message);onError?.(result);} else {setError(result.message);}}};return (<div className="space-y-4"><div><inputtype="text"value={code}onChange={(e) => setCode(e.target.value.replace(/D/g, ''))}placeholder="Enter code"className="w-full p-2 border rounded"disabled={verifying}maxLength={6}autoFocus/>{remainingAttempts !== null && (<p className="text-sm text-yellow-600 mt-1">{remainingAttempts} attempt{remainingAttempts !== 1 ? 's' : ''} remaining</p>)}{error && (<p className="text-sm text-red-600 mt-1">{error}</p>)}</div><buttononClick={handleVerify}disabled={code.length < 4 || verifying}className="w-full bg-blue-600 text-white p-2 rounded disabled:opacity-50">{verifying ? 'Verifying...' : 'Verify Code'}</button></div>);}
Security Implementation Checklist
Attempt Tracking Flow (default max: 3)
Correct code, any attempt
Verified immediately — 200, OTP moves to VERIFIED
Wrong code, attempt 1-2
400 INVALID_OTP with data.attemptsRemaining
Wrong code, attempt 3
403 MAX_ATTEMPTS_EXCEEDED — OTP is now BLOCKED, request a new one
Error Response Matrix
| Error Type | HTTP Status | Description | User Action |
|---|---|---|---|
| INVALID_OTP | 400 | Code doesn't match | Try again with correct code |
| MAX_ATTEMPTS_EXCEEDED | 403 | Too many failed attempts, OTP now BLOCKED | Request new OTP |
| OTP_EXPIRED | 400 | OTP time window passed | Request new OTP |
| OTP_ALREADY_VERIFIED | 400 | Code already used | Login with existing session |
| OTP_BLOCKED | 403 | Superseded by a newer request, or already locked | Request new OTP |
| OTP_NOT_FOUND | 404 | No pending OTP for identifier | Request new OTP |
Rate Limiting & Security
- Max attempts:1-10 (default 3)
- Lock duration:Permanent
/verifyisn't rate-limited by phone or IP on its own — it's bounded indirectly: a phone can only ever have one live PENDING OTP (a new /request invalidates the old one), and each OTP locks permanently after its max-attempts limit. The phone-based (3/hr) and IP-based rate limits documented on /request apply to issuing codes, not to checking them.
Webhook Events
Configure a webhook to receive OTP_VERIFIED (and OTP_SENT, OTP_DELIVERED, OTP_FAILED, OTP_EXPIRED) events. Every delivery is wrapped in a stable envelope so retried deliveries can be deduplicated by eventId.
{"eventId": "wd_123456789","event": "OTP_VERIFIED","businessId": "biz_abc123","timestamp": "2024-01-15T10:30:45.000Z","data": {"phone": "233555539152","verifiedAt": "2024-01-15T10:30:45.000Z","metadata": {"userId": "usr_12345","action": "login"}}}
Failed deliveries are retried with backoff (1m, 5m, 30m) if the webhook has retries enabled.
Monitoring
There's no built-in analytics dashboard for verification success/failure rates yet. In the meantime, GET /v1/otp/stats and GET /v1/otp/history (filterable by status, startDate/endDate) return the raw data needed to compute success rate, average attempts, and failure counts yourself.
Testing Your Integration
Sandbox Numbers
1555000####
Any number matching this pattern (e.g. 15550001234) always verifies with code 000000
*@sandbox.sendexa.test
Same behavior for Email-channel OTPs — always verifies with code 000000
There are no dedicated always-fails or always-expired test numbers — to test those paths, submit the wrong code against a real OTP, or wait past its expiresAt.
Troubleshooting Common Issues
Users reporting "Invalid Code"
- Check if code was auto-filled correctly (spaces, dashes)
- Verify code length matches pinLength configuration
- Ensure code hasn't expired (10-minute default window)
- Check if user already attempted too many times
High failure rates
- Check SMS delivery rates (codes not being received) — pull delivery status via
/otp/history - Verify phone number formatting is correct
- Consider increasing expiry time if users are slow
Integration issues
- Confirm you're storing OTP ID from request response
- Check that phone numbers are in international format
- Verify authentication headers are correct
- Test with sandbox credentials first
Data Handling
Retention
OTP records are kept for 90 days, then deleted. The plaintext code is cleared ~1 hour after an OTP dies (verified, expired, or blocked) — only the hash and metadata remain until the 90-day purge.
Choosing Length & Expiry for Sensitive Flows
Sendexa doesn't enforce a policy for payment/KYC-grade verification — that's a call for your own compliance requirements. A 6-digit code with a short expiry and a low max-attempts count is a reasonable default for high-value flows.
Frequently Asked Questions
What happens after max attempts?
Can I verify an expired OTP?
How are codes compared?
What's the difference between phone and id?
phone is always required. On its own, it targets the most recent pending OTP for that number. Adding id narrows it to that exact OTP — useful if a user could plausibly have more than one flow in progress.Quick Security Checklist
- Implement rate limiting
- Show remaining attempts
- Clear errors appropriately
- Log all attempts
- Use HTTPS only
- Invalidate after success
Need Help?
Our team is here to help you implement secure OTP verification:
- Email: support@sendexa.co (24/7 support)
- Documentation: /docs/verify-best-practices
- Discord: https://discord.gg/sendexa
- Status page: https://status.sendexa.co