DocumentationAPI v1 · 1.3.0

Guides

OTP verification

OTP as a service: send a code by SMS, verify it, anti-abuse limits, test_code and Web OTP.

The API generates the code, sends it by SMS (same cost as a normal SMS), stores it hashed and verifies it. Your server never sees the code in production.

Flow#

  1. Your server calls POST /otp/send with the number and keeps otp_id server-side (session).
  2. The user types the code received (code_length and code_type give its shape).
  3. Your server calls POST /otp/verify with otp_id and the code: verified: true, or a failure reason.

Length, alphabet, validity, attempts, resend delay, Sender ID and template come from the account OTP settings (client area > OTP verification). In the browser, the OTP widget provides a ready-made form.

Send a code#

FieldDescription
phoneNumber, any format. Required.
templateTemplate containing {code} (optional {app_name}, {minutes} tokens).
localefr or en: language of the standard template.
web_otp_domainHost name (e.g. app.example.com): adds the line @app.example.com #<code> for autofill (Web OTP API, Chrome Android).
strip_accentsGSM-7 message, lower cost.

Over HTTP

curl -X POST "https://api.labyrinthe-rdc.com/api/public/v1/otp/send" \
  -H "Authorization: Bearer lsk_test_xxxxxxxxxxxxxxxx" \
  -H "Idempotency-Key: login-7f3a9c" \
  -H "Content-Type: application/json" \
  -d '{"phone":"243812345678","locale":"fr"}'

With the packages

<?php
use Labyrinthe\Connect\Client;
use Labyrinthe\Connect\Exception\OtpResendTooSoonException;

$client = new Client('lsk_test_xxxxxxxxxxxxxxxx'); // or Client::fromEnv() (LABYRINTHE_API_KEY)

try {
    $otp = $client->otp->send('243812345678', locale: 'fr', idempotencyKey: 'login-'.session_id());
} catch (OtpResendTooSoonException $e) {
    exit('Wait '.$e->getRetryAfter().' s before asking for a new code.');
}

$_SESSION['otp_id'] = $otp->otpId;   // keep it server-side
$otp->codeLength;                     // 6
$otp->resendAfterSeconds;             // 60, for a "Resend" countdown
$otp->testCode();                     // '482913' with a lsk_test_ key, null in live
201 response (test key: with test_code)
{
  "data": {
    "otp_id": "9d5c0a3e-6f0b-4c2e-9a51-3b7f2d8e4c10",
    "expires_at": "2026-11-06T09:49:11+00:00",
    "max_attempts": 5,
    "code_length": 6,
    "code_type": "numeric",
    "resend_after_seconds": 60,
    "test_code": "482913"
  }
}

Each success sends a billed SMS. Never retry this call automatically without Idempotency-Key. A new send to the same number invalidates the previous code (its verification returns expired).

Verify the code#

A wrong code is not an HTTP error: the response is 200 with verified: false and a reason. Each wrong attempt uses one try (attempts_remaining). Input is normalized (spaces removed, case-insensitive). Once concluded, the verification is frozen. Verifying is free.

Over HTTP

curl -X POST "https://api.labyrinthe-rdc.com/api/public/v1/otp/verify" \
  -H "Authorization: Bearer lsk_test_xxxxxxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{"otp_id":"9d5c0a3e-6f0b-4c2e-9a51-3b7f2d8e4c10","code":"482913"}'

With the packages

<?php
use Labyrinthe\Connect\Client;

$client = new Client('lsk_test_xxxxxxxxxxxxxxxx'); // or Client::fromEnv() (LABYRINTHE_API_KEY)

$result = $client->otp->verify($_SESSION['otp_id'], $_POST['code']);

if ($result->verified) {
    unset($_SESSION['otp_id']); // phone verified: log the user in
} elseif ($result->reason?->value === 'invalid_code') {
    echo 'Wrong code, ', $result->attemptsRemaining, ' attempt(s) left.';
} else {
    echo 'Code expired or blocked: ask for a new one.'; // expired, max_attempts_reached, already_used
}
200 responses
// Correct code
{ "data": { "verified": true, "reason": null, "attempts_remaining": null } }

// Wrong code: HTTP 200, one attempt used
{ "data": { "verified": false, "reason": "invalid_code", "attempts_remaining": 4 } }

// Blocked or expired
{ "data": { "verified": false, "reason": "max_attempts_reached", "attempts_remaining": 0 } }
{ "data": { "verified": false, "reason": "expired", "attempts_remaining": null } }
reasonMeaningWhat to do
invalid_codeWrong code, attempts left.Ask for the code again.
max_attempts_reachedNo attempts left, code blocked.Offer a new send.
expiredCode expired, or invalidated by a new send.Offer a new send.
already_usedCode already verified.Restart the flow.

Anti-abuse limits#

  • 10 requests / minute per API key on POST /otp/send (429 TOO_MANY_REQUESTS).
  • Minimum delay between two sends to the same number while the last code is not verified (429 OTP_RESEND_TOO_SOON); show a countdown with resend_after_seconds.
  • Cap over a sliding hour, per number and per account (429 OTP_RATE_LIMITED, details.scope, details.limit).

Both business errors carry the Retry-After header, equal to details.retry_after_seconds.

OTP service under maintenance#

If OTP sending is off: 503 FEATURE_DISABLED (api.otp_send) or 503 OTP_SERVICE_DISABLED (the whole OTP service). Codes already sent can still be verified, unless api.otp_verify is off. See Errors.

OTP webhooks#

The outcome of each verification is notified once: otp.verified (correct code) or otp.failed (attempts exhausted or expiry seen). Never the code. See Webhooks.