Skip to content

Authentication API ​

User registration, login, OTP verification, session management, and profile management.

Credential Rules ​

RuleDescription
At least one requiredRegistration and login require at least one of phone or email
Phone formatSaudi E.164: +966XXXXXXXXX (12 digits, +966 prefix + 9 digits)
Email formatStandard RFC 5322 email
Dual credentialIf both phone and email are provided, OTP is sent via both channels
UniquenessPhone and email are each globally unique across all users
Nullabilityphone is null for email-only users; email is null for phone-only users

The Saudi phone format above applies to self-service registration (POST /auth/register) and phone changes. B2B roster-provisioned accounts — staged by a company admin via CSV upload and completed through magic-link redemption (POST /auth/magic-link, below) — accept any E.164 phone number, since a company's roster can include foreign visitors.

Client Rule ​

Customers (end_user) sign in only from the mobile app; every other role signs in only from the web portal. POST /auth/verify-otp and POST /auth/magic-link refuse a mismatch with 403 before any session is created: AUTH_USE_MOBILE_APP (customer on a web client) or AUTH_USE_PORTAL (any other role on a mobile client). The client comes from X-Client-Type (or User-Agent), so this is a usability gate; customer-only routes also check the role. A magic link refuses a web client before the link is used, so it still works from the app.


POST /auth/register ​

Register a new user account. Requires at least one of phone or email, a name, and exactly 3 consent entries. On success, an OTP is sent to the provided identifier(s).

Request Body ​

FieldTypeRequiredDescription
phonestringConditionalSaudi phone in E.164 format (+966XXXXXXXXX). Required if email not provided.
emailstringConditionalEmail address. Required if phone not provided.
namestringYesFull name (1–255 characters)
consentsarrayYesExactly 3 consent objects
FieldTypeRequiredAllowed Values
typestringYesterms_of_service, privacy_policy, data_processing
versionstringYesVersion string (e.g. "1.0")

All 3 consent types must be present exactly once. marketing is a valid ConsentType enum value but is not required for registration.

Examples ​

Phone-only registration:

json
{
  "phone": "+966501234567",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Email-only registration:

json
{
  "email": "ahmed@example.com",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Dual credential registration (phone + email):

json
{
  "phone": "+966501234567",
  "email": "ahmed@example.com",
  "name": "Ahmed Al-Rashid",
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Response ​

201 Created

json
{
  "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "message": "OTP sent"
}

Arabic response (Accept-Language: ar):

json
{
  "userId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "message": "تم إرسال رمز التحقق"
}

Error Responses ​

CodeStatusCondition
AUTH_NO_CREDENTIAL401Neither phone nor email provided
AUTH_PHONE_EXISTS401Phone number already registered
AUTH_EMAIL_EXISTS401Email address already registered
AUTH_CONSENT_REQUIRED401Missing or invalid consent entries
AUTH_RATE_LIMIT_EXCEEDED401Too many OTP requests for this phone or email (max 3 per 15-minute window)
VALIDATION_ERROR400Schema validation failed (invalid phone format, invalid email, etc.)

POST /auth/login ​

Send an OTP to an existing user. Requires at least one of phone or email. The OTP is delivered via SMS (for phone) or email (for email identifier).

The response is always 200 with the same generic message, whether or not the account exists. The code is sent only to an existing account whose role matches the calling client (end_user on mobile, every other role on web); otherwise nothing is sent. The send runs in the background, so delivery failures and the per-identifier OTP rate limit (3 per 15 minutes) are not reported here. This prevents phone/email enumeration.

Request Body ​

FieldTypeRequiredDescription
phonestringConditionalRegistered phone in E.164 format. Required if email not provided.
emailstringConditionalRegistered email address. Required if phone not provided.

Examples ​

Phone login:

json
{
  "phone": "+966501234567"
}

Email login:

json
{
  "email": "ahmed@example.com"
}

Response ​

200 OK

json
{
  "message": "If the account is eligible, a code has been sent."
}

Arabic (Accept-Language: ar):

json
{
  "message": "إذا كان الحساب مؤهلاً، فقد تم إرسال الرمز."
}

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Schema validation failed

POST /auth/verify-otp ​

Verify an OTP code and create a session. Works for both registration completion and login. Supports phone or email as the identifier.

The response differs by client type, detected from the X-Client-Type header or User-Agent:

  • Mobile (clientType: "mobile"): JWT token returned in response body.
  • Web (clientType: "web"): JWT set as httpOnly cookie named activation-sys-session.

Request Body ​

FieldTypeRequiredDescription
phonestringConditionalPhone in E.164 format used for OTP. Required if email not provided.
emailstringConditionalEmail address used for OTP. Required if phone not provided.
otpstringYes6-digit numeric OTP code

Examples ​

Verify with phone:

json
{
  "phone": "+966501234567",
  "otp": "123456"
}

Verify with email:

json
{
  "email": "ahmed@example.com",
  "otp": "123456"
}

Response (Mobile Client) ​

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "clientType": "mobile",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  }
}

Response (Web Client) ​

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": {
    "clientType": "web",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  }
}

Set-Cookie header: activation-sys-session=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=3600; Priority=High

Email-Only User Response ​

For email-only users, user.phone is null:

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": null,
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen"
  },
  "session": { "..." : "..." }
}

Error Responses ​

CodeStatusCondition
AUTH_INVALID_OTP401Wrong code, no user matches the identifier, or no code to check (never issued, expired after 300 seconds, or spent by 5 wrong attempts). One answer for all of them, so verification never reveals whether a code was issued
AUTH_ACCOUNT_DEACTIVATED401Account is deactivated (admin deactivation or account-deletion soft-delete) — sign-in is blocked
AUTH_USE_MOBILE_APP403Customer account on a web client (see Client Rule)
AUTH_USE_PORTAL403Portal account (any role but end_user) on a mobile client (see Client Rule)
VALIDATION_ERROR400Schema validation failed, including neither phone nor email provided

POST /auth/magic-link ​

Redeem a B2B roster invite token and create a session — the sign-in path for CSV-staged company employees/visitors who never went through /auth/register. Public (no auth), rate-limited the same as /auth/verify-otp. The raw token travels only in the JSON body, never a query string or log line.

The response is the same dual-strategy session as /auth/verify-otp (cookie for web, JWT in body for mobile), with an additional b2b object driving the client's next step.

Request Body ​

FieldTypeRequiredDescription
tokenstringYesThe raw magic-link token from the invite (20–200 characters)

Example Request ​

json
{
  "token": "a1b2c3d4e5f6...invite-token"
}

Response ​

200 OK

json
{
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+9665XXXXXXXX",
    "name": "Employee Name",
    "email": null,
    "appRole": "end_user",
    "identityType": "resident"
  },
  "session": {
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
    "clientType": "mobile",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  },
  "b2b": {
    "nextStep": "iccid_entry",
    "packageId": "pkg-uuid",
    "activationId": null,
    "consentsRequired": ["terms_of_service", "privacy_policy", "data_processing"]
  }
}

b2b.nextStep is one of iccid_entry, identity_entry, identity_waiting, done, computed from the user's most recent activation. b2b.consentsRequired lists which of the 3 required PDPL consents the user has not yet recorded — CSV-staged users have none until they call POST /auth/consents.

Error Responses ​

CodeStatusCondition
VALIDATION_ERROR400Token missing or outside the 20–200 character range
MAGIC_LINK_INVALID401Token doesn't match any issued magic link
MAGIC_LINK_ALREADY_USED401The link has already been redeemed
MAGIC_LINK_REVOKED401The link was revoked before redemption
MAGIC_LINK_EXPIRED401The link's 24-hour TTL has passed
COMPANY_NOT_APPROVED403The roster entry's company is not currently approved
AUTH_ACCOUNT_DEACTIVATED401The account is deactivated
AUTH_USE_MOBILE_APP403Customer account on a web client (see Client Rule)
AUTH_USE_PORTAL403Portal account (any role but end_user) on a mobile client (see Client Rule)
MAGIC_LINK_USER_ALREADY_ACTIVE409The user's latest activation is already activated — a magic link only onboards a new line

POST /auth/consents ​

Record the 3 required PDPL consents for the authenticated user. CSV-staged roster users never go through /auth/register, so they never recorded consent — the mobile app calls this once after magic-link redemption, guided by the redemption response's consentsRequired.

Requires authentication.

Request Body ​

FieldTypeRequiredDescription
consentsarrayYesExactly the 3 required consent objects (see Consent Object)

Example Request ​

json
{
  "consents": [
    { "type": "terms_of_service", "version": "1.0" },
    { "type": "privacy_policy", "version": "1.0" },
    { "type": "data_processing", "version": "1.0" }
  ]
}

Response ​

201 Created

json
{
  "data": { "recorded": true }
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
AUTH_CONSENT_REQUIRED401Missing or invalid consent entries
VALIDATION_ERROR400Schema validation failed

POST /auth/logout ​

Invalidate the current session.

  • Web: Clears the activation-sys-session httpOnly cookie.
  • Mobile: Adds the JWT to a Redis blocklist (SHA-256 hashed, TTL matching remaining JWT expiry).

Requires authentication.

Response ​

200 OK

json
{
  "message": "Logged out successfully"
}

Arabic (Accept-Language: ar):

json
{
  "message": "تم تسجيل الخروج بنجاح"
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token

GET /auth/session ​

Return the current authenticated user's session details.

Requires authentication.

Example Request ​

GET /auth/session
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response ​

200 OK

json
{
  "session": {
    "clientType": "web",
    "expiresAt": "2026-05-05T09:00:00.000Z"
  },
  "user": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966501234567",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "managedAreas": null,
    "managedAreasWrite": null,
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  }
}

appRole is one of end_user, b2b_admin, activation_officer, support, admin, or super_admin. For an admin user, managedAreas / managedAreasWrite carry the assigned portal areas; they are null for end users and fixed staff roles, and super_admin implicitly holds every area.

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
AUTH_SESSION_EXPIRED401Session has expired

GET /auth/me ​

Return the authenticated user's profile with decrypted PII. Functionally equivalent to GET /auth/session.

Requires authentication.

Example Request ​

GET /auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Response ​

200 OK

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "phone": "+966501234567",
  "name": "Ahmed Al-Rashid",
  "email": "ahmed@example.com",
  "appRole": "end_user",
  "identityType": "citizen",
  "isActive": true,
  "createdAt": "2026-04-30T12:00:00.000Z"
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token

PUT /auth/me ​

Update the authenticated user's profile. Only name and email can be updated. Phone changes require a separate OTP flow via POST /auth/change-phone.

Requires authentication.

The update runs in one transaction and shares its email rules with PUT /profile:

  • A new email also becomes the login email: emailHash, the sign-in lookup key, is rewritten from the email as typed (no normalization), the same hash the login lookup computes.
  • An email another account owns is refused with 409 AUTH_EMAIL_EXISTS, including one taken concurrently and caught by the unique index.
  • email: null removes the email and its hash, and is refused with 409 AUTH_LAST_IDENTIFIER when the account has no phone, since it would be left with no identifier to sign in with.
  • When the caller is a company admin, a new email fills the company's contact email while that is still empty, unless the company is archived.
  • A change to name or email is audited as profile.updated with the names of the changed fields, never their values. A save that changes neither writes no audit row.

Request Body ​

FieldTypeRequiredDescription
namestringNoUpdated name (1–255 characters)
emailstring | nullNoUpdated email (null to remove email)

Example Request ​

PUT /auth/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "name": "Ahmed Mohammed Al-Rashid",
  "email": "ahmed.new@example.com"
}

Response ​

200 OK

json
{
  "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "phone": "+966501234567",
  "name": "Ahmed Mohammed Al-Rashid",
  "email": "ahmed.new@example.com",
  "appRole": "end_user",
  "identityType": "citizen",
  "isActive": true,
  "createdAt": "2026-04-30T12:00:00.000Z"
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid name or email format
AUTH_EMAIL_EXISTS409Another account already uses the email
AUTH_LAST_IDENTIFIER409email: null on an account with no phone, which would remove its only sign-in identifier

POST /auth/change-phone ​

Send a phone-change scoped OTP to a new phone number. The user must then call POST /auth/verify-change-phone with the new phone and OTP to complete the change.

If another account already owns the submitted phone number, the API rejects the request with AUTH_PHONE_EXISTS and does not send an OTP.

Requires authentication.

On top of the OTP service's per-phone limits, this route and POST /auth/verify-change-phone share a request limit keyed per signed-in user rather than per IP, applied regardless of client type (AUTH_RATE_LIMIT_WINDOW_MS / AUTH_RATE_LIMIT_MAX_REQUESTS, default 100 requests per 15 minutes). The admin portal calls these routes server-side without forwarding the browser IP, so an IP key would put every portal user in one bucket.

Request Body ​

FieldTypeRequiredDescription
newPhonestringYesNew phone in Saudi E.164 format (+966XXXXXXXXX)

Example Request ​

POST /auth/change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "newPhone": "+966509876543"
}

Response ​

200 OK

json
{
  "message": "OTP sent to new number"
}

Arabic (Accept-Language: ar):

json
{
  "message": "تم إرسال رمز التحقق إلى الرقم الجديد"
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid phone format
AUTH_PHONE_EXISTS409Phone number is already registered to another account
AUTH_RATE_LIMIT_EXCEEDED401Too many OTP requests for this phone (max 3 per 15-minute window)
RATE_LIMIT_EXCEEDED429The per-user request limit shared with /auth/verify-change-phone was exceeded

POST /auth/verify-change-phone ​

Verify the OTP sent by POST /auth/change-phone and update the authenticated user's phone number. The OTP challenge is scoped to the phone-change purpose and authenticated user, so login/register OTPs cannot complete this flow. This endpoint derives the user from the JWT context and does not create a new login session.

Requires authentication.

Request Body ​

FieldTypeRequiredDescription
newPhonestringYesNew phone in Saudi E.164 format (+966XXXXXXXXX)
otpstringYes6-digit OTP sent to newPhone

Example Request ​

POST /auth/verify-change-phone
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
json
{
  "newPhone": "+966509876543",
  "otp": "654321"
}

Response ​

200 OK

json
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966509876543",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  },
  "message": "Phone number changed successfully"
}

Arabic (Accept-Language: ar):

json
{
  "data": {
    "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
    "phone": "+966509876543",
    "name": "Ahmed Al-Rashid",
    "email": "ahmed@example.com",
    "appRole": "end_user",
    "identityType": "citizen",
    "isActive": true,
    "createdAt": "2026-04-30T12:00:00.000Z"
  },
  "message": "تم تغيير رقم الهاتف بنجاح"
}

Error Responses ​

CodeStatusCondition
AUTH_REQUIRED401Missing or invalid token
VALIDATION_ERROR400Invalid phone format or OTP format
AUTH_INVALID_OTP401OTP is incorrect, expired, or spent by too many wrong attempts (one answer)
AUTH_PHONE_EXISTS409Phone number is already registered to another account
RATE_LIMIT_EXCEEDED429The per-user request limit shared with /auth/change-phone was exceeded
AUTH_PHONE_UPDATE_FAILED502Supabase Auth rejected the phone update; local profile was not changed

Complete Auth Flows ​

Phone-Only Registration ​

1. POST /auth/register  { phone: "+966501234567", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via SMS)

2. POST /auth/verify-otp { phone: "+966501234567", otp: "123456" }
   → 200 { user: { phone: "+966501234567", email: null, ... }, session }

Email-Only Registration ​

1. POST /auth/register  { email: "ahmed@example.com", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via Email)

2. POST /auth/verify-otp { email: "ahmed@example.com", otp: "123456" }
   → 200 { user: { phone: null, email: "ahmed@example.com", ... }, session }

Dual Credential Registration ​

1. POST /auth/register  { phone: "+966501234567", email: "ahmed@example.com", name: "Ahmed", consents: [...] }
   → 201 { userId, message: "OTP sent" }        (OTP via SMS AND Email)

2. POST /auth/verify-otp { phone: "+966501234567", otp: "123456" }
   — OR —
   POST /auth/verify-otp { email: "ahmed@example.com", otp: "123456" }
   → 200 { user: { phone: "+966501234567", email: "ahmed@example.com", ... }, session }

Login Flow (Returning User) ​

1. POST /auth/login     { phone: "+966501234567" }  — or —  { email: "ahmed@example.com" }
   → 200 { message: "If the account is eligible, a code has been sent." }

2. POST /auth/verify-otp { phone/email, otp: "123456" }
   → 200 { user, session }

Phone Change Flow ​

1. POST /auth/change-phone  { newPhone: "+966509876543" }
   → 200 { message: "OTP sent to new number" }

2. POST /auth/verify-change-phone { newPhone: "+966509876543", otp: "654321" }
   → 200 { data: { phone: "+966509876543", ... }, message: "Phone number changed successfully" }
1. POST /auth/magic-link  { token: "<raw-invite-token>" }
   → 200 { user, session, b2b: { nextStep, packageId, activationId, consentsRequired } }

2. POST /auth/consents { consents: [...] }   — only if consentsRequired is non-empty
   → 201 { data: { recorded: true } }

OTP Rate Limits ​

ParameterDefaultDescription
OTP_MAX_REQUESTS3Max OTP requests per phone/email per window
OTP_WINDOW_MINUTES15Rate limit window in minutes
OTP_MAX_VERIFY_ATTEMPTS5Max verification attempts per OTP
OTP_TTL_SECONDS300OTP expiry (5 minutes)
OTP_LENGTH6Number of digits in OTP code

App Client Mapping ​

AppClient typeSession behavior
apps/adminwebhttpOnly cookie session for admin/B2B browser flows
apps/web-b2cwebhttpOnly cookie session for customer browser activation
apps/mobilemobileJWT returned in response body for native storage/authorization header use
apps/sitepublic/unauthenticated by defaultNo auth session unless future public account flows are added

Do not trust a user, company, or role identifier supplied by any client. Ownership and authorization must be derived from the verified session in API middleware/services.

Internal documentation - Activation System