HOCO B2B
SRSAPI reference

Authentication & sessions

Sign-in for the four clients, OTP codes, reseller sign-up with customer type and account claim, password reset and change, guest access and session management.

Every HOCO client authenticates through this group: the reseller app and the reseller web (resellers), the delegate app (délégués — field sales representatives) and the back-office (staff). The server wraps Better Auth (User, Session, Account, Verification), but clients only ever see the operations below — never Better Auth's own routes.

Key rules, detailed per operation:

  • One account, one app family. auth.login carries the app the user is signing in to; a reseller cannot open a session in the delegate app or the back-office, and so on (403 WRONG_APP).
  • Mobile gets a bearer token, web gets a cookie. Mobile apps store the token in secure storage (flutter_secure_storage) and send Authorization: Bearer …; the web apps never see the token — the API sets the httpOnly cookie hoco.session_token on .hoco.dz.
  • Guests have no session at all. The reseller app and web can be browsed without an account (RA-41, AUTH-10): the app simply calls the operations marked optional (catalog, facets, promotions, banners, support.whatsapp, app config, reference data) without an Authorization header and receives no prices. Nothing in this group is called for guest browsing.
  • Resellers sign up themselves with a phone verified by SMS code, their store details and their declared customer type (SUPER_GROS, DEMI_GROS, DETAIL). If the verified phone already belongs to a point of sale known to HOCO (created in the field, in the back-office or imported), the account claims that record instead of creating a duplicate (AUTH-05, CRM-03).
  • Staff and delegate accounts are created by the back-office (admin.users.create, admin.delegates.crud); they only sign in, reset or change their password here.
  • Secrets are never stored in idempotency records. auth.login, auth.otp.verify, auth.signup.reseller and auth.password.reset ignore Idempotency-Key: their responses carry tokens, and their own single-use rules make retries safe.
MethodPathOperationAccessAppsRelease
POST/auth/loginauth.login
Sign in with phone (or email for staff) and password; returns a session token
publicRA · RW · DA · BOV1
POST/auth/logoutauth.logout
Revoke the current session and unregister the device push token
sessionRA · RW · DA · BOV1
GET/auth/sessionauth.session
Current session and its expiry
sessionRA · RW · DA · BOV1
POST/auth/otp/sendauth.otp.send
Send a one-time code by SMS for sign-up, password reset or phone change
publicRA · RW · DA · BOV1
POST/auth/otp/verifyauth.otp.verify
Verify a one-time code; returns a short-lived verification token
publicRA · RW · DA · BOV1
POST/auth/signup/resellerauth.signup.reseller
Create (or claim) a reseller account from a verified phone with the declared customer type; returns a session
publicRA · RWV1
POST/auth/password/resetauth.password.reset
Set a new password using a verification token
publicRA · RW · DA · BOV1
POST/auth/password/changeauth.password.change
Change password (current password required); other sessions revoked
sessionRA · RW · DA · BOV1
GET/auth/sessionsauth.sessions.list
List my active sessions (device, app, last activity)
sessionRA · RW · DA · BOV1
DELETE/auth/sessions/{sessionId}auth.sessions.revoke
Revoke one of my sessions
sessionRA · RW · DA · BOV1

Besides the errors listed per operation, every operation can answer the platform errors 426 UPGRADE_REQUIRED, 429 RATE_LIMITED, 500 INTERNAL_ERROR and 503 MAINTENANCE; operations that need a session can also answer 401 UNAUTHENTICATED and 403 ACCOUNT_SUSPENDED. See the error registry.

Apps, user types and sessions

app sent to auth.loginUser type allowedClientSession clientAppTransportSession lifetime
RESELLERRESELLERReseller app (dz.hoco.reseller)RESELLER_APPBearer token30 days, sliding
RESELLERRESELLERReseller web (app.hoco.dz)RESELLER_WEBCookie30 days, sliding
DELEGATEDELEGATEDelegate app (dz.hoco.delegate)DELEGATE_APPBearer token30 days, sliding
DASHBOARDSTAFFBack-office (admin.hoco.dz)DASHBOARDCookie7 days, sliding; re-authentication for sensitive actions
  • Transport selection. A request that carries X-App-Platform (android / ios) is a mobile request: the response body contains token and no cookie is set. A request without it is a browser request: the API sets the cookie and leaves token out of the body.
  • Sliding expiry. Every authenticated request extends expiresAt by the full lifetime, at most once per 24 hours. There is no absolute maximum in V1 (back-office lifetimes: D-33).
  • Cookie. hoco.session_token=…; Domain=.hoco.dz; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000 (604800 for the back-office). The API accepts cookie-authenticated mutations only from the allowed origins https://app.hoco.dz and https://admin.hoco.dz (CORS with credentials + Origin check). One browser holds one HOCO session: each web app ignores a session whose clientApp is not its own and offers to sign out.
  • Re-authentication (back-office). Working default of D-33 and AUTH-01: role and permission changes, business settings, credit limits, user status changes, imports and exports of personal or financial data require that the password was entered less than 15 minutes ago (SessionInfo.freshUntil); otherwise they answer 403 REAUTH_REQUIRED and the back-office asks the password again through auth.login, which replaces the session. Two-factor authentication for sensitive roles is option 2 of the same decision.
  • Mobile headers. Mobile calls also send X-App-Version, X-App-Platform and X-Device-Id (see request conventions). The session remembers the device; a new sign-in from the same X-Device-Id for the same app replaces the previous session of that device.

Account status gates

AccountStatusauth.loginOther calls
PENDING_VALIDATIONAllowed (resellers only; skipped when the setting crm.validationRequired is false)Reads allowed; prices follow the setting catalog.pricesVisibleBeforeValidation (D-18 — default true: the declared customer type's prices). cart.checkout, assisted-order confirmation and payment declarations answer 403 ACCOUNT_PENDING_VALIDATION. The apps show RA-06 on the account tab and a banner on checkout.
ACTIVEAllowedFull access for the user's role and scope.
SUSPENDED403 ACCOUNT_SUSPENDED (details.status: "SUSPENDED")All sessions are revoked when the back-office suspends the account; any call answers 403 ACCOUNT_SUSPENDED.
DISABLED403 ACCOUNT_SUSPENDED (details.status: "DISABLED")Same as suspended (also the result of a final rejection at validation); history is kept (SEC-06).

The reseller's commercial status (ResellerStatus.SUSPENDED, e.g. unpaid invoices) is different: the account stays ACTIVE, the owner signs in, reads orders and invoices and declares payments, but checkout answers 403 RESELLER_SUSPENDED (AUTH-08).

Passwords and one-time codes

  • Password policy (AUTH-07): 8 to 128 characters, at least one letter and one digit, not containing the phone's 9 national digits (555123456 for +213555123456), not in the bundled list of the 10 000 most common passwords; no forced periodic change. Field codes in 400 VALIDATION_FAILED: PASSWORD_TOO_SHORT, PASSWORD_TOO_WEAK, PASSWORD_TOO_COMMON, PASSWORD_REUSED (same as the current password). Hashed with scrypt (Better Auth default); passwords, codes and tokens are never logged — bodies of auth.* requests are excluded from request logs.
  • One-time codes: 6 random digits, valid 5 minutes, 5 attempts per code, one code per destination and purpose at a time (a new code invalidates the previous one). Sending: 1 per 60 s and 5 per hour per phone, 20 per hour per IP. Codes go by SMS; staff may receive a password-reset code by email.
  • Verification token: a successful auth.otp.verify returns a single-use verificationToken bound to the destination and the purpose, valid 10 minutes (the requirements page says 15 — to reconcile). It is consumed by auth.signup.reseller (SIGNUP), auth.password.reset (PASSWORD_RESET) or me.update (PHONE_CHANGE).
  • Non-production environments (and staging, which runs NODE_ENV=production with SIMULATE_SMS=true): no SMS is sent; the message is written to the server log and, when an SMTP server is set, copied to Mailpit as an e-mail to <digits>@sms.hoco.invalid (213555010001@sms.hoco.invalid, subject SMS +213555010001). The test numbers +213555000001 to +213555000009 accept the fixed code 123456, so Imad and the QA team can run the flows without an SMS budget. A staff password reset asked with email sends the code by e-mail.

Sign-up and claim flow

The record resolution at sign-up (claim by phone or new record) is detailed under auth.signup.reseller; attribution to the inviting delegate follows the attribution precedence.

Me object

Me is the signed-in user as every client needs it at start-up: identity, account status, roles and permissions, and the reseller or delegate context. It is returned by auth.login, auth.signup.reseller, auth.session, me.get and me.update. This is the only definition; other pages reference it.

type UserType = "STAFF" | "DELEGATE" | "RESELLER";
type AccountStatus = "PENDING_VALIDATION" | "ACTIVE" | "SUSPENDED" | "DISABLED";
type CustomerType = "SUPER_GROS" | "DEMI_GROS" | "DETAIL";
type Locale = "fr" | "ar" | "en";

interface Me {
  id: Uuid;                          // User.id
  type: UserType;
  displayName: string;               // User.name — resellers: the store manager's name
  phone: string | null;              // E.164 — sign-in identifier of resellers and delegates
  phoneVerified: boolean;
  email: string | null;              // sign-in identifier of staff (their phone also works)
  locale: Locale;                    // language of push, SMS and email sent to this user
  status: AccountStatus;
  roles: string[];                   // RoleKey values, e.g. ["RESELLER"], ["COMMERCIAL_ADMIN", "FINANCE_MANAGER"]
  permissions: string[];             // effective "<module>.<action>" keys of all roles
  mustChangePassword: boolean;       // true after a reset by an administrator
  avatar: FileRef | null;            // purpose AVATAR; signed URL valid 5 minutes
  consents: {
    termsVersion: string | null;     // version of the terms accepted (resellers)
    termsAcceptedAt: Iso | null;
    locationTrackingVersion: string | null;   // delegates (DA-04)
    locationTrackingAcceptedAt: Iso | null;
  };
  supportRegion: {                   // resellers: region of the declared wilaya; null for staff and delegates
    id: Uuid;
    name: string;                    // "Ouest"
    whatsappNumber: string;          // E.164
  } | null;
  reseller?: {                       // present when type = RESELLER
    id: Uuid;                        // Reseller.id
    number: string;                  // REV-000123
    storeName: string;
    customerType: CustomerType;      // selects the price of every variant
    status: string;                  // ResellerStatus (commercial lifecycle)
    validationStatus: "PENDING" | "APPROVED" | "REJECTED";   // ValidationStatus
    rejectionReason: string | null;  // set when validationStatus = REJECTED
    canResubmit: boolean;            // REJECTED but correctable: the owner fixes the store profile, which re-enters the queue
    wilayaCode: string;              // "31"
    assignedDelegate: (UserSummary & { phone: string }) | null;   // phone = DelegateProfile.phone
  };
  delegate?: {                       // present when type = DELEGATE
    id: Uuid;                        // DelegateProfile.id (equals User.id)
    code: string;                    // DLG-0007
    zones: { id: Uuid; name: string; wilayaCodes: string[] }[];
  };
  createdAt: Iso;
  lastLoginAt: Iso | null;
}
FieldNotes
permissionsThe back-office builds its navigation and hides actions from this list (BO-03, ACL-05). Resellers have an empty list: their access is the self scope. Delegates receive the keys granted to the DELEGATE role in the permissions matrix, always applied inside the delegate scope. The server re-checks every call — the list is a UI hint.
mustChangePasswordSet when an administrator resets the password (admin.users.resetPassword) or creates the account with a temporary password. While true, every operation except auth.password.change, auth.logout, auth.session, me.get and appConfig.get answers 403 PASSWORD_CHANGE_REQUIRED; the app shows the change-password screen right after sign-in.
reseller.customerTypeDeclared at sign-up, reviewed by the validator at admin.resellers.validate, changed afterwards only by the back-office (audited, AUTH-11). The reseller cannot change it (profile.update refuses the field with 403 FIELD_NOT_EDITABLE, as in the permissions matrix). When it changes, the cart is re-priced and the app refreshes Me (the server pushes notification.created for account.customer_type_changed).
reseller.statusApps act on one value only: SUSPENDED → ordering blocked, show the suspended banner with the support contacts. The other values are CRM information and are not displayed to the reseller.
reseller.validationStatusPENDING → RA-06 "en attente" state on the account tab and the checkout banner; REJECTED → RA-06 rejected state with rejectionReason, the « Corriger mes informations » action when canResubmit is true, and the WhatsApp/support buttons (a final rejection disables the account instead); APPROVED → normal.
supportRegionComputed from the wilaya declared in the reseller's account (SupportRegionWilaya). The fixed WhatsApp button opens https://wa.me/<number without +> directly from this value; guests use support.whatsapp with their location instead (SUP-05). null when no region covers the wilaya — the button then uses settings.support.defaultWhatsappNumber from appConfig.get.
consentsThe reseller app compares termsVersion with legal.termsVersion from appConfig.get and asks for re-acceptance when they differ. The delegate app shows the location consent (DA-04) until locationTrackingAcceptedAt is set (SEC-07). Both are written through me.update.
avatarA signed URL valid 5 minutes. Cache the image by avatar.id, not by URL (the URL changes on every response).
{
  "id": "01a19c3e-5a10-7b21-8f4e-2c6d9a1b3e57",
  "type": "RESELLER",
  "displayName": "Mohamed Benali",
  "phone": "+213555123456",
  "phoneVerified": true,
  "email": null,
  "locale": "fr",
  "status": "PENDING_VALIDATION",
  "roles": ["RESELLER"],
  "permissions": [],
  "mustChangePassword": false,
  "avatar": null,
  "consents": {
    "termsVersion": "2026-11",
    "termsAcceptedAt": "2026-11-03T08:12:00Z",
    "locationTrackingVersion": null,
    "locationTrackingAcceptedAt": null
  },
  "supportRegion": {
    "id": "01a0c7e2-41d0-7a6b-9c3e-5f1a2b3c4d5e",
    "name": "Ouest",
    "whatsappNumber": "+213770900310"
  },
  "reseller": {
    "id": "01a17f02-93c4-7e8a-b1d2-4f5a6b7c8d90",
    "number": "REV-000123",
    "storeName": "Magasin El Wafa",
    "customerType": "DEMI_GROS",
    "status": "ACCOUNT_CREATED",
    "validationStatus": "PENDING",
    "rejectionReason": null,
    "canResubmit": false,
    "wilayaCode": "31",
    "assignedDelegate": {
      "id": "01a0f2b1-7d3c-7a45-9e61-0b2c3d4e5f60",
      "displayName": "Yacine Belkacem",
      "phone": "+213770123456"
    }
  },
  "createdAt": "2026-11-03T08:12:00Z",
  "lastLoginAt": "2026-11-03T08:12:00Z"
}

SessionInfo object

interface SessionInfo {
  id: Uuid;
  clientApp: "RESELLER_APP" | "RESELLER_WEB" | "DELEGATE_APP" | "DASHBOARD";   // ClientApp
  current: boolean;                  // the session that made this request
  createdAt: Iso;
  lastActiveAt: Iso;                 // refreshed at most once per minute
  expiresAt: Iso;                    // sliding
  freshUntil: Iso | null;            // DASHBOARD only: sensitive actions allowed until then
  device: {
    deviceId: string | null;         // X-Device-Id (mobile)
    platform: "android" | "ios" | "web";
    name: string | null;             // Device.model for mobile ("Samsung SM-A155F"), browser and OS parsed from the user agent for web
    appVersion: string | null;
  };
  ipAddress: string | null;          // last IP seen, truncated: "41.104.x.x"
}

Operations

POST /auth/login

auth.login · Access public · Apps RA, RW, DA, BO · Idempotency-Key — (ignored) · Release V1 · Requirements AUTH-01, AUTH-02, AUTH-04, AUTH-08

Purpose. Opens a session for a reseller, a delegate or a staff member with an identifier and a password, after checking that the account belongs to the app it is signing in to and that its status allows it.

Request

HeaderValue
X-App-Platform, X-App-Version, X-Device-IdMobile only. Their presence selects token mode; the device id is stored on the session.
interface LoginRequest {
  identifier: string;                // phone in any accepted format ("0555 12 34 56", "+213555123456"), or email (staff only)
  password: string;
  app: "RESELLER" | "DELEGATE" | "DASHBOARD";
}
{
  "identifier": "0555 12 34 56",
  "password": "ElWafa2026!",
  "app": "RESELLER"
}

Response — 200 OK.

interface AuthResult {
  token?: string;                    // mobile only; never present in cookie mode
  expiresAt: Iso;
  sessionId: Uuid;
  user: Me;
}
{
  "token": "hs_9fK2mQ7vX4pL8sT1nB6cR3wY0zD5hJ2a",
  "expiresAt": "2026-12-05T07:45:10Z",
  "sessionId": "01a1a077-1080-7c3e-9b2f-3d4e5f6a7b8c",
  "user": {
    "id": "01a19c3e-5a10-7b21-8f4e-2c6d9a1b3e57",
    "type": "RESELLER",
    "displayName": "Mohamed Benali",
    "phone": "+213555123456",
    "phoneVerified": true,
    "email": null,
    "locale": "fr",
    "status": "ACTIVE",
    "roles": ["RESELLER"],
    "permissions": [],
    "mustChangePassword": false,
    "avatar": null,
    "consents": {
      "termsVersion": "2026-11",
      "termsAcceptedAt": "2026-11-03T08:12:00Z",
      "locationTrackingVersion": null,
      "locationTrackingAcceptedAt": null
    },
    "supportRegion": { "id": "01a0c7e2-41d0-7a6b-9c3e-5f1a2b3c4d5e", "name": "Ouest", "whatsappNumber": "+213770900310" },
    "reseller": {
      "id": "01a17f02-93c4-7e8a-b1d2-4f5a6b7c8d90",
      "number": "REV-000123",
      "storeName": "Magasin El Wafa",
      "customerType": "DEMI_GROS",
      "status": "FIRST_ORDER",
      "validationStatus": "APPROVED",
      "rejectionReason": null,
      "canResubmit": false,
      "wilayaCode": "31",
      "assignedDelegate": { "id": "01a0f2b1-7d3c-7a45-9e61-0b2c3d4e5f60", "displayName": "Yacine Belkacem", "phone": "+213770123456" }
    },
    "createdAt": "2026-11-03T08:12:00Z",
    "lastLoginAt": "2026-11-05T07:45:10Z"
  }
}

In cookie mode (web) the body has no token and the response carries Set-Cookie: hoco.session_token=…; Domain=.hoco.dz; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000.

FieldNotes
tokenOpaque, prefixed hs_. Store it in secure storage; never log it. Send it as Authorization: Bearer hs_….
expiresAtInformative: the expiry slides with use. The app does not need to refresh the token; it handles 401 instead.
userThe full Me — route on user.mustChangePassword, user.status and user.reseller.validationStatus right away, without an extra me.get.

Errors

StatusCodeWhen
400VALIDATION_FAILEDidentifier is neither a phone number nor an email (field code INVALID_IDENTIFIER), password empty, unknown app.
401INVALID_CREDENTIALSUnknown identifier, wrong password, or an email that does not belong to a staff account. One message for all cases: « Numéro ou mot de passe incorrect. »
403WRONG_APPCorrect credentials but the account type does not match app. details.userType ("DELEGATE") lets the app say « Ce compte est un compte délégué : utilisez l'application HOCO Délégué. »
403ACCOUNT_SUSPENDEDAccount SUSPENDED or DISABLED (details.status, details.supportPhone, details.supportWhatsapp). The app shows the explicit message with these contacts (AUTH-02).
429RATE_LIMITEDMore than 5 attempts in a minute for this identifier and IP (Retry-After).
429AUTH_LOCKED5 consecutive failures for this identifier from this IP: sign-in locked 15 minutes (details.lockedUntil, Retry-After) — « Trop de tentatives. Réessayez dans 15 minutes ou réinitialisez votre mot de passe. »
503MAINTENANCEThe requested app is in maintenance (details.until, details.message); never for DASHBOARD.

Rules & side effects

  1. Normalisation: an identifier containing @ is an email (trimmed, lower-cased) and is looked up among STAFF users only; anything else is normalised to E.164 (0555 12 34 56, 555123456, 00213555123456, +213 555 12 34 56 → +213555123456).
  2. The password is checked before the app and the status, so WRONG_APP and ACCOUNT_SUSPENDED are only revealed to someone who knows the password. An unknown identifier still runs a dummy hash comparison so response times do not reveal which phones have accounts.
  3. Order of checks after the password: app versus user type → 403 WRONG_APP; status SUSPENDED/DISABLED → 403 ACCOUNT_SUSPENDED; PENDING_VALIDATION and ACTIVE sign in.
  4. A Session is created with clientApp (RESELLER + mobile → RESELLER_APP, RESELLER + browser → RESELLER_WEB, DELEGATE → DELEGATE_APP, DASHBOARD → DASHBOARD), device id, platform, app version, IP and user agent (Session.app, deviceId, appVersion, ipAddress, userAgent). An existing session of the same device and app is revoked first. A user holds at most 10 active sessions; an 11th revokes the least recently used one.
  5. Failure counters are kept per identifier + IP in Redis; a successful sign-in resets them.
  6. Audit: LOGIN on success, LOGIN_FAILED on failure with the reason (INVALID_PASSWORD, UNKNOWN_IDENTIFIER, WRONG_APP, SUSPENDED, LOCKED), IP and client app (AUD-01). User.lastLoginAt is updated.
  7. Sign-in from an X-Device-Id (or, on the web, a browser) not seen for this user in 90 days — except the very first sign-in — emits security.new_device (category SECURITY: in-app, push to the user's other devices, email for staff).
  8. After a successful mobile sign-in the app calls me.devices.register with its FCM token, then opens the events.stream (reseller app).

Used by — RA-07 (« Se connecter »), DA-02, BO-01, RW-01; also the last step of the password-reset screens RA-08, DA-03, BO-02, and the guest-to-account prompts of RA-41 (« J'ai déjà un compte »).

POST /auth/logout

auth.logout · Access session (any user type) · Apps RA, RW, DA, BO · Idempotency-Key — (naturally idempotent) · Release V1 · Requirements AUTH-06

Purpose. Ends the current session and stops push notifications to this device.

Request — no body. The session is the one identified by the bearer token or the cookie.

Response — 204 No Content. Web: Set-Cookie: hoco.session_token=; Domain=.hoco.dz; Path=/; Max-Age=0.

Errors — none specific: an expired, revoked or unknown token also answers 204, so the client can always clear its local state.

Rules & side effects

  1. The Session is revoked; the Device bound to it loses its push token and is detached from the user (no more push to this phone).
  2. The open events.stream connection of this session is closed by the server.
  3. Audit LOGOUT.
  4. Client side: the reseller app clears its token and cached Me, keeps the catalog cache and returns to guest browsing (RA-02). The delegate app refuses to sign out while its outbox holds unsynchronised actions and sends the user to the sync centre (DA-27) — signing out would lose them.

Used by — settings screens RA-35, DA-26, RW-12 and the back-office user menu (BO-03): « Se déconnecter ».

GET /auth/session

auth.session · Access session (any user type) · Apps RA, RW, DA, BO · Idempotency-Key — · Release V1 · Requirements AUTH-06

Purpose. Tells whether the current session is valid, when it expires, and who the user is — the check the web apps' middleware runs on navigation and server-side rendering.

Request — no parameters.

Response — 200 OK, Cache-Control: no-store.

{
  "session": {
    "id": "01a1a077-1080-7c3e-9b2f-3d4e5f6a7b8c",
    "clientApp": "DASHBOARD",
    "current": true,
    "createdAt": "2026-11-05T07:45:10Z",
    "lastActiveAt": "2026-11-05T09:02:44Z",
    "expiresAt": "2026-11-12T07:45:10Z",
    "freshUntil": "2026-11-05T08:00:10Z",
    "device": { "deviceId": null, "platform": "web", "name": "Chrome 141 · Windows", "appVersion": null },
    "ipAddress": "105.235.x.x"
  },
  "user": {
    "id": "01a0d5c2-3b4a-7f19-8e27-6d5c4b3a2f10",
    "type": "STAFF",
    "displayName": "Samira Haddad",
    "phone": "+213661445566",
    "phoneVerified": true,
    "email": "samira.haddad@hoco.dz",
    "locale": "fr",
    "status": "ACTIVE",
    "roles": ["COMMERCIAL_ADMIN"],
    "permissions": ["dashboard.view", "crm.view", "crm.create", "crm.update", "crm.validate", "orders.view", "orders.validate", "reports.view"],
    "mustChangePassword": false,
    "avatar": null,
    "consents": { "termsVersion": null, "termsAcceptedAt": null, "locationTrackingVersion": null, "locationTrackingAcceptedAt": null },
    "supportRegion": null,
    "createdAt": "2026-10-20T08:30:00Z",
    "lastLoginAt": "2026-11-05T07:45:10Z"
  }
}

user is the complete Me object.

Errors

StatusCodeWhen
401UNAUTHENTICATEDNo session, or it expired or was revoked. details.reason: MISSING, EXPIRED, REVOKED.

Rules & side effects

  1. Counts as activity: slides expiresAt (at most once per 24 h) and refreshes lastActiveAt (at most once per minute).
  2. The mobile apps do not need this call: they call me.get at start-up, which fails with the same 401 when the session is gone.

Used by — the Next.js middleware of the back-office and the reseller web (every protected page); the session panel of BO-68.

POST /auth/otp/send

auth.otp.send · Access public (PHONE_CHANGE requires a session) · Apps RA, RW, DA, BO · Idempotency-Key — (ignored; the resend cooldown protects it) · Release V1 · Requirements AUTH-03, AUTH-04, AUTH-07

Purpose. Sends a 6-digit one-time code to a phone (or, for staff password resets, to an email) for sign-up, password reset or phone change.

Request

interface OtpSendRequest {
  purpose: "SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE";   // OtpPurpose
  phone?: string;                    // required unless email is given
  email?: string;                    // PASSWORD_RESET of a staff account only — the code is sent by email
  app: "RESELLER" | "DELEGATE" | "DASHBOARD";               // selects the SMS/email template
  androidAppHash?: string;           // 11-character app signature hash (SMS Retriever API auto-fill)
}
{
  "purpose": "SIGNUP",
  "phone": "0555 12 34 56",
  "app": "RESELLER",
  "androidAppHash": "FA+9qCX9VSu"
}

Response — 200 OK, the same body whatever the phone's situation (new, known point of sale, existing account).

{
  "channel": "SMS",
  "destination": "+213 555 •• •• 56",
  "expiresAt": "2026-11-03T08:05:30Z",
  "expiresInSeconds": 300,
  "resendAvailableAt": "2026-11-03T08:01:30Z",
  "cooldownSeconds": 60,
  "codeLength": 6
}
FieldNotes
destinationMasked from the input (never from a stored record), for the RA-04 sentence « Code envoyé au +213 555 •• •• 56 ».
resendAvailableAt, cooldownSecondsRA-04 shows « Renvoyer le code dans 0:42 » until then. Use cooldownSeconds when the device clock is unreliable.

Errors

StatusCodeWhen
400VALIDATION_FAILEDNot an Algerian mobile number (field code INVALID_PHONE), both or neither of phone/email, email with a purpose other than PASSWORD_RESET.
401UNAUTHENTICATEDPHONE_CHANGE without a session.
409PHONE_ALREADY_EXISTSPHONE_CHANGE only: the new number belongs to another account or another point of sale. (SIGNUP never reveals it — see rule 3.)
429OTP_RESEND_TOO_SOONLess than 60 s since the previous code for this destination and purpose (details.retryAfterSeconds).
429RATE_LIMITED5 codes in the last hour for this phone, or 20 for this IP.
503OTP_DELIVERY_FAILEDThe SMS gateway or the mail server refused or timed out. Nothing is counted against the limits; retry after Retry-After.

Rules & side effects

  1. Phones must be Algerian mobiles: after normalisation +213 followed by 9 digits starting with 5, 6 or 7.
  2. A new code replaces any pending code for the same destination and purpose. The code is stored hashed in Verification (identifier otp:<purpose>:<destination>, 5-minute expiry, attempt counter).
  3. SIGNUP: a code is sent whatever the phone's situation — unknown, a point of sale without an account (claim) or an existing account — and the answer is byte-identical, so the operation cannot reveal who is a customer. The situation is only disclosed after the code is verified (signupContext.mode).
  4. PASSWORD_RESET: if the phone or email matches no account, or a SUSPENDED/DISABLED one, the API answers the same 200 without sending anything, so the operation cannot be used to discover accounts. Email destinations only match staff.
  5. PHONE_CHANGE: the session's user is recorded on the code; only that user can verify it.
  6. SMS text (French template, ar/en by Accept-Language): « HOCO : votre code est 482913. Il expire dans 5 minutes. Ne le communiquez à personne. », followed by the androidAppHash line when provided. Every send is logged as a NotificationDelivery (channel SMS) for cost follow-up (INT-07).

Used by — RA-03 (« Recevoir le code »), RA-04 (« Renvoyer le code »), RA-08, DA-03, BO-02, RW-01; phone change from RA-35 and RW-12.

POST /auth/otp/verify

auth.otp.verify · Access public (PHONE_CHANGE requires the session that requested the code) · Apps RA, RW, DA, BO · Idempotency-Key — (ignored) · Release V1 · Requirements AUTH-03, AUTH-07

Purpose. Checks the code typed by the user and exchanges it for a short-lived verification token; for sign-up, also tells whether the phone matches a point of sale that can be claimed.

Request

interface OtpVerifyRequest {
  purpose: "SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE";
  phone?: string;                    // same destination as auth.otp.send
  email?: string;
  code: string;                      // 6 digits
}
{ "purpose": "SIGNUP", "phone": "+213555123456", "code": "482913" }

Response — 200 OK.

interface OtpVerifyResult {
  verificationToken: string;         // single use, bound to destination + purpose
  expiresAt: Iso;                    // 10 minutes
  purpose: "SIGNUP" | "PASSWORD_RESET" | "PHONE_CHANGE";
  phone: string | null;              // normalised E.164 (null for an email destination)
  signupContext?: {                  // purpose SIGNUP only
    mode: "NEW" | "CLAIM" | "EXISTING_ACCOUNT";
    prefill: {                       // mode CLAIM only: the known point of sale
      storeName: string;
      ownerName: string | null;
      businessTypeCode: string | null;
      customerType: CustomerType | null;   // when a delegate already recorded one
      wilayaCode: string | null;
      communeId: Uuid | null;
      communeName: string | null;
      addressLine: string | null;
    } | null;
    delegateFirstName: string | null;      // mode CLAIM: the delegate who created the record
  };
}
{
  "verificationToken": "vt_7Qm2Xc9LpR4sT8vB1nK6",
  "expiresAt": "2026-11-03T08:11:40Z",
  "purpose": "SIGNUP",
  "phone": "+213555123456",
  "signupContext": {
    "mode": "CLAIM",
    "prefill": {
      "storeName": "Magasin El Wafa",
      "ownerName": "Mohamed Benali",
      "businessTypeCode": "PHONE_SHOP",
      "customerType": "DEMI_GROS",
      "wilayaCode": "31",
      "communeId": "01a0c3e1-5d20-7a10-8b31-310300000003",
      "communeName": "Bir El Djir",
      "addressLine": "Cité USTO, local n° 14"
    },
    "delegateFirstName": "Yacine"
  }
}
FieldNotes
signupContext.modeNEW → empty RA-05 form. CLAIM → RA-05 pre-filled from prefill with « Nous avons trouvé votre magasin, enregistré par Yacine. Vérifiez vos informations. » EXISTING_ACCOUNT → no form: offer « Se connecter » and « Mot de passe oublié » (auth.signup.reseller would answer 409 PHONE_ALREADY_EXISTS).
signupContext.prefillOnly returned after the code proved that the caller holds the phone; never by auth.otp.send or a failed verification.

Errors

StatusCodeWhen
400VALIDATION_FAILEDCode not 6 digits, destination missing.
422OTP_INVALIDWrong code (details.attemptsLeft).
422OTP_EXPIREDNo pending code for this destination and purpose: expired (5 min), replaced by a newer one, or already used. Ask for a new code.
429TOO_MANY_ATTEMPTSFifth wrong attempt: the code is destroyed; a new one must be requested.

Rules & side effects

  1. Constant-time comparison; each wrong attempt decrements the counter; success destroys the code.
  2. The token is stored hashed in Verification (identifier vt:<purpose>:<destination>); it can be consumed once, by the operation of its purpose only.
  3. No account change happens here; the invitation step PHONE_VERIFIED is recorded at sign-up, together with ACCOUNT_CREATED.

Used by — RA-04 (auto-submit at the 6th digit), RA-08, DA-03, BO-02, RW-01, and the phone-change dialog of RA-35.

POST /auth/signup/reseller

auth.signup.reseller · Access public (verification token required) · Apps RA, RW · Idempotency-Key — (ignored; the single-use token prevents duplicates) · Release V1 · Requirements AUTH-03, AUTH-05, AUTH-11, CRM-02, CRM-03, IVT-04

Purpose. Creates the reseller's user account from a verified phone, the store details and the declared customer type — claiming the existing point of sale when HOCO already knows the phone — and opens a session. The account starts PENDING_VALIDATION until the back-office validates it.

Request

interface ResellerSignupRequest {
  verificationToken: string;         // auth.otp.verify, purpose SIGNUP — carries the verified phone
  password: string;
  ownerName: string;                 // 2–80 characters
  customerType: "SUPER_GROS" | "DEMI_GROS" | "DETAIL";   // declared; reviewed at validation
  store: {
    name: string;                    // 2–120 characters
    businessTypeCode: string;        // BUSINESS_TYPE item (signup.businessTypes in appConfig.get)
    wilayaCode: string;              // "31"
    communeId: Uuid;                 // must belong to wilayaCode
    addressLine: string;             // 5–200 characters
    landmark?: string;               // "En face de la pharmacie"
    location?: GeoPoint;             // phone GPS, when the user allowed it
    whatsapp?: string;               // E.164; defaults to the verified phone when omitted
    email?: string;
  };
  invitationCode?: string;           // "AB12-CD34" or "ab12cd34" — attribution only, never ownership
  locale?: "fr" | "ar" | "en";       // default: Accept-Language
  acceptedTermsVersion: string;      // legal.termsVersion of appConfig.get, shown on the form
}
{
  "verificationToken": "vt_7Qm2Xc9LpR4sT8vB1nK6",
  "password": "ElWafa2026!",
  "ownerName": "Mohamed Benali",
  "customerType": "DEMI_GROS",
  "store": {
    "name": "Magasin El Wafa",
    "businessTypeCode": "PHONE_SHOP",
    "wilayaCode": "31",
    "communeId": "01a0c3e1-5d20-7a10-8b31-310300000003",
    "addressLine": "Cité USTO, local n° 14",
    "landmark": "En face de la pharmacie Benyahia",
    "location": { "lat": 35.7079, "lng": -0.5821, "accuracy": 18, "capturedAt": "2026-11-03T08:10:52Z" },
    "whatsapp": "+213555123456"
  },
  "invitationCode": "AB12-CD34",
  "acceptedTermsVersion": "2026-11"
}

Response — 201 Created: an AuthResult plus the claim outcome.

interface SignupResult extends AuthResult {
  claimed: boolean;                  // true when an existing point of sale was linked
  invitation: { code: string; delegateFirstName: string } | null;   // attributed invitation
  invitationCodeIgnored: "NOT_FOUND" | "EXPIRED" | "ALREADY_USED" | "OTHER_POINT_OF_SALE" | null;
}
{
  "token": "hs_2Lr8Wq5Zt1Yk7Nv3Pc9Xb4Md6Hs0Jf8a",
  "expiresAt": "2026-12-03T08:12:00Z",
  "sessionId": "01a19c3e-5b22-7d40-a1e3-7f6e5d4c3b2a",
  "claimed": true,
  "invitation": { "code": "AB12CD34", "delegateFirstName": "Yacine" },
  "invitationCodeIgnored": null,
  "user": {
    "id": "01a19c3e-5a10-7b21-8f4e-2c6d9a1b3e57",
    "type": "RESELLER",
    "displayName": "Mohamed Benali",
    "phone": "+213555123456",
    "phoneVerified": true,
    "email": null,
    "locale": "fr",
    "status": "PENDING_VALIDATION",
    "roles": ["RESELLER"],
    "permissions": [],
    "mustChangePassword": false,
    "avatar": null,
    "consents": {
      "termsVersion": "2026-11",
      "termsAcceptedAt": "2026-11-03T08:12:00Z",
      "locationTrackingVersion": null,
      "locationTrackingAcceptedAt": null
    },
    "supportRegion": { "id": "01a0c7e2-41d0-7a6b-9c3e-5f1a2b3c4d5e", "name": "Ouest", "whatsappNumber": "+213770900310" },
    "reseller": {
      "id": "01a17f02-93c4-7e8a-b1d2-4f5a6b7c8d90",
      "number": "REV-000123",
      "storeName": "Magasin El Wafa",
      "customerType": "DEMI_GROS",
      "status": "ACCOUNT_CREATED",
      "validationStatus": "PENDING",
      "rejectionReason": null,
      "canResubmit": false,
      "wilayaCode": "31",
      "assignedDelegate": { "id": "01a0f2b1-7d3c-7a45-9e61-0b2c3d4e5f60", "displayName": "Yacine Belkacem", "phone": "+213770123456" }
    },
    "createdAt": "2026-11-03T08:12:00Z",
    "lastLoginAt": "2026-11-03T08:12:00Z"
  }
}

Errors

StatusCodeWhen
400VALIDATION_FAILEDField codes: PASSWORD_TOO_WEAK, INVALID_PHONE (store.whatsapp), INVALID_EMAIL, COMMUNE_NOT_IN_WILAYA, UNKNOWN_REFERENCE_CODE (store.businessTypeCode), INVALID_ENUM (customerType), TERMS_NOT_ACCEPTED (acceptedTermsVersion is not the current version).
409PHONE_ALREADY_EXISTSThe phone already has an account (signupContext.mode was EXISTING_ACCOUNT), or another device completed the claim a moment earlier.
422VERIFICATION_TOKEN_INVALIDToken unknown, expired (10 min), already used or issued for another purpose (details.reason: EXPIRED, USED, PURPOSE_MISMATCH). The app restarts at RA-03.
429RATE_LIMITEDMore than 10 sign-ups per hour from one IP.

Rules & side effects

  1. Token. The verification token is consumed (single use); the account phone is the token's phone, normalised.

  2. Record resolution — the verified phone is the only proof of ownership; an invitation code links attribution, never ownership:

    CaseResult
    A point of sale has this main phone and no user (any status or source: field prospect, NOT_INTERESTED record, imported customer)Claim — the account is linked to it (Reseller.userId). No second record is created.
    No point of sale has this phoneNew record — source: SELF_SIGNUP, number REV-… from NumberSequence, default ResellerAddress from the store address, zone derived from the commune.
    A point of sale has this phone and a userRefused: 409 PHONE_ALREADY_EXISTS.

    The matching Reseller row is locked (SELECT … FOR UPDATE) and the unique index on the normalised phone is the final guarantee: of two devices claiming at the same time, one gets 201, the other 409.

  3. Claim merge (AUTH-05). What the owner submits updates store name, owner name, address line and landmark, email, WhatsApp and business type; each changed field is written to the reseller's history with the reseller as actor. Data captured by staff or delegates is never overwritten: GPS position, photos, documents, potential, segments, notes, assignment, zone, createdByDelegateId, source, the REV-… number and the creation date (a submitted location only fills an empty one; a different wilaya or commune is shown to the validator, not applied).

  4. Customer type (AUTH-11). The declared customerType is stored on the Reseller; if a delegate had recorded another value, it is kept in the history and highlighted in the validation queue (BO-13). From then on only the back-office changes it. Before validation the reseller sees the prices of the declared type when catalog.pricesVisibleBeforeValidation is true (default; D-18 may instead show the Détail price to everyone until validation); ordering waits for validation in every case.

  5. Account. User (type RESELLER, role RESELLER, phoneVerified: true, locale, terms version and acceptance time) and its credential Account are created in the same transaction. Account status: ACTIVE at once when the record is already APPROVED (a point of sale validated by the back-office, an imported customer) or when crm.validationRequired is false; otherwise PENDING_VALIDATION and the record enters the validation queue (validationStatus: PENDING).

  6. Reseller status. Becomes ACCOUNT_CREATED (automatic ResellerStatusChange) from a pre-account status (NEW_PROSPECT, TO_CONTACT, INTERESTED, APP_INSTALLED, NOT_INTERESTED, TO_FOLLOW_UP); FIRST_ORDER, ACTIVE and SUSPENDED are kept.

  7. Invitation attribution follows the attribution rules: for a claim, the active invitation of that point of sale first, then a valid invitationCode, then the device attribution recorded by invitations.attribute; for a new record, a valid invitationCode, then the device attribution. The attributed invitation records ACCOUNT_CREATED and PHONE_VERIFIED (and APP_INSTALLED if missing and the call comes from the reseller app). An unknown, expired or used code — or the code of another point of sale's invitation — never fails the sign-up: it is ignored for attribution and reported in invitationCodeIgnored (a code of another point of sale is also recorded on that invitation for review).

  8. Assignment (new records). The delegate of the attributed invitation; otherwise the zone's primary delegate; otherwise unassigned. A claimed record keeps its createdByDelegateId and current assignment.

  9. Session. Created exactly as in auth.login (token or cookie, device fields).

  10. Events. account.pending_validation (in-app to the reseller; in-app and back-office live event account.pending to users holding crm.validate) — or account.approved when the account is active at once. For a claim, the creating and the assigned delegates receive « Le revendeur Magasin El Wafa a activé son compte » (in-app and push, through invitation.step_completed when an invitation is attributed). Audit: CREATE on the user, CREATE or UPDATE (details.claim = true) on the reseller.

  11. Retries. Replaying auth.signup.reseller with the same verification token while it is valid returns 201 for the same account with a new session (the first token is never stored for replay); a token can never create a second account. After the token expired, the app signs in with auth.login and the password just chosen.

  12. Wrong phone on the record. When the CRM record carries a wrong phone, the owner signs up with their own phone as NEW; the new record is flagged in the validation queue as a possible duplicate (same invitation code, same store name in the same commune, or a position within crm.duplicateRadiusMeters). Staff correct the original record; merging is V2 (CRM-13).

Used by — RA-05 (« Créer mon compte », then RA-06) and the sign-up form of RW-01; the guest prompts of RA-41 lead into this flow.

POST /auth/password/reset

auth.password.reset · Access public (verification token required) · Apps RA, RW, DA, BO · Idempotency-Key — (ignored; single-use token) · Release V1 · Requirements AUTH-04, AUTH-07

Purpose. Sets a new password for the account proven by a PASSWORD_RESET verification token.

Request

interface PasswordResetRequest {
  verificationToken: string;         // auth.otp.verify, purpose PASSWORD_RESET
  newPassword: string;
}
{ "verificationToken": "vt_Hn4Kq8Rz2Wm6Ty1Bx9Pd", "newPassword": "Wafa-Oran-31" }

Response — 204 No Content. The client then signs in with auth.login (phone pre-filled).

Errors

StatusCodeWhen
400VALIDATION_FAILEDPASSWORD_TOO_SHORT, PASSWORD_TOO_WEAK, PASSWORD_TOO_COMMON, PASSWORD_REUSED (same as the current password).
422VERIFICATION_TOKEN_INVALIDToken unknown, expired, used or of another purpose.

Rules & side effects

  1. The password hash is replaced; every session of the user is revoked and their devices are detached from push.
  2. mustChangePassword is cleared and the sign-in lock of that phone is cleared.
  3. security.password_changed (category SECURITY: in-app, push, SMS for phone accounts, email for staff): « Votre mot de passe HOCO a été modifié. Si vous n'êtes pas à l'origine de ce changement, contactez le support. »
  4. Audit UPDATE on the user (field password, no value recorded).

Used by — RA-08, DA-03, BO-02, RW-01 — last step « Enregistrer le nouveau mot de passe ».

POST /auth/password/change

auth.password.change · Access session (any user type) · Apps RA, RW, DA, BO · Idempotency-Key optional · Release V1 · Requirements AUTH-07

Purpose. Changes the password of the signed-in user, who proves it with the current password; every other session is signed out.

Request

interface PasswordChangeRequest {
  currentPassword: string;
  newPassword: string;
}
{ "currentPassword": "ElWafa2026!", "newPassword": "Wafa-Oran-31" }

Response — 200 OK.

{ "revokedSessions": 2 }

Errors

StatusCodeWhen
400VALIDATION_FAILEDPASSWORD_TOO_SHORT, PASSWORD_TOO_WEAK, PASSWORD_TOO_COMMON; PASSWORD_REUSED when the new password equals the current one.
422INVALID_CURRENT_PASSWORDcurrentPassword is wrong (details.attemptsLeft). Not a 401: the session stays valid.
429TOO_MANY_ATTEMPTS5 wrong current passwords in 15 minutes: the operation is blocked for 15 minutes.

Rules & side effects

  1. The current session is kept (and becomes fresh for back-office re-authentication); all other sessions are revoked and their devices detached from push.
  2. mustChangePassword is cleared — this is how a delegate or staff member leaves the forced-change screen after an administrator reset.
  3. security.password_changed notification and audit UPDATE, as for the reset.

Used by — RA-35, DA-26, RW-12, BO-68 (« Modifier le mot de passe »), and the forced-change screen shown after DA-02 / BO-01 when mustChangePassword is true.

GET /auth/sessions

auth.sessions.list · Access session (any user type) · Apps RA, RW, DA, BO · Idempotency-Key — · Release V1 · Requirements AUTH-06

Purpose. Lists the user's active sessions so they can recognise their devices and sign out the ones they do not trust.

Request — no parameters. Not paginated: a user has at most 10 active sessions.

Response — 200 OK, current session first, then by lastActiveAt descending.

{
  "items": [
    {
      "id": "01a1a077-1080-7c3e-9b2f-3d4e5f6a7b8c",
      "clientApp": "RESELLER_APP",
      "current": true,
      "createdAt": "2026-11-05T07:45:10Z",
      "lastActiveAt": "2026-11-05T09:02:44Z",
      "expiresAt": "2026-12-05T07:45:10Z",
      "freshUntil": null,
      "device": { "deviceId": "5f8c2a1e-7b3d-4e9f-a6c0-1d2e3f4a5b6c", "platform": "android", "name": "Samsung SM-A155F", "appVersion": "1.4.2" },
      "ipAddress": "105.235.x.x"
    },
    {
      "id": "01a19c3e-5b22-7d40-a1e3-7f6e5d4c3b2a",
      "clientApp": "RESELLER_WEB",
      "current": false,
      "createdAt": "2026-11-03T08:12:00Z",
      "lastActiveAt": "2026-11-04T18:20:05Z",
      "expiresAt": "2026-12-04T18:20:05Z",
      "freshUntil": null,
      "device": { "deviceId": null, "platform": "web", "name": "Chrome 141 · Windows", "appVersion": null },
      "ipAddress": "41.111.x.x"
    }
  ]
}

Errors — none specific.

Rules & side effects — only non-expired, non-revoked sessions are listed; ipAddress is the last IP seen, truncated. Administrators see and revoke other users' sessions through admin.users.sessions.

Used by — « Appareils connectés » in RA-35, DA-26, RW-12 and BO-68.

DELETE /auth/sessions/{sessionId}

auth.sessions.revoke · Access session (own sessions only) · Apps RA, RW, DA, BO · Idempotency-Key optional · Release V1 · Requirements AUTH-06

Purpose. Signs out one of the user's sessions, typically a lost phone or a shared computer.

Request

Path parameterTypeNotes
sessionIdUuidSessionInfo.id from auth.sessions.list.

Response — 204 No Content.

Errors

StatusCodeWhen
404NOT_FOUNDUnknown id, already revoked, or a session of another user.

Rules & side effects

  1. The session is revoked, its device detached from push and its live events.stream closed; its next call answers 401 UNAUTHENTICATED with details.reason: "REVOKED" and the app shows « Vous avez été déconnecté de cet appareil. »
  2. Revoking the current session is a sign-out (web: the cookie is cleared).
  3. Audit LOGOUT with context revoked-from-session-list.

Used by — the « Déconnecter » action on each row of the sessions list (RA-35, DA-26, RW-12, BO-68).

On this page