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#
- Your server calls
POST /otp/sendwith the number and keepsotp_idserver-side (session). - The user types the code received (
code_lengthandcode_typegive its shape). - Your server calls
POST /otp/verifywithotp_idand 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#
| Field | Description |
|---|---|
phone | Number, any format. Required. |
template | Template containing {code} (optional {app_name}, {minutes} tokens). |
locale | fr or en: language of the standard template. |
web_otp_domain | Host name (e.g. app.example.com): adds the line @app.example.com #<code> for autofill (Web OTP API, Chrome Android). |
strip_accents | GSM-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{
"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
}// 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 } }reason | Meaning | What to do |
|---|---|---|
invalid_code | Wrong code, attempts left. | Ask for the code again. |
max_attempts_reached | No attempts left, code blocked. | Offer a new send. |
expired | Code expired, or invalidated by a new send. | Offer a new send. |
already_used | Code 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 withresend_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.