Reliability
Errors
Error format, every API error code, 503 FEATURE_DISABLED maintenance and typed package exceptions.
All errors have the same shape. code is stable and meant for your code; message is in French and meant for humans; details is always an object ({} when empty), whose content depends on the code.
// Every error has the same shape: branch on "code", show "message" to humans (French)
{ "error": { "code": "INSUFFICIENT_CREDITS", "message": "Solde de crédits insuffisant pour cet envoi. Aucun message envoyé.", "details": { "available": 3, "required": 4 } } }
// Maintenance: nothing was done (no SMS, no debit)
{ "error": { "code": "FEATURE_DISABLED", "message": "Maintenance de l'envoi de SMS jusqu'à 10 h.",
"details": { "feature": "api.sms_send", "blocked_by": "api.sms_send", "message": "Maintenance de l'envoi de SMS jusqu'à 10 h.", "until": "2026-11-06T10:00:00+00:00" } } }Branch your logic on code, never on message. The code list is open: an integration must accept an unknown code (handle it by HTTP status).
Error codes#
| Code | HTTP | Meaning | What to do | Retry? |
|---|---|---|---|---|
UNAUTHENTICATED | 401 | API key missing, invalid or revoked.All operations | Check the Authorization: Bearer … header and the key in the client area. | No |
FORBIDDEN | 403 | IP address not allowed for this key.All operations | Add the server IP to the key's allowlist, or remove the restriction. | No |
ACCOUNT_SUSPENDED | 403 | Account suspended by the platform: the whole API is closed until reactivation.All operations | Contact support. | No |
OTP_DISABLED_FOR_ACCOUNT | 403 | OTP service disabled for this account by the platform.POST /otp/send | Contact support. | No |
NOT_FOUND | 404 | Unknown route ("Route introuvable.") or resource not found for this account (message, otp_id).All operations | Check the path and the identifier. | No |
HTTP_ERROR | 405 | Method not allowed on this path (details.allowed_methods, Allow header).All operations | Use one of the methods in details.allowed_methods. | No |
INSUFFICIENT_CREDITS | 409 | The balance does not cover the send: nothing is sent (details.available, details.required). Never with a test key.POST /sms, POST /otp/send | Top up the account, then resend. | No |
IDEMPOTENCY_KEY_REUSED | 409 | Idempotency-Key already used for a request with different content.POST /sms, POST /otp/send | Generate one key per business operation, never reused for other content. | No |
IDEMPOTENCY_KEY_UNRESOLVED | 409 | The original request with this key failed unexpectedly: its outcome is unknown (an SMS may have been sent).POST /sms, POST /otp/send | Do not retry automatically: check (GET /sms?reference=), then resend with a new key. | No |
IDEMPOTENCY_REQUEST_IN_PROGRESS | 409 | The original request with this key is still in progress (Retry-After: 5).POST /sms, POST /otp/send | Retry with the same key after Retry-After seconds. | After a delay |
VALIDATION_ERROR | 422 | Missing or invalid field; details lists messages per field.All operations | Fix the request (messages are in French). | No |
RECIPIENT_NOT_SENDABLE | 422 | Invalid, blacklisted or opted-out number (details.reason, details.rejected). All or nothing: nothing is sent.POST /sms, POST /otp/send | Remove the rejected numbers and resend. | No |
SENDER_ID_NOT_APPROVED | 422 | sender (or the OTP settings' Sender ID) is not an approved Sender ID of the account.POST /sms, POST /otp/send | Use a value from GET /sender-ids, or omit sender. | No |
INVALID_PHONE | 422 | Unusable phone number.POST /otp/send | Ask the user to correct the number. | No |
INVALID_OTP_TEMPLATE | 422 | template without the {code} token.POST /otp/send | Add {code} to the template. | No |
TOO_MANY_REQUESTS | 429 | Rate limit reached (details.retry_after_seconds).All operations | Wait Retry-After seconds, then retry. | After a delay |
OTP_RESEND_TOO_SOON | 429 | Resend too soon to the same number while the last code is not verified.POST /otp/send | Show a Retry-After seconds countdown. | After a delay |
OTP_RATE_LIMITED | 429 | Hourly cap reached, per number or per account (details.scope, details.limit).POST /otp/send | Do not retry before Retry-After; inform the user. | After a delay |
FEATURE_DISABLED | 503 | Feature turned off by the platform (maintenance): nothing is done. details = feature, blocked_by, message, until.All operations | Do not retry in a loop: retry after Retry-After when present, otherwise queue or inform the user. | After a delay |
OTP_SERVICE_DISABLED | 503 | The whole OTP service is off (legacy code, same details as FEATURE_DISABLED). Codes already sent can still be verified.POST /otp/send | Same as FEATURE_DISABLED. | After a delay |
Maintenance: 503 FEATURE_DISABLED#
A platform administrator can temporarily turn off the whole API or one function. The call then gets 503 FEATURE_DISABLED and nothing is done: no send, no debit.
details | Content |
|---|---|
feature | Feature requested by the call. |
blocked_by | Feature actually turned off: itself or a parent (api when the whole API is off). |
message | Administrator message, null otherwise (error.message repeats it or gives a generic text). |
until | Scheduled automatic reactivation (ISO 8601), null if unknown. When known, the Retry-After header gives the remaining seconds. |
| Feature | Operations affected |
|---|---|
api | The whole API, every route. The refusal comes before authentication and limits, even with an invalid key. |
api.sms_send | POST /sms |
api.otp_send | POST /otp/send |
api.otp_verify | POST /otp/verify |
otp | The whole OTP service: POST /otp/send returns the legacy code 503 OTP_SERVICE_DISABLED, with the same details. Codes already sent can still be verified. |
What to do?#
- Do not retry in a loop.
- If
Retry-Afteris present, retry after that delay (packages do it on their own when it is short: 10 s by default,maxRetryWait). - Otherwise, treat the call as a temporary failure: queue the send, or show
details.messageto the user.
Handling errors over HTTP#
Over HTTP
curl -i -X POST "https://api.labyrinthe-rdc.com/api/public/v1/sms" \
-H "Authorization: Bearer lsk_test_xxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"recipient":"243812345678","body":"Bonjour"}'
# HTTP/1.1 503 Service Unavailable
# Retry-After: 1800 <- only when a return date is scheduled
# X-Request-Id: 5f0c2b9e-... <- give it to support
# {"error":{"code":"FEATURE_DISABLED", ...}}Package exceptions#
Every SDK turns errors into typed exceptions, with code, HTTP status, details and request ID. Maintenance has its own class, FeatureDisabledException / FeatureDisabledError, which covers FEATURE_DISABLED and OTP_SERVICE_DISABLED and exposes feature, blocked_by, the administrator message, until and Retry-After.
With the packages
<?php
use Labyrinthe\Connect\Client;
use Labyrinthe\Connect\Exception\ApiException;
use Labyrinthe\Connect\Exception\FeatureDisabledException;
use Labyrinthe\Connect\Exception\InsufficientCreditsException;
use Labyrinthe\Connect\Exception\RateLimitException;
use Labyrinthe\Connect\Exception\RecipientNotSendableException;
$client = new Client('lsk_test_xxxxxxxxxxxxxxxx'); // or Client::fromEnv() (LABYRINTHE_API_KEY)
try {
$client->sms->send(to: '243812345678', body: 'Bonjour');
} catch (FeatureDisabledException $e) {
// 503 maintenance: nothing was sent, no credit used
$e->getFeature(); // 'api.sms_send'
$e->getBlockedBy(); // 'api' when the whole API is off
$e->getAdminMessage(); // platform message, or null
$e->getUntil(); // ?DateTimeImmutable
$e->getRetryAfter() !== null ? retryIn($e->getRetryAfter()) : queueForLater();
} catch (InsufficientCreditsException $e) {
notifyTopUp($e->getAvailable(), $e->getRequired());
} catch (RecipientNotSendableException $e) {
foreach ($e->getRejected() as $rejected) {
echo $rejected->recipient, ' ', $rejected->reason->value, PHP_EOL;
}
} catch (RateLimitException $e) {
retryIn($e->getRetryAfter() ?? 60);
} catch (ApiException $e) {
logger($e->getErrorCode(), $e->getStatusCode(), $e->getRequestId());
}| Package | Base | Maintenance |
|---|---|---|
| PHP / Laravel | ApiException : getErrorCode(), getStatusCode(), getDetails(), getRequestId() | FeatureDisabledException : getFeature(), getBlockedBy(), getAdminMessage(), getUntil(), getRetryAfter() |
| Node.js | ApiError : code, status, details, requestId | FeatureDisabledError : feature, blockedBy, adminMessage, until, retryAfter |
| Python | ApiError : code, status, details, request_id | FeatureDisabledError : feature, blocked_by, admin_message, until, retry_after |
| .NET | ApiException : Code, StatusCode, Details, RequestId | FeatureDisabledException : Feature, BlockedBy, AdminMessage, Until, RetryAfter |
| Java | ApiException : code(), statusCode(), details(), requestId() | FeatureDisabledException : feature(), blockedBy(), adminMessage(), until(), retryAfter() |
Errors common to every route#
404 NOT_FOUND, message "Route introuvable.": unknown path.405 HTTP_ERROR: method not allowed (e.g.DELETE /balance), withdetails.allowed_methodsand theAllowheader.
Always give the X-Request-Id header to support.