Verify (OTP)
Managed one-time-passcode verification - start, check, fetch, resend, cancel and list, with Verify Apps.
Overview
The Verify API runs the whole one-time-passcode flow for you: eSMS generates the code, sends it, stores it hashed, expires it, and validates the user's input. You never store or compare codes yourself. Codes are rate-limited per phone and have a capped number of check attempts.
Reliability guarantees
- You are never charged for a code that didn't send. If the gateway rejects
the OTP,
start/resendreturn502 otp_send_failedand any charge is automatically refunded - you never get a silent "pending" you can't complete. - Test keys work end-to-end.
esms_test_keys don't send a real SMS; thestartresponse returns asandbox_code, and the fixed code000000always passescheck. No charge, isolated from live data. - Retries are safe. Send an
Idempotency-Keyheader and a repeatedstartreturns the original verification instead of sending a second code.
Authentication
Authorization: Bearer esms_live_your_api_keyEvery endpoint is available under both /api/... (legacy) and /v1/...
(versioned) - e.g. POST /v1/verify/start. New integrations should pin /v1.
Verify Apps
A Verify App bundles everything a verification flow needs - your brand/sender,
the message template, the allowed countries and the OTP policy (code length,
expiry, max attempts). Create one in the dashboard (Verify → New app) or via
the API, then pass its app_id to /verify/start and those settings apply
automatically. Verifications are tracked per app so you get success-rate stats.
GET /api/verify/apps # list your apps
POST /api/verify/apps # create
GET /api/verify/apps/{id} # fetch one
PATCH /api/verify/apps/{id} # update (full replace - send all fields)
DELETE /api/verify/apps/{id} # delete
GET /api/verify/apps/{id}/stats # starts / success rate (?days=1-90, default 30)Create an app
{
"name": "Acme Login",
"sender_id": "Acme",
"template": "Your {app} code is {code}. It expires in {min} minutes.",
"code_length": 6,
"expiry_seconds": 300,
"max_attempts": 5,
"allowed_countries": ["KE", "UG", "NG"],
"locale": "en",
"enabled": true
}| Field | Type | Description |
|---|---|---|
name | string | App name (used for the {app} token). Required, ≤ 80 chars. |
sender_id | string | Brand shown as the SMS sender (≤ 11 chars). |
template | string | Message template. Must contain {code}. Tokens: {code}, {app}, {min}. |
code_length | int | 4-8 (default 6). |
expiry_seconds | int | 60-900 (default 300). |
max_attempts | int | 1-10 (default 5). |
allowed_countries | string[] | ISO-2 codes. Empty = all countries. A start to a country outside this list is rejected with country_not_allowed. |
locale | string | Language tag for the template (default en). |
enabled | bool | When false, start with this app returns 403 app_disabled. Default true. |
PATCH is a full replace, not a partial update - name is required and any
field you omit reverts to its default. Send the app's complete current settings
when updating.
The response is the app object, including the id (e.g. vera_ab12cd34ef56gh78ij90) -
that's the app_id you pass to /verify/start.
App stats response
{
"app_id": "vera_ab12...",
"days": 30,
"total_starts": 1204,
"approved": 1010,
"failed": 62,
"expired": 40,
"pending": 92,
"success_rate": 90.8
}success_rate = approved ÷ (approved + failed + expired) - pending verifications
are excluded from the denominator.
Start a verification
POST https://sms.esmsafrica.io/api/verify/startPass an app_id to use a Verify App's brand, template, countries and policy - or
send the inline fields directly.
{ "to": "+254712345678", "app_id": "vera_ab12cd34ef56gh78ij90" }Or without an app:
{
"to": "+254712345678",
"channel": "sms",
"code_length": 6,
"expiry_seconds": 300,
"sender_id": "MyApp",
"template": "Your code is {code}. Expires in {min} min."
}| Field | Type | Required | Description |
|---|---|---|---|
to | string | Yes | Recipient in international format. |
app_id | string | No | A Verify App id. When set, its template/sender/policy/country-allowlist apply and the inline fields below are ignored. |
channel | string | No | Only sms is available (default sms). |
code_length | integer | No | Digits in the code, 4-8 (default 6). |
expiry_seconds | integer | No | Lifetime of the code, 60-900 seconds (default 300). |
template | string | No | Custom message; must contain {code} or you get 400 template_missing_code. {min} → expiry in minutes, {app} → app name. Default: Your verification code is {code}. It expires in {min} minutes. |
sender_id | string | No | Sender ID; falls back to the route default. |
Header: Idempotency-Key: <your-unique-key> (optional) - a repeated start
with the same key within 1 hour returns the original verification (idempotent_replay: true)
without sending a second code.
Response (201)
{
"verification_id": "ver_9f8c1234ab...",
"app_id": "vera_ab12...",
"to": "+2547••••5678",
"channel": "sms",
"status": "pending",
"environment": "live",
"attempts": 0,
"max_attempts": 5,
"attempts_remaining": 5,
"sends": 1,
"expires_at": "2026-08-16T12:34:56+00:00",
"created_at": "2026-08-16T12:29:56+00:00",
"verified_at": null,
"cost": 0.0096,
"cost_currency": "USD"
}The to field is masked. Keep verification_id to check the code later. With a
test key the response also includes "sandbox_code": "482193" (see Test mode).
Errors
| Status | Code | Description |
|---|---|---|
400 | invalid_phone | to is not a valid number. |
400 | unsupported_channel | Only sms is supported. |
400 | template_missing_code | Inline template has no {code} placeholder. |
402 | insufficient_balance | Not enough balance to send the code. |
403 | recipient_opted_out | The recipient has opted out. |
403 | app_disabled | The Verify App is disabled. |
403 | country_not_allowed | Destination country is outside the app's allow-list. |
404 | app_not_found | app_id doesn't exist for your account. |
429 | too_many_requests | More than 5 starts for this number in the last hour. |
502 | otp_send_failed | The code couldn't be sent. You were not charged. Safe to retry. |
Check a code
POST https://sms.esmsafrica.io/api/verify/check{ "verification_id": "ver_9f8c...", "code": "482193" }| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | The code the user entered. |
verification_id | string | No* | The ID returned by start. |
to | string | No* | Phone number - checks the most recent pending verification for that number. |
* Provide verification_id or to (else 400 missing_selector). Unknown id → 404 not_found.
Response
{ "status": "approved", "verification_id": "ver_9f8c..." }status | Meaning |
|---|---|
approved | Code matched - the number is verified. |
pending | Wrong code, attempts remain (attempts_remaining included). |
failed | Too many wrong attempts (reason: "too_many_attempts"). |
expired | The code lifetime elapsed before a correct entry. |
canceled | The verification was canceled. |
Each wrong check consumes one attempt. To read status without consuming an
attempt, use Fetch a verification.
Fetch a verification
Read the current state without consuming an attempt.
GET https://sms.esmsafrica.io/api/verify/{verification_id}Returns the same object shape as the start response (minus cost). A pending
verification past its expiry is returned as expired.
Resend a code
Send a fresh code for the same verification (new code, reset expiry and attempt budget). Up to 4 sends total (original + 3 resends).
POST https://sms.esmsafrica.io/api/verify/{verification_id}/resendReturns the verification object (with sends incremented). Errors: 409 not_pending
(already approved/failed/canceled), 429 resend_limit, 502 otp_send_failed (refunded).
Cancel a verification
Void an in-flight verification (e.g. the user abandoned the flow).
POST https://sms.esmsafrica.io/api/verify/{verification_id}/cancel{ "status": "canceled", "verification_id": "ver_9f8c..." }List verifications
GET https://sms.esmsafrica.io/api/verify?status=pending&app_id=vera_ab12...&page=0&limit=20Query: status, app_id, to, page (default 0), limit (1-100, default 20).
Scoped to the key's environment (test vs live). Returns
{ "verifications": [...], "total", "page", "limit" }.
Test mode
With an esms_test_ API key:
- No real SMS is sent and there is no charge.
- The
start/resendresponse includes"sandbox_code"- the exact code to submit. - The fixed code
000000always passescheck(handy for automated tests). - Test verifications are isolated - a live key can't see or check them, and vice-versa.
Example
# 1. Start (idempotent)
curl -X POST https://sms.esmsafrica.io/api/verify/start \
-H "Authorization: Bearer esms_live_your_api_key" \
-H "Idempotency-Key: signup-9f8c-2026" \
-H "Content-Type: application/json" \
-d '{ "to": "+254712345678", "app_id": "vera_ab12..." }'
# 2. Check
curl -X POST https://sms.esmsafrica.io/api/verify/check \
-H "Authorization: Bearer esms_live_your_api_key" \
-H "Content-Type: application/json" \
-d '{ "verification_id": "ver_9f8c...", "code": "482193" }'
# 3. (optional) Resend if the user didn't get it
curl -X POST https://sms.esmsafrica.io/api/verify/ver_9f8c.../resend \
-H "Authorization: Bearer esms_live_your_api_key"You are charged the normal per-segment SMS price for each code that is actually
sent (cost in the response). A code that fails to send is refunded automatically,
and esms_test_ keys never incur a charge.