eSMS AfricaeSMS Africa
API ReferenceSMS API

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/resend return 502 otp_send_failed and 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; the start response returns a sandbox_code, and the fixed code 000000 always passes check. No charge, isolated from live data.
  • Retries are safe. Send an Idempotency-Key header and a repeated start returns the original verification instead of sending a second code.

Authentication

Authorization: Bearer esms_live_your_api_key

Every 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
}
FieldTypeDescription
namestringApp name (used for the {app} token). Required, ≤ 80 chars.
sender_idstringBrand shown as the SMS sender (≤ 11 chars).
templatestringMessage template. Must contain {code}. Tokens: {code}, {app}, {min}.
code_lengthint4-8 (default 6).
expiry_secondsint60-900 (default 300).
max_attemptsint1-10 (default 5).
allowed_countriesstring[]ISO-2 codes. Empty = all countries. A start to a country outside this list is rejected with country_not_allowed.
localestringLanguage tag for the template (default en).
enabledboolWhen 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/start

Pass 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."
}
FieldTypeRequiredDescription
tostringYesRecipient in international format.
app_idstringNoA Verify App id. When set, its template/sender/policy/country-allowlist apply and the inline fields below are ignored.
channelstringNoOnly sms is available (default sms).
code_lengthintegerNoDigits in the code, 4-8 (default 6).
expiry_secondsintegerNoLifetime of the code, 60-900 seconds (default 300).
templatestringNoCustom 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_idstringNoSender 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

StatusCodeDescription
400invalid_phoneto is not a valid number.
400unsupported_channelOnly sms is supported.
400template_missing_codeInline template has no {code} placeholder.
402insufficient_balanceNot enough balance to send the code.
403recipient_opted_outThe recipient has opted out.
403app_disabledThe Verify App is disabled.
403country_not_allowedDestination country is outside the app's allow-list.
404app_not_foundapp_id doesn't exist for your account.
429too_many_requestsMore than 5 starts for this number in the last hour.
502otp_send_failedThe 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" }
FieldTypeRequiredDescription
codestringYesThe code the user entered.
verification_idstringNo*The ID returned by start.
tostringNo*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..." }
statusMeaning
approvedCode matched - the number is verified.
pendingWrong code, attempts remain (attempts_remaining included).
failedToo many wrong attempts (reason: "too_many_attempts").
expiredThe code lifetime elapsed before a correct entry.
canceledThe 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}/resend

Returns 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=20

Query: 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/resend response includes "sandbox_code" - the exact code to submit.
  • The fixed code 000000 always passes check (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.

On this page