Error Codes Reference
All API errors follow a consistent bilingual format. The message field is automatically localized based on the Accept-Language header.
Error Response Structure
{
"error": {
"code": "ERROR_CODE",
"message": "English error message",
"details": [
{ "path": "fieldName", "message": "Field-specific error" }
]
}
}Arabic (Accept-Language: ar):
{
"error": {
"code": "ERROR_CODE",
"message": "رسالة الخطأ بالعربية",
"details": [
{ "path": "fieldName", "message": "خطأ خاص بالحقل" }
]
}
}The details field is optional and only included for validation errors with field-level information.
Authentication Errors (401 / 403 / 502)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
AUTH_REQUIRED | 401 | Authentication required | يلزم المصادقة | Missing or invalid token |
AUTH_SESSION_EXPIRED | 401 | Session has expired | انتهت صلاحية الجلسة | Expired JWT |
AUTH_ACCOUNT_DEACTIVATED | 401 | Account is deactivated | الحساب معطل | Sign-in blocked at OTP verification for a deactivated account (admin deactivation or account-deletion soft-delete) |
AUTH_INVALID_OTP | 401 | Invalid OTP code | رمز التحقق غير صحيح | Wrong OTP or unregistered phone/email |
AUTH_EXPIRED_OTP | 401 | OTP has expired | انتهت صلاحية رمز التحقق | OTP TTL exceeded (300s), or the maximum verification attempts for the OTP were exceeded |
AUTH_NO_CREDENTIAL | 401 | At least one of phone or email is required | يلزم توفير رقم الهاتف أو البريد الإلكتروني | Neither phone nor email provided |
AUTH_PHONE_EXISTS | 401 | Phone number already registered | رقم الهاتف مسجل مسبقاً | Duplicate phone on registration |
AUTH_EMAIL_EXISTS | 401 | Email already registered | البريد الإلكتروني مسجل مسبقاً | Duplicate email on registration |
AUTH_CONSENT_REQUIRED | 401 | Required consent types missing | أنواع الموافقة المطلوبة ناقصة | Invalid or missing consent entries |
AUTH_RATE_LIMIT_EXCEEDED | 401 | Too many OTP requests, please try again later | طلبات كثيرة للتحقق، يرجى المحاولة لاحقاً | Per-recipient OTP request throttle. More than 3 OTP requests were sent to the same phone or email within the 15-minute window (register, login, or change-phone). This is separate from verification-attempt limiting, which is reported under AUTH_EXPIRED_OTP instead |
AUTH_PHONE_UPDATE_FAILED | 502 | Failed to update phone number | فشل تحديث رقم الهاتف | The phone-change OTP was verified, but persisting the new phone number failed. This happens if the local database update itself fails, or if it succeeds but the following Supabase Auth update fails (the local change is then rolled back) |
AUTH_BLOCKLISTED | 403 | User or device is blocklisted | المستخدم أو الجهاز محظور | Blocklist check |
FORBIDDEN | 401 | Insufficient permissions — super_admin required | صلاحيات غير كافية — مطلوب super_admin | Non-super_admin on compliance routes |
AUTH_FORBIDDEN | 403 | Insufficient permissions | صلاحيات غير كافية | Role/area guard denied the request (non-super_admin on /admin/users, or a user lacking the required area on an area-gated route) |
AUTH_INVALID_OTPis used generically for both wrong OTP and unregistered phone/email to prevent enumeration attacks.
AUTH_PHONE_EXISTSis 401 here (thrown as anAuthErroron/auth/register) but 409 on/auth/change-phoneand/auth/verify-change-phone(thrown there as a plainAppError) — see Authentication.
FORBIDDEN(compliance routes) is thrown as anAuthError, whose constructor only maps the exact codeAUTH_FORBIDDENto 403 — every other code, including the distinct stringFORBIDDEN, resolves to 401. So despite the "super_admin required" message, this code actually returns 401, not 403.
Validation Errors (400)
| Code | Status | English Message | Condition |
|---|---|---|---|
VALIDATION_ERROR | 400 | Validation failed | Zod schema validation failed. details array contains field-specific errors. |
VALIDATION_ICCID_INVALID | 400 | ICCID must be exactly 19 digits | ICCID format validation failed |
VALIDATION_SIM_LIMIT_EXCEEDED | 400 | SIM limit exceeded for your identity type | CITC SIM limit per identity type exceeded |
Validation Error Details Example
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{ "path": "phone", "message": "Phone must be Saudi format +966XXXXXXXXX" },
{ "path": "iccid", "message": "ICCID must contain only digits" }
]
}
}Not Found Errors (404)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
NOT_FOUND | 404 | Resource not found | المورد غير موجود | Generic not found |
PACKAGE_NOT_FOUND | 404 | Package not found | الباقة غير موجودة | Package ID not found or inactive |
ACTIVATION_NOT_FOUND | 404 | Activation not found | التفعيل غير موجود | Activation ID not found or not owned by user |
Administration User Errors (404 / 409 / 502)
Returned by the super_admin-only /admin/users endpoints. See Admin Users.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
ADMIN_USER_NOT_FOUND | 404 | Administration user not found | مستخدم الإدارة غير موجود | No manageable user with the given id (owners and end users are not manageable) |
ADMIN_USER_PHONE_EXISTS | 409 | Phone number already registered | رقم الهاتف مسجل بالفعل | Phone already in use on create |
ADMIN_USER_EMAIL_EXISTS | 409 | Email address already registered | البريد الإلكتروني مسجل بالفعل | Email already in use on create |
ADMIN_USER_CREATE_FAILED | 502 | Failed to create user account | فشل إنشاء حساب المستخدم | Auth account creation failed upstream |
ADMIN_USER_PROFILE_NOT_CREATED | 502 | Failed to create user profile | فشل إنشاء الملف الشخصي للمستخدم | Profile row was not seeded; the orphaned auth identity is rolled back |
ADMIN_USER_DELETE_FAILED | 502 | Failed to remove user | فشل حذف المستخدم | Auth identity removal failed upstream |
Company Errors (403 / 404 / 409 / 502)
Returned by the B2B company registration, portal, and admin-oversight endpoints. See B2B Portal and B2B Admin.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
COMPANY_NOT_FOUND | 404 | Company not found | الشركة غير موجودة | No company with the given id |
COMPANY_ALREADY_REGISTERED | 409 | You already belong to a company | أنت مرتبط بشركة بالفعل | Caller already has a company membership |
COMPANY_CR_EXISTS | 409 | CR number already registered | رقم السجل التجاري مسجل بالفعل | Commercial registration number already in use |
COMPANY_CREATE_FAILED | 502 | Failed to register company | فشل تسجيل الشركة | Company registration failed |
COMPANY_INVALID_TRANSITION | 409 | Cannot {action} a {status} company | إجراء غير صالح لحالة الشركة الحالية | The status action (approve, reject, suspend, reactivate) is not valid for the company's current status |
COMPANY_NOT_APPROVED | 403 | Company is pending approval | الشركة في انتظار الموافقة | Portal route accessed by a member of a not-yet-approved company (also returned, with a different message, when a magic link is redeemed for — or POST /activations is attempted against — a company that isn't approved) |
COMPANY_SUSPENDED | 403 | Company access is suspended | تم تعليق وصول الشركة | Portal route accessed by a member of a suspended company |
COMPANY_REJECTED | 403 | Company registration was rejected | تم رفض تسجيل الشركة | Portal route accessed by a member of a rejected company |
COMPANY_NOT_READY | 409 | This company isn't ready to send activation links yet — the contract and payment must be confirmed first | هذه الشركة غير جاهزة لإرسال روابط التفعيل بعد — يجب تأكيد العقد والدفع أولاً | Magic links minted before the contract + payment readiness gate is satisfied |
COMPANY_BALANCE_EXCEEDED | 409 | This activation would exceed your company's available balance | سيؤدي هذا التفعيل إلى تجاوز الرصيد المتاح لشركتك | Flexible balance gate: drawn funds plus this seat's charge would pass the company's funded amount (also appears as a per-row code in the link-send failed array) |
Quote Errors (400 / 404 / 409 / 502)
Returned by the B2B quote lifecycle endpoints. See B2B Portal and B2B Admin.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
QUOTE_NOT_FOUND | 404 | Quote not found | الطلب غير موجود | No quote with the given id (or, on the admin read, the quote is still a private draft) |
QUOTE_INVALID_STATE | 409 | Varies with the action (e.g. "Quote has no items to confirm") | يختلف حسب الإجراء | The quote's status doesn't allow the requested action |
QUOTE_CREATE_FAILED | 502 | Failed to create quote | فشل إنشاء الطلب | Quote draft creation failed |
QUOTE_VERSION_SUPERSEDED | 409 | A newer version has superseded this one | استُبدلت هذه النسخة بنسخة أحدث | Action attempted on a quote version that a counter has since superseded |
QUOTE_COUNTER_FAILED | 502 | Failed to create the countered version | فشل إنشاء النسخة المعدَّلة | Countering a quote failed |
QUOTE_PACKAGE_NOT_FOUND | 400 | One or more packages do not exist or are not available for business orders | باقة واحدة أو أكثر غير موجودة أو غير متاحة لطلبات الأعمال | A quote line references a package id that doesn't exist or is not B2B-orderable (inactive, or no B2B price — the same surface the B2B catalog exposes). The offending ids are in error.details.packageIds |
Contract Errors (400 / 404 / 409 / 502)
Returned by the B2B contract confirmation and payment endpoints. See B2B Portal and B2B Admin.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
CONTRACT_NOT_FOUND | 404 | Contract not found | العقد غير موجود | No contract with the given id |
CONTRACT_ALREADY_EXISTS | 409 | This company already has an active contract | يوجد لدى هذه الشركة عقد نشط بالفعل | Confirming a quote for a company that already has an active contract |
CONTRACT_ITEM_NOT_FOUND | 404 | Contract line not found on this company's active contract | بند العقد غير موجود ضمن العقد النشط لهذه الشركة | A roster package assignment references a contract line that doesn't belong to the company's active contract |
CONTRACT_PACKAGE_PRICE_MISSING | 400 | One or more quoted packages have no B2B price set — cannot confirm this contract | باقة واحدة أو أكثر في طلب تحديد الباقات لا تملك سعر أعمال محدد — لا يمكن تأكيد هذا العقد | Confirming a quote where a quoted package has no priceSarB2b |
CONTRACT_CREATE_FAILED | 502 | Failed to create contract | فشل إنشاء العقد | Contract creation failed |
CONTRACT_PAYMENT_MODE_INVALID | 409 | Payments cannot be recorded for this contract | لا يمكن تسجيل الدفعات لهذا العقد | Recording a payment on a contract whose payment mode is neither prepaid nor flexible (legacy/invalid data — every contract is one or the other today) |
CONTRACT_PAYMENT_BELOW_TOTAL | 409 | Payment amount is below the contract total | قيمة الدفعة أقل من إجمالي العقد | Recorded payment amount is less than the contract's totalSar |
CONTRACT_PAYMENT_IDEMPOTENCY_CONFLICT | 409 | This idempotency key was already used for a different payment | مفتاح العملية مستخدم بالفعل لدفعة مختلفة | The same idempotencyKey was replayed with a different method or amount — replaying it with identical values returns the original record instead |
CONTRACT_PAYMENT_DOCUMENT_INVALID_MIME | 400 | Unsupported document type | نوع المستند غير مدعوم | The uploaded payment document's MIME type isn't accepted |
CONTRACT_PAYMENT_DOCUMENT_TOO_LARGE | 400 | Document must be 5MB or less | يجب ألا يتجاوز حجم المستند 5 ميغابايت | The uploaded payment document exceeds the 5MB size cap |
CONTRACT_PAYMENT_RECORD_FAILED | 502 | Failed to record payment | فشل تسجيل الدفعة | Recording the payment failed |
CONTRACT_PAYMENT_DOCUMENT_NOT_FOUND | 404 | Payment document not found | مستند الدفع غير موجود | The payment record has no supporting document to download |
Roster Errors (404 / 409)
The B2B CSV roster staging endpoint returns no error envelope of its own. Row-level problems — invalid phone or email, a duplicate contact, or a contact a concurrent upload claimed first — come back as data in the rejected array of a 200 response. The same holds for the batch link routes, whose per-entry skips and failures are data too.
The two single-entry roster-control routes are the exception. PATCH /b2b/roster/:rosterId and DELETE /b2b/roster/:rosterId write one row, so they have no partial-accept response to carry a reason and throw instead. See B2B Portal.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
ROSTER_ENTRY_NOT_FOUND | 404 | Roster entry not found | صف القائمة غير موجود | No roster entry with the given id on the caller's own company roster (also appears as a per-entry skip code on the batch link routes) |
ROSTER_LINK_OPEN | 409 | This person has an open invitation — revoke it first | لدى هذا الشخص دعوة مفتوحة — يجب إلغاؤها أولاً | A live, unexpired issued/sent/exported link still holds this seat, so the assignment cannot change and the person cannot be removed. Cleared with POST /b2b/roster/links/revoke. |
ROSTER_SEAT_CONSUMED | 409 | This person already used their invitation — their seat is permanently theirs | استخدم هذا الشخص دعوته بالفعل — أصبح مقعده دائمًا | The beneficiary redeemed a link at some point. Redemption is permanent, even once that link is past its own expiry. The removal route also returns this code, with the message "This person already has activation history — their record cannot be removed", when the underlying account is no longer a bare staged end user. |
Magic Link Errors (401 / 409)
Returned by POST /auth/magic-link on redemption. See Authentication.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
MAGIC_LINK_INVALID | 401 | This link is not valid | هذا الرابط غير صالح | Token doesn't match any issued magic link |
MAGIC_LINK_ALREADY_USED | 401 | This link has already been used | تم استخدام هذا الرابط بالفعل | The link's status is already used (or it lost the race to consume it) |
MAGIC_LINK_REVOKED | 401 | This link has been revoked | تم إلغاء هذا الرابط | The link was revoked before redemption |
MAGIC_LINK_EXPIRED | 401 | This link has expired | انتهت صلاحية هذا الرابط | The link's 24h TTL has passed |
MAGIC_LINK_USER_ALREADY_ACTIVE | 409 | This account already has an active SIM | يوجد لدى هذا الحساب شريحة مفعّلة بالفعل | Redemption succeeded but the user's latest activation is already activated. The link send/export routes pre-empt this at mint time with the per-entry skip LINK_USER_ALREADY_ACTIVE (same predicate), so a company-minted link should no longer reach this state |
COMPANY_NOT_APPROVED (403) is also returned here — see Company Errors.
Document Errors (404 / 500)
Returned by GET /documents/:type/:entityId.pdf. See Documents.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
DOCUMENT_TYPE_NOT_FOUND | 404 | Document type not found | نوع المستند غير موجود | :type isn't a registered document type |
DOCUMENT_NOT_FOUND | 404 | Document not found | المستند غير موجود | :entityId isn't a valid UUID, or no matching entity exists |
DOCUMENT_RENDER_FAILED | 500 | Failed to render document | فشل إنشاء المستند | PDF rendering threw after the access policy and entity lookup both passed |
Activation Errors (409 / 422)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
ACTIVATION_FAILED | 422 | Activation failed | فشل التفعيل | General activation failure |
ACTIVATION_ICCID_INVALID | 422 | ICCID validation failed | فشل التحقق من رقم ICCID | Operator rejected ICCID |
ACTIVATION_ESIM_DISABLED | 422 | eSIM activation is disabled | تفعيل eSIM غير مفعّل حاليًا | FEATURE_ESIM is off for an eSIM request, or a corporate B2B user attempted eSIM activation before that lane is enabled |
ACTIVATION_CODE_INVALID | 422 | Invalid eSIM activation code | رمز تفعيل eSIM غير صالح | eSIM activation create reached the operator validation path and the LPA activation code was rejected |
ACTIVATION_ESIM_UNSUPPORTED_OPERATOR | 422 | This operator does not support eSIM activation | هذا المشغل لا يدعم تفعيل eSIM | simType: esim was requested for an operator plugin that did not explicitly declare supportsEsim: true |
ACTIVATION_PACKAGE_NOT_ON_CONTRACT | 409 | This package is not on your company's contract | هذه الباقة غير مدرجة في عقد شركتك | B2B activation: no roster entry, no assigned contract line, the requested package doesn't match the assigned line, or the contract is not active |
ACTIVATION_LINK_NOT_REDEEMED | 409 | Open your company invite link before activating | يجب فتح رابط دعوة شركتك قبل التفعيل | B2B activation: the roster entry has no redeemed invite link — OTP login alone cannot activate a staged seat |
ACTIVATION_SEAT_ALREADY_USED | 409 | This account already has an active or pending SIM | يوجد لدى هذا الحساب شريحة نشطة أو قيد التفعيل بالفعل | B2B activation: the seat already has a pending/in-progress/activated SIM (failed/cancelled allow retry) |
IDENTITY_ERROR | 422 | Identity verification failed | فشل التحقق من الهوية | Identity verification initiation/retrieval failure |
eSIM Store Errors (400 / 404 / 409 / 422 / 503)
Returned by the public eSIM store, store-order, admin catalog, admin order recovery, FX settings, and manual sync endpoints. See eSIM Store.
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
STORE_DISABLED | 422 | eSIM store is disabled | متجر eSIM غير مفعّل حاليًا | FEATURE_ESIM_STORE is off — gates browse (GET /store/destinations, GET /store/products, GET /store/products/:id) and store writes (POST /store/orders, POST /store/device-acks); GET /store/status stays public, and owned-order reads and the QR endpoint stay available so delivered codes are never locked out |
STORE_PRODUCT_NOT_FOUND | 404 | Store product not found | منتج المتجر غير موجود | Product is missing, or public detail hides it because it is inactive |
STORE_PRODUCT_INACTIVE | 422 | Store product is inactive | منتج المتجر غير متاح حاليًا | Buy request targets an inactive product |
STORE_CURRENCY_UNSUPPORTED | 400 | Unsupported store currency | عملة المتجر غير مدعومة | Currency value is outside the supported USD / SAR / AED / EUR / GBP / EGP set, or a supported currency's FX rate has not been fetched yet (details.currency + details.detail). On list and buy routes, unsupported currencies are rejected as standard validation errors; on the detail route, the explicit currency param fires this code |
STORE_KYC_REQUIRED | 422 | This eSIM requires KYC and cannot be purchased yet | تتطلب هذه الشريحة تحققًا من الهوية ولا يمكن شراؤها حاليًا | Buy request targets a KYC provider SKU whose provider does not support the async verification flow (or is not registered). With a supporting provider the purchase proceeds and issuance answers with a verification URL instead |
STORE_ORDER_NOT_FOUND | 404 | Store order not found | طلب المتجر غير موجود | Store order is missing, foreign to the caller, or not issued yet for QR delivery; also returned by the admin order detail and requeue routes for unknown ids |
STORE_SYNC_UNAVAILABLE | 503 | eSIM store sync is temporarily unavailable | مزامنة متجر eSIM غير متاحة مؤقتًا | Manual admin catalog sync could not be enqueued because Redis/queueing is unavailable |
STORE_ORDER_NOT_REQUEUEABLE | 409 | Store order is not in a requeueable state | طلب المتجر ليس في حالة تسمح بإعادة الإدراج | Admin requeue targeted an order that is not paid-without-an-issued-profile (already issued, completed, cancelled, or still pending payment) |
STORE_REQUEUE_UNAVAILABLE | 503 | Store fulfillment requeue is temporarily unavailable | إعادة إدراج تلبية طلب المتجر غير متاحة مؤقتًا | Admin requeue could not enqueue a store fulfillment job because Redis/queueing is unavailable |
STORE_CHARGE_BELOW_MINIMUM | 422 | Charge amount is below the minimum allowed by payment processors | مبلغ الدفع أقل من الحد الأدنى المسموح به لمعالجات الدفع | The calculated store order charge amount is below the mandatory minimum threshold |
STORE_PROVIDER_PAYMENT_EXTERNAL | 422 | External provider checkout is not supported yet | الدفع عبر مزوّد الخدمة غير مدعوم بعد | The store order's provider has payment_mode = 'external'. Fail-closed — order creation and card/Apple Pay payment initiation both block before any insert or PSP call. Missing linkage or unrecognised mode values throw the same code. |
STORE_PRICE_CHANGED | 409 | Store product price has changed; re-fetch the product and retry with the current price | تغيّر سعر منتج المتجر؛ يرجى إعادة جلب المنتج والمحاولة بالسعر الحالي | Optional expectedPrice guard on POST /store/orders: the request carried expectedPrice and it was not strictly equal to the freshly computed presentment price.amount; details.currentPrice: { amount, currency } carries the current price so the client can re-render without a refetch |
STORE_PROVIDER_NOT_FOUND | 404 | Store provider not found | مزوّد المتجر غير موجود | Admin provider toggle (PATCH /admin/store/providers/:id) targeted an id with no eSIM operator row — telecom operators are invisible to this route by design |
STORE_PROVIDER_UNAVAILABLE | 422 | Store provider is temporarily unavailable | مزوّد المتجر غير متاح مؤقتًا | Checkout circuit breaker on POST /store/orders: the product's provider plugin is not registered in this process, its latest health reading is unhealthy, or the health check itself failed (fail closed) |
Payment Errors (402 / 404)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
PAYMENT_INTENT_FAILED | 402 | Payment intent creation failed | فشل إنشاء نية الدفع | Stripe intent error |
PAYMENT_METHOD_NOT_SUPPORTED_FOR_ORDER | 422 | This payment method is not available for eSIM store orders | طريقة الدفع هذه غير متاحة لطلبات متجر الشرائح الإلكترونية | mada or STC Pay attempted on an eSIM store order; cards and Apple Pay are the only supported store rails |
PAYMENT_APPLE_PAY_FAILED | 402 | Apple Pay payment failed | فشل دفع Apple Pay | Apple Pay confirmation failed |
PAYMENT_STCPAY_FAILED | 402 | STC Pay initiation failed | فشل بدء عملية STC Pay | STC Pay initiation error |
PAYMENT_RECEIPT_FAILED | 402 | Receipt retrieval failed | فشل استرجاع الإيصال | Receipt generation error |
PAYMENT_ORDER_INVALID_STATUS | 402 | Order is not in pending_payment status | الطلب ليس في حالة انتظار الدفع | The order is not in pending_payment when a payment create call is attempted |
PAYMENT_IDEMPOTENCY_CONFLICT | 402 | Idempotency conflict | تعارض مفتاح الوحدة | Same idempotency key with different parameters |
PAYMENT_MISMATCH_RECORD_FAILED | 500 | Failed to record the payment mismatch | فشل تسجيل عدم تطابق الدفعة | Writing the reconciliation record and the failed-payment transition both failed on a mismatched webhook. Returned to the provider so it redelivers the event |
ORDER_NOT_FOUND | 404 | Order not found | لم يتم العثور على الطلب | The payment request referenced an order that does not exist or is not owned by the caller |
Idempotency conflict response:
{
"error": {
"code": "PAYMENT_IDEMPOTENCY_CONFLICT",
"message": "A payment with this idempotency key already exists with different parameters",
"details": {
"existingPaymentId": "d5e6f7a8-b9c0-1234-defa-567890123456"
}
}
}Operator Errors (502 / 504)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
OPERATOR_UNAVAILABLE | 502 | Operator API unavailable | خدمات المشغل غير متاحة | Operator API returned error |
OPERATOR_TIMEOUT | 504 | Operator API timed out | انتهت مهلة خدمات المشغل | Operator API did not respond in time |
Server Errors (5xx)
| Code | Status | English Message | Condition |
|---|---|---|---|
INTERNAL_ERROR | 500 | An unexpected error occurred | Unhandled server error |
NOT_IMPLEMENTED | 501 | This endpoint is not yet implemented | Endpoint stub (not yet built) |
Rate Limiting (429)
When the rate limit (100 requests / 15 minutes / IP) is exceeded:
{
"error": {
"code": "RATE_LIMIT_EXCEEDED",
"message": "Too many requests, please try again later"
}
}Response headers include:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests per window |
X-RateLimit-Remaining | Requests remaining in current window |
X-RateLimit-Reset | Window reset timestamp (Unix epoch) |
Stripe Webhook Errors (400)
| Code | Status | English Message | Arabic Message | Condition |
|---|---|---|---|---|
STRIPE_WEBHOOK_SIGNATURE_MISSING | 400 | Stripe webhook signature header is missing | رأس توقيع Stripe مفقود | Stripe-Signature header not provided |
STRIPE_WEBHOOK_SIGNATURE_INVALID | 400 | Stripe webhook signature verification failed | فشل التحقق من توقيع Stripe | Signature does not match payload |
Reconciliation Codes
These codes are emitted as structured console.error records and are never returned as HTTP error responses. Some also write reconciliation records to the database:
| Code | Condition |
|---|---|
PAYMENT_PROVIDER_AMOUNT_MISMATCH | Provider-reported payment intent amount, currency, or metadata does not match the persisted payment row. The payment is NOT completed, settlement is NOT enqueued, and the webhook still returns 200 (Stripe retry-storm prevention). A reconciliation record is written to the payment_webhook_events table with the mismatch detail, in the same transaction as the failed-payment transition. The structured log carries the paymentId, orderId, userId, expected vs received amounts and currencies, and the specific mismatched fields — no raw payload or client secret is logged. If that transaction fails, the webhook responds 500 PAYMENT_MISMATCH_RECORD_FAILED so the provider redelivers. |
SIM Limits (CITC)
Per CITC regulations, users are limited by identity type:
| Identity Type | Max SIMs | identityType Value |
|---|---|---|
| Citizen | 10 | citizen |
| Resident | 2 | resident |
| Visitor | 1 | visitor |