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.

Default Max Attempts

3

Configurable up to 10

Code Length

4-10

Chars, any PIN type

Fee on Verify

Success only

No charge on a failed attempt

Retention

90 days

Code cleared ~1h after it dies

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

BLOCKED

Max attempts hit or superseded by a newer request

Attempt Handling

1-2

Allow retry

INVALID_OTP with data.attemptsRemaining

3 (default max)

Lock OTP

MAX_ATTEMPTS_EXCEEDED — request a 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",
"pinLength": 6,
"pinType": "NUMERIC",
"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
}
// 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 success
this.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 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);
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
Per-OTP attempt counter
SHA-256 hashed codes

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 TypeHTTP StatusDescriptionUser Action
INVALID_OTP400Code doesn't matchTry again with correct code
MAX_ATTEMPTS_EXCEEDED403Too many failed attempts, OTP now BLOCKEDRequest new OTP
OTP_EXPIRED400OTP time window passedRequest new OTP
OTP_ALREADY_VERIFIED400Code already usedLogin with existing session
OTP_BLOCKED403Superseded by a newer request, or already lockedRequest new OTP
OTP_NOT_FOUND404No pending OTP for identifierRequest new OTP

Rate Limiting & Security

Per OTP
  • Max attempts:
    1-10 (default 3)
  • Lock duration:
    Permanent
No Separate Verify-Time Limit

/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.

JSON
{
"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

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?
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?
Your submitted code is SHA-256 hashed and compared against the stored hash — the plaintext code itself is never kept around, aside from a short-lived encrypted copy used for USSD fallback display.
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