Sendexa LogoDocs

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

Secure Matching

Constant-time comparison to prevent timing attacks

Security Features

All verifications use constant-time comparison to prevent timing attacks. Codes are hashed and salted in storage. Failed attempts are rate-limited per OTP and per IP address.

Default Max Attempts

3

Configurable up to 10

Verification Timeout

500ms

95th percentile

Success Rate

94%

First attempt

Retention

24h

Verification records

POST
/v1/otp/verify
Secure

Request Body

application/json
JSON
{
"phone": "0555539152",
"code": "123456",
"id": "otp_123456789_abc"
}

OTP Lifecycle

Requested

T+0

OTP generated and sent

Pending

T+0 to T+expiry

Waiting for verification

Attempt 1

T+30s

First verification attempt

Attempt 2

T+45s

Second attempt if needed

Verified/Success

T+50s

Correct code entered

OTP States

pending

OTP created, waiting for verification

verified

Successfully verified, code invalidated

expired

Time window passed, cannot verify

max_attempts

Too many failed attempts, locked

Attempt Handling

-1

Allow retry

Show error: Invalid code

3

Last attempt warning

Show: One attempt remaining

4

Lock OTP

Show: Too many attempts, request new code

Response

JSON
{
"success": true,
"message": "OTP verified successfully",
"data": {
"verified": true,
"phone": "233555539152",
"verifiedAt": "2024-01-15T10:30:45.000Z",
"id": "otp_123456789_abc",
"attemptsUsed": 1,
"metadata": {
"userId": "usr_12345",
"action": "login"
}
}
}

Try It Yourself

POST
https://api.sendexa.co/v1/otp/verify

Implementation Examples

JavaScript
// Secure OTP verification with attempt tracking
class 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
}
async verifyOTP(identifier, code, security = {}) {
const requestBody = {
code,
...this.parseIdentifier(identifier),
security: {
timestamp: new Date().toISOString(),
...security
}
};
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 success
this.clearAttempts(identifier);
return {
success: true,
verified: true,
data: data.data
};
}
// Handle specific error types
switch (data.errorType) {
case 'INVALID_CODE':
const remaining = data.data.attemptsRemaining;
this.trackAttempt(identifier, 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':
this.lockOTP(identifier);
return {
success: false,
locked: true,
message: 'Too many failed attempts. Please request a new code.'
};
case 'EXPIRED':
return {
success: false,
expired: true,
message: 'This code has expired. Please request a new one.'
};
default:
return {
success: false,
error: data.errorType,
message: data.message
};
}
} catch (error) {
return {
success: false,
error: 'NETWORK_ERROR',
message: 'Verification failed. Please try again.'
};
}
}
parseIdentifier(identifier) {
if (typeof identifier === 'string') {
if (identifier.startsWith('otp_')) {
return { id: identifier };
} else {
return { phone: identifier };
}
}
return identifier;
}
trackAttempt(identifier, remaining) {
const key = typeof identifier === 'string' ? identifier : identifier.phone;
this.attempts.set(key, {
remaining,
lastAttempt: new Date()
});
}
clearAttempts(identifier) {
const key = typeof identifier === 'string' ? identifier : identifier.phone;
this.attempts.delete(key);
}
lockOTP(identifier) {
const key = typeof identifier === 'string' ? identifier : identifier.phone;
this.attempts.set(key, {
locked: true,
lastAttempt: new Date()
});
}
getAttemptStatus(identifier) {
const key = typeof identifier === 'string' ? identifier : identifier.phone;
return this.attempts.get(key) || null;
}
}
// Usage with React component
function 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, {
ipAddress: await getClientIP(),
userAgent: navigator.userAgent
});
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>
<input
type="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>
<button
onClick={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

Rate limiting per IP
Attempt tracking per OTP
Automatic code expiry
Post-verification invalidation
HTTPS enforcement
Audit logging of all attempts
Constant-time comparison

Attempt Tracking Flow

Attempt 1: Success

Code verified immediately - user redirected to app

Attempt 1-2: Invalid Code

Show error with remaining attempts count

Attempt 3: Last Warning

Show "One attempt remaining" warning

Attempt 4: Locked

OTP invalidated, user must request new code

Error Response Matrix

Error TypeHTTP StatusDescriptionUser Action
INVALID_CODE400Code doesn't matchTry again with correct code
MAX_ATTEMPTS_EXCEEDED400Too many failed attemptsRequest new OTP
EXPIRED400OTP time window passedRequest new OTP
ALREADY_VERIFIED400Code already usedLogin with existing session
NOT_FOUND404No pending OTP for identifierRequest new OTP

Rate Limiting & Security

Per OTP
  • Max attempts:
    3-10 (configurable)
  • Lock duration:
    Permanent
Per Phone
  • Verifications/hour:
    20
  • Failed attempts/hour:
    15
Per IP
  • Verifications/hour:
    50
  • Failed attempts/hour:
    30

Webhook Events

Configure webhooks to receive real-time notifications when OTPs are verified.

JSON
{
"event": "otp.verified",
"timestamp": "2024-01-15T10:30:45.000Z",
"data": {
"id": "otp_123456789_abc",
"phone": "233555539152",
"verifiedAt": "2024-01-15T10:30:45.000Z",
"attemptsUsed": 1,
"metadata": {
"userId": "usr_12345",
"action": "login"
}
},
"signature": "sha256=7d5e3f8a2b1c9d4e6f8a0b2c4d6e8f0a2b4c6d8e"
}

Analytics & Monitoring

Key Metrics to Track

  • Success Rate94.2%
  • Average Attempts1.3
  • Peak Verification Time2:30 PM
  • Failed Attempt Rate5.8%

Alert Thresholds

  • High failure rate
    >15%
  • Brute force attempt
    >50 fails/IP
  • Verification spike
    >1000/hour

Testing Your Integration

0550000001

Always succeeds with code 123456

0550000002

Always fails with INVALID_CODE

0550000003

Always returns EXPIRED

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 (5-10 minute window)
  • Check if user already attempted too many times

High failure rates

  • Monitor for brute force attacks by IP
  • Check SMS delivery rates (codes not being received)
  • 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

Compliance & Regulations

GDPR Compliance

Verification attempts are stored for 30 days for security auditing. Users have the right to request deletion of their verification history.

PCI-DSS Requirements

OTP verification for payment transactions must use 6-digit codes with 5-minute expiry and maximum 3 attempts.

Authentication Standards

Meets NIST SP 800-63B requirements for out-of-band verification when used with appropriate session management.

Frequently Asked Questions

What happens after max attempts?
The OTP is permanently invalidated and cannot be used again. The user must request a new OTP. This prevents brute force attacks.
Can I verify an expired OTP?
No, expired OTPs cannot be verified for security reasons. The user must request a new code. The expiry time is configurable from 1 minute to 24 hours.
How are codes compared?
All comparisons use constant-time algorithms to prevent timing attacks. The actual codes are hashed and salted in storage.
What's the difference between phone and id?
Using phone number will verify the most recent pending OTP for that number. Using the specific OTP ID ensures you're verifying the exact OTP you requested.

Quick Security Checklist

  • Implement rate limiting
  • Show remaining attempts
  • Clear errors appropriately
  • Log all attempts
  • Use HTTPS only
  • Invalidate after success