V1
API v1•App Verification (OTP)

App Verification (OTP)

The Verify API (/api/v1/verify) is a turnkey One-Time Password (OTP) service that generates, hashes, delivers, rate-limits, and validates verification codes without requiring you to manage OTP state or register a template first.

How Verification Works

  1. Request an OTP: Call POST /api/v1/verify/send with the user's phone number. Emess generates a cryptographically random numeric code, stores only its HMAC-SHA256 hash, and dispatches the SMS over the critical priority queue.
  2. Verify the Code: When your user submits the code they received, call POST /api/v1/verify/check with code and either verificationId or number.
  3. Single-Use & Brute-Force Protected: Each verification allows up to 5 attempts before locking (MAX_ATTEMPTS) and is immediately consumed once APPROVED.

POST/api/v1/verify/send

Generates a new OTP, cancels any previous PENDING verification for the same phone number, and dispatches the SMS on the critical priority tier.

Parameters (Body)

NameTypeRequiredDescription
numberstringYesRecipient phone number (E.164 or local format, e.g. +243812345678).
brandstringNoBrand name displayed in the SMS body (max 32 chars). Defaults to your application's name.
lengthintegerNoNumber of digits in the generated OTP (4 to 8). Default: 6.
ttlSecondsintegerNoCode validity window in seconds (60 to 900). Default: 300 (5 minutes).
langstringNoLanguage code: "fr" (default), "en", or DR Congo national languages: "ln" (Lingala), "sw" (Swahili), "kg" (Kikongo), "lu" (Tshiluba). Automatically selects the pre-approved public verification template for that language.

Pre-Approved Public Verification Templates

Verification SMS messages are delivered on the critical priority tier (4.0 token multiplier). Custom templates are not accepted on the verify endpoint; instead, the system automatically uses the official public template matching your lang parameter:

  • French (fr, slug: verify-otp-fr): "Votre code de vérification {brand} est {code}. Valide pendant {minutes} min."
  • English (en, slug: verify-otp-en): "Your {brand} verification code is {code}. Valid for {minutes} min."
  • Lingala (ln, slug: verify-otp-ln): "Kodi ya bopimi ya {brand} ezali {code}. Ekozala na ntina miniti {minutes}."
  • Swahili (sw, slug: verify-otp-sw): "Nambari yako ya uthibitisho ya {brand} ni {code}. Ni halali kwa dakika {minutes}."
  • Kikongo (kg, slug: verify-otp-kg): "Kodi na nge ya lukwikilu {brand} kele {code}. Yau kele na mfunu miniti {minutes}."
  • Tshiluba (lu / lua, slug: verify-otp-lu): "Kodi webe wa dishindika {brand} udi {code}. Udi ne mushinga minite {minutes}."

Example Request & Response (200 OK)

POST /api/v1/verify/send
// Request: POST /api/v1/verify/send
{
"number": "+243812345678",
"brand": "AcmePay",
"length": 6,
"ttlSeconds": 300,
"lang": "fr"
}
// Response (200 OK) — raw OTP code is never returned
{
"verificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e",
"to": "+243812345678",
"status": "PENDING",
"expiresAt": "2026-10-25T12:15:00.000Z",
"ttlSeconds": 300
}

POST/api/v1/verify/check

Validates a user-supplied OTP code using timing-safe comparison (crypto.timingSafeEqual). You can identify the verification session by either verificationId or number.

Parameters (Body)

NameTypeRequiredDescription
codestringYesThe OTP code entered by the user.
verificationIdstringConditionalUUID returned by /verify/send. Required if number is omitted.
numberstringConditionalPhone number to check against the latest pending verification. Required if verificationId is omitted.

Responses

POST /api/v1/verify/check
// 200 OK — Valid Code (Single-use: status transitions to APPROVED)
{
"valid": true,
"status": "APPROVED",
"verificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e",
"verifiedAt": "2026-10-25T12:11:20.000Z"
}
// 422 Unprocessable Entity — Wrong Code (Attempts < 5)
{
"valid": false,
"status": "INVALID_CODE",
"remainingAttempts": 4
}
// 422 Unprocessable Entity — Locked Out After 5 Wrong Attempts
{
"valid": false,
"status": "MAX_ATTEMPTS",
"remainingAttempts": 0
}
// 422 Unprocessable Entity — Expired, Not Found, or Already Consumed
{
"valid": false,
"status": "EXPIRED"
}

GET/api/v1/verify/:id

Retrieves the current status and remaining attempt count of a verification session belonging to your application. If a PENDING verification has passed its expiresAt timestamp, it is reported as EXPIRED. The hashed OTP code (codeHash) is never exposed.

GET /api/v1/verify/:id
// Response (200 OK)
{
"verificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e",
"to": "0812345678",
"status": "PENDING",
"attempts": 1,
"remainingAttempts": 4,
"expiresAt": "2026-10-25T12:15:00.000Z",
"verifiedAt": null,
"createdAt": "2026-10-25T12:10:00.000Z"
}

POST/api/v1/verify/:id/retry

Retry an OTP verification by invalidating the previous verification (CANCELED) and generating and dispatching a new OTP code to the same recipient over the critical queue. If the previous verification's SMS failed delivery (FAILED, EXPIRED, or NOT_SENT), the 60-second cooldown is automatically bypassed so legitimate users are not locked out. If the verification was already APPROVED, Emess does not send a new code and replies with "retried": false.

Response (200 OK - Retried)
{
"message": "Verification retried with a new code",
"verificationId": "d1f8a9c4-3e2b-4a8d-9b01-7c6e5d4f3a2b",
"previousVerificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e",
"to": "0812345678",
"status": "PENDING",
"expiresAt": "2026-10-25T12:20:00.000Z",
"ttlSeconds": 300,
"retried": true
}
Response (200 OK - Already Approved)
{
"message": "Verification has already been approved",
"verificationId": "b8c4e1a2-7b2f-4d9a-9f12-3e4a5b6c7d8e",
"to": "0812345678",
"status": "APPROVED",
"verifiedAt": "2026-10-25T12:11:10.000Z",
"retried": false
}

Rate Limits & Security

To protect your organization's token balance from SMS pumping and brute-force abuse, POST /api/v1/verify/send enforces per-number rate limits scoped to [appId, phone]:

  • 60-Second Resend Cooldown: Rejects with 429 Too Many Requests (and returns retryAfterSeconds) if a code was already requested for the same phone number within the last 60 seconds.
  • Sliding 15-Minute Cap: Rejects with 429 Too Many Requests if the phone number has already requested 5 codes within the last 15 minutes.
  • Zero Plaintext Storage: OTP codes are hashed immediately with HMAC-SHA256 before persistence and compared in constant time.

Webhooks & Automatic Refunds

  • Automatic Failure Refunds: If the underlying SMS fails to send (sms:failed), Emess automatically refunds the deducted tokens back to your organization's active token bucket.
  • Webhook Events: Active webhook endpoints subscribed to verify:approved or verify:expired receive signed event payloads whenever a verification is approved or expires.