Send and Verify OTP in Sandbox
Use AfriRoute Verify to add phone verification without building OTP storage, retry controls, fraud rules, or SMS/voice fallback yourself.
This tutorial intentionally keeps OTP infrastructure inside AfriRoute. Your application starts a verification, asks the user for the code, then checks the code and creates its own authenticated session only after AfriRoute returns approved.
What You'll Build
- Start a managed verification challenge for an E.164 phone number.
- Let AfriRoute deliver the code over SMS, with optional voice fallback.
- Check the user-submitted code.
- Create your own application session after successful verification.
- Handle common errors without exposing OTP internals.
Estimated time: 10 minutes
Prerequisites
- AfriRoute account: Sign up
- API key with verification permissions
- A backend route in your own app
- Phone numbers in E.164 format, for example
+251912345678
Verification Flow
User enters phone number
-> your backend calls AfriRoute Verify start
-> AfriRoute sends the code and enforces delivery/risk controls
-> user enters the code
-> your backend calls AfriRoute Verify check
-> your app creates its own session if status is approved
Step 1: Start a Verification
Call AfriRoute from your backend. Do not call verification endpoints directly from browser code with your secret API key.
import Afriroute from "@afriroute/sdk";
const afriroute = new Afriroute({
apiKey: process.env.AFRIROUTE_API_KEY,
});
export async function startPhoneVerification(phone) {
assertE164(phone);
return afriroute.verify.start({
phone,
channel: "sms",
fallbackChannel: "voice",
locale: "en",
});
}
function assertE164(phone) {
if (!/^\+[1-9]\d{7,14}$/.test(phone)) {
throw new Error("Phone number must be in E.164 format.");
}
}
Example response:
{
"verificationId": "ver_01JQ7Z9G6PMV8G1E0F8N6T3Q5K",
"status": "pending",
"expiresAt": "2026-06-01T18:25:00Z",
"channel": "sms"
}
Store only verificationId, the normalized phone number, and the status in your application. Never store or log the OTP code.
Step 2: Check the Code
export async function checkPhoneVerification({ verificationId, code }) {
if (!/^\d{6}$/.test(code)) {
throw new Error("Verification code must be six digits.");
}
const result = await afriroute.verify.check({
verificationId,
code,
});
if (result.status !== "approved") {
return {
ok: false,
reason: result.status,
};
}
// Create your app session here using your normal auth system:
// - OIDC/JWT
// - server-side session cookie
// - your identity provider
return {
ok: true,
phone: result.phone,
};
}
Example approved response:
{
"verificationId": "ver_01JQ7Z9G6PMV8G1E0F8N6T3Q5K",
"status": "approved",
"phone": "+251912345678",
"approvedAt": "2026-06-01T18:22:10Z"
}
Frontend Example
The frontend should only talk to your backend. It should never hold an AfriRoute API key.
async function startVerification(phone) {
const response = await fetch("/api/verify/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
if (!response.ok) throw new Error("Could not start verification.");
return response.json();
}
async function checkVerification(verificationId, code) {
const response = await fetch("/api/verify/check", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ verificationId, code }),
});
if (!response.ok) throw new Error("Verification failed.");
return response.json();
}
What AfriRoute Handles
AfriRoute Verify is designed to keep the unsafe parts out of your application code:
- OTP generation and expiration
- SMS delivery and optional voice fallback
- Code attempt limits
- Request throttling and abuse controls
- Delivery provider selection
- Verification audit events
- Normalized verification status
What Your App Still Handles
- E.164 phone-number validation before calling AfriRoute
- CAPTCHA or bot protection before high-volume start requests
- Your own authenticated user/session after approval
- Account linking rules, for example whether one phone can be reused
- Audit logs that do not include OTP values
Do Not Build OTP Storage Yourself
Avoid these patterns in production documentation and applications:
new Map()/{}for OTP storage- plaintext OTP database columns
- logging OTP values
- random-number APIs that are not cryptographically secure
- fake session tokens such as hashes of phone numbers and timestamps
- per-process rate limits that disappear on restart
If you cannot use AfriRoute Verify and must build a low-level verifier, use Redis or a database, hash OTPs, enforce distributed rate limits, validate E.164 phone numbers, add bot protection, and issue sessions through your real auth system. That is an advanced architecture topic, not the recommended quickstart path.
Error Handling
| Error | Meaning | What to do |
|---|---|---|
PHONE_INVALID | Phone is not E.164 or is not routable | Ask the user to correct the number |
VERIFY_RATE_LIMITED | Too many verification starts | Show a retry time and do not immediately retry |
VERIFY_EXPIRED | Code expired | Start a new verification |
VERIFY_CODE_INVALID | Code does not match | Let the user retry until attempts are exhausted |
VERIFY_MAX_ATTEMPTS | Too many failed checks | Start over after the cooldown |
Production Checklist
- Use AfriRoute Verify instead of custom OTP infrastructure.
- Keep API keys on the server.
- Validate phone numbers in E.164 format.
- Add CAPTCHA or equivalent bot protection before start requests.
- Never log OTP values.
- Create your app session only after
status: "approved". - Subscribe to verification webhooks where auditability matters.
- Monitor rate-limit and fraud signals.