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.logincarries 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 sendAuthorization: Bearer …; the web apps never see the token — the API sets the httpOnly cookiehoco.session_tokenon.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 anAuthorizationheader 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.resellerandauth.password.resetignoreIdempotency-Key: their responses carry tokens, and their own single-use rules make retries safe.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| POST | /auth/login | auth.loginSign in with phone (or email for staff) and password; returns a session token | public | RA · RW · DA · BO | V1 |
| POST | /auth/logout | auth.logoutRevoke the current session and unregister the device push token | session | RA · RW · DA · BO | V1 |
| GET | /auth/session | auth.sessionCurrent session and its expiry | session | RA · RW · DA · BO | V1 |
| POST | /auth/otp/send | auth.otp.sendSend a one-time code by SMS for sign-up, password reset or phone change | public | RA · RW · DA · BO | V1 |
| POST | /auth/otp/verify | auth.otp.verifyVerify a one-time code; returns a short-lived verification token | public | RA · RW · DA · BO | V1 |
| POST | /auth/signup/reseller | auth.signup.resellerCreate (or claim) a reseller account from a verified phone with the declared customer type; returns a session | public | RA · RW | V1 |
| POST | /auth/password/reset | auth.password.resetSet a new password using a verification token | public | RA · RW · DA · BO | V1 |
| POST | /auth/password/change | auth.password.changeChange password (current password required); other sessions revoked | session | RA · RW · DA · BO | V1 |
| GET | /auth/sessions | auth.sessions.listList my active sessions (device, app, last activity) | session | RA · RW · DA · BO | V1 |
| DELETE | /auth/sessions/{sessionId} | auth.sessions.revokeRevoke one of my sessions | session | RA · RW · DA · BO | V1 |
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.login | User type allowed | Client | Session clientApp | Transport | Session lifetime |
|---|---|---|---|---|---|
RESELLER | RESELLER | Reseller app (dz.hoco.reseller) | RESELLER_APP | Bearer token | 30 days, sliding |
RESELLER | RESELLER | Reseller web (app.hoco.dz) | RESELLER_WEB | Cookie | 30 days, sliding |
DELEGATE | DELEGATE | Delegate app (dz.hoco.delegate) | DELEGATE_APP | Bearer token | 30 days, sliding |
DASHBOARD | STAFF | Back-office (admin.hoco.dz) | DASHBOARD | Cookie | 7 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 containstokenand no cookie is set. A request without it is a browser request: the API sets the cookie and leavestokenout of the body. - Sliding expiry. Every authenticated request extends
expiresAtby 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 originshttps://app.hoco.dzandhttps://admin.hoco.dz(CORS with credentials +Origincheck). One browser holds one HOCO session: each web app ignores a session whoseclientAppis 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 answer403 REAUTH_REQUIREDand the back-office asks the password again throughauth.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-PlatformandX-Device-Id(see request conventions). The session remembers the device; a new sign-in from the sameX-Device-Idfor the same app replaces the previous session of that device.
Account status gates
AccountStatus | auth.login | Other calls |
|---|---|---|
PENDING_VALIDATION | Allowed (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. |
ACTIVE | Allowed | Full access for the user's role and scope. |
SUSPENDED | 403 ACCOUNT_SUSPENDED (details.status: "SUSPENDED") | All sessions are revoked when the back-office suspends the account; any call answers 403 ACCOUNT_SUSPENDED. |
DISABLED | 403 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 (
555123456for+213555123456), not in the bundled list of the 10 000 most common passwords; no forced periodic change. Field codes in400 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 ofauth.*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.verifyreturns a single-useverificationTokenbound to the destination and the purpose, valid 10 minutes (the requirements page says 15 — to reconcile). It is consumed byauth.signup.reseller(SIGNUP),auth.password.reset(PASSWORD_RESET) orme.update(PHONE_CHANGE). - Non-production environments (and staging, which runs
NODE_ENV=productionwithSIMULATE_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, subjectSMS +213555010001). The test numbers+213555000001to+213555000009accept the fixed code123456, so Imad and the QA team can run the flows without an SMS budget. A staff password reset asked withemailsends 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;
}| Field | Notes |
|---|---|
permissions | The 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. |
mustChangePassword | Set 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.customerType | Declared 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.status | Apps 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.validationStatus | PENDING → 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. |
supportRegion | Computed 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. |
consents | The 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. |
avatar | A 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
| Header | Value |
|---|---|
X-App-Platform, X-App-Version, X-Device-Id | Mobile 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.
| Field | Notes |
|---|---|
token | Opaque, prefixed hs_. Store it in secure storage; never log it. Send it as Authorization: Bearer hs_…. |
expiresAt | Informative: the expiry slides with use. The app does not need to refresh the token; it handles 401 instead. |
user | The full Me — route on user.mustChangePassword, user.status and user.reseller.validationStatus right away, without an extra me.get. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | identifier is neither a phone number nor an email (field code INVALID_IDENTIFIER), password empty, unknown app. |
| 401 | INVALID_CREDENTIALS | Unknown 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. » |
| 403 | WRONG_APP | Correct 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é. » |
| 403 | ACCOUNT_SUSPENDED | Account SUSPENDED or DISABLED (details.status, details.supportPhone, details.supportWhatsapp). The app shows the explicit message with these contacts (AUTH-02). |
| 429 | RATE_LIMITED | More than 5 attempts in a minute for this identifier and IP (Retry-After). |
| 429 | AUTH_LOCKED | 5 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. » |
| 503 | MAINTENANCE | The requested app is in maintenance (details.until, details.message); never for DASHBOARD. |
Rules & side effects
- Normalisation: an identifier containing
@is an email (trimmed, lower-cased) and is looked up amongSTAFFusers only; anything else is normalised to E.164 (0555 12 34 56,555123456,00213555123456,+213 555 12 34 56→+213555123456). - The password is checked before the app and the status, so
WRONG_APPandACCOUNT_SUSPENDEDare 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. - Order of checks after the password:
appversus user type →403 WRONG_APP; statusSUSPENDED/DISABLED→403 ACCOUNT_SUSPENDED;PENDING_VALIDATIONandACTIVEsign in. - A
Sessionis created withclientApp(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. - Failure counters are kept per identifier + IP in Redis; a successful sign-in resets them.
- Audit:
LOGINon success,LOGIN_FAILEDon failure with the reason (INVALID_PASSWORD,UNKNOWN_IDENTIFIER,WRONG_APP,SUSPENDED,LOCKED), IP and client app (AUD-01).User.lastLoginAtis updated. - 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 — emitssecurity.new_device(categorySECURITY: in-app, push to the user's other devices, email for staff). - After a successful mobile sign-in the app calls
me.devices.registerwith its FCM token, then opens theevents.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
- The
Sessionis revoked; theDevicebound to it loses its push token and is detached from the user (no more push to this phone). - The open
events.streamconnection of this session is closed by the server. - Audit
LOGOUT. - 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
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHENTICATED | No session, or it expired or was revoked. details.reason: MISSING, EXPIRED, REVOKED. |
Rules & side effects
- Counts as activity: slides
expiresAt(at most once per 24 h) and refresheslastActiveAt(at most once per minute). - The mobile apps do not need this call: they call
me.getat start-up, which fails with the same401when 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
}| Field | Notes |
|---|---|
destination | Masked from the input (never from a stored record), for the RA-04 sentence « Code envoyé au +213 555 •• •• 56 ». |
resendAvailableAt, cooldownSeconds | RA-04 shows « Renvoyer le code dans 0:42 » until then. Use cooldownSeconds when the device clock is unreliable. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Not an Algerian mobile number (field code INVALID_PHONE), both or neither of phone/email, email with a purpose other than PASSWORD_RESET. |
| 401 | UNAUTHENTICATED | PHONE_CHANGE without a session. |
| 409 | PHONE_ALREADY_EXISTS | PHONE_CHANGE only: the new number belongs to another account or another point of sale. (SIGNUP never reveals it — see rule 3.) |
| 429 | OTP_RESEND_TOO_SOON | Less than 60 s since the previous code for this destination and purpose (details.retryAfterSeconds). |
| 429 | RATE_LIMITED | 5 codes in the last hour for this phone, or 20 for this IP. |
| 503 | OTP_DELIVERY_FAILED | The SMS gateway or the mail server refused or timed out. Nothing is counted against the limits; retry after Retry-After. |
Rules & side effects
- Phones must be Algerian mobiles: after normalisation
+213followed by 9 digits starting with 5, 6 or 7. - A new code replaces any pending code for the same destination and purpose. The code is stored hashed in
Verification(identifierotp:<purpose>:<destination>, 5-minute expiry, attempt counter). 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).PASSWORD_RESET: if the phone or email matches no account, or aSUSPENDED/DISABLEDone, the API answers the same200without sending anything, so the operation cannot be used to discover accounts. Email destinations only match staff.PHONE_CHANGE: the session's user is recorded on the code; only that user can verify it.- SMS text (French template,
ar/enbyAccept-Language): « HOCO : votre code est 482913. Il expire dans 5 minutes. Ne le communiquez à personne. », followed by theandroidAppHashline when provided. Every send is logged as aNotificationDelivery(channelSMS) 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"
}
}| Field | Notes |
|---|---|
signupContext.mode | NEW → 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.prefill | Only returned after the code proved that the caller holds the phone; never by auth.otp.send or a failed verification. |
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Code not 6 digits, destination missing. |
| 422 | OTP_INVALID | Wrong code (details.attemptsLeft). |
| 422 | OTP_EXPIRED | No pending code for this destination and purpose: expired (5 min), replaced by a newer one, or already used. Ask for a new code. |
| 429 | TOO_MANY_ATTEMPTS | Fifth wrong attempt: the code is destroyed; a new one must be requested. |
Rules & side effects
- Constant-time comparison; each wrong attempt decrements the counter; success destroys the code.
- The token is stored hashed in
Verification(identifiervt:<purpose>:<destination>); it can be consumed once, by the operation of its purpose only. - No account change happens here; the invitation step
PHONE_VERIFIEDis recorded at sign-up, together withACCOUNT_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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Field 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). |
| 409 | PHONE_ALREADY_EXISTS | The phone already has an account (signupContext.mode was EXISTING_ACCOUNT), or another device completed the claim a moment earlier. |
| 422 | VERIFICATION_TOKEN_INVALID | Token unknown, expired (10 min), already used or issued for another purpose (details.reason: EXPIRED, USED, PURPOSE_MISMATCH). The app restarts at RA-03. |
| 429 | RATE_LIMITED | More than 10 sign-ups per hour from one IP. |
Rules & side effects
-
Token. The verification token is consumed (single use); the account phone is the token's phone, normalised.
-
Record resolution — the verified phone is the only proof of ownership; an invitation code links attribution, never ownership:
Case Result A point of sale has this main phone and no user (any status or source: field prospect, NOT_INTERESTEDrecord, imported customer)Claim — the account is linked to it ( Reseller.userId). No second record is created.No point of sale has this phone New record — source: SELF_SIGNUP, numberREV-…fromNumberSequence, defaultResellerAddressfrom the store address, zone derived from the commune.A point of sale has this phone and a user Refused: 409 PHONE_ALREADY_EXISTS.The matching
Resellerrow 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 gets201, the other409. -
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, theREV-…number and the creation date (a submittedlocationonly fills an empty one; a different wilaya or commune is shown to the validator, not applied). -
Customer type (AUTH-11). The declared
customerTypeis stored on theReseller; 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 whencatalog.pricesVisibleBeforeValidationis true (default; D-18 may instead show the Détail price to everyone until validation); ordering waits for validation in every case. -
Account.
User(typeRESELLER, roleRESELLER,phoneVerified: true, locale, terms version and acceptance time) and its credentialAccountare created in the same transaction. Account status:ACTIVEat once when the record is alreadyAPPROVED(a point of sale validated by the back-office, an imported customer) or whencrm.validationRequiredis false; otherwisePENDING_VALIDATIONand the record enters the validation queue (validationStatus: PENDING). -
Reseller status. Becomes
ACCOUNT_CREATED(automaticResellerStatusChange) from a pre-account status (NEW_PROSPECT,TO_CONTACT,INTERESTED,APP_INSTALLED,NOT_INTERESTED,TO_FOLLOW_UP);FIRST_ORDER,ACTIVEandSUSPENDEDare kept. -
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 byinvitations.attribute; for a new record, a validinvitationCode, then the device attribution. The attributed invitation recordsACCOUNT_CREATEDandPHONE_VERIFIED(andAPP_INSTALLEDif 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 ininvitationCodeIgnored(a code of another point of sale is also recorded on that invitation for review). -
Assignment (new records). The delegate of the attributed invitation; otherwise the zone's primary delegate; otherwise unassigned. A claimed record keeps its
createdByDelegateIdand current assignment. -
Session. Created exactly as in
auth.login(token or cookie, device fields). -
Events.
account.pending_validation(in-app to the reseller; in-app and back-office live eventaccount.pendingto users holdingcrm.validate) — oraccount.approvedwhen 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, throughinvitation.step_completedwhen an invitation is attributed). Audit:CREATEon the user,CREATEorUPDATE(details.claim = true) on the reseller. -
Retries. Replaying
auth.signup.resellerwith the same verification token while it is valid returns201for 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 withauth.loginand the password just chosen. -
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 withincrm.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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | PASSWORD_TOO_SHORT, PASSWORD_TOO_WEAK, PASSWORD_TOO_COMMON, PASSWORD_REUSED (same as the current password). |
| 422 | VERIFICATION_TOKEN_INVALID | Token unknown, expired, used or of another purpose. |
Rules & side effects
- The password hash is replaced; every session of the user is revoked and their devices are detached from push.
mustChangePasswordis cleared and the sign-in lock of that phone is cleared.security.password_changed(categorySECURITY: 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. »- Audit
UPDATEon the user (fieldpassword, 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
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | PASSWORD_TOO_SHORT, PASSWORD_TOO_WEAK, PASSWORD_TOO_COMMON; PASSWORD_REUSED when the new password equals the current one. |
| 422 | INVALID_CURRENT_PASSWORD | currentPassword is wrong (details.attemptsLeft). Not a 401: the session stays valid. |
| 429 | TOO_MANY_ATTEMPTS | 5 wrong current passwords in 15 minutes: the operation is blocked for 15 minutes. |
Rules & side effects
- The current session is kept (and becomes fresh for back-office re-authentication); all other sessions are revoked and their devices detached from push.
mustChangePasswordis cleared — this is how a delegate or staff member leaves the forced-change screen after an administrator reset.security.password_changednotification and auditUPDATE, 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 parameter | Type | Notes |
|---|---|---|
sessionId | Uuid | SessionInfo.id from auth.sessions.list. |
Response — 204 No Content.
Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id, already revoked, or a session of another user. |
Rules & side effects
- The session is revoked, its device detached from push and its live
events.streamclosed; its next call answers401 UNAUTHENTICATEDwithdetails.reason: "REVOKED"and the app shows « Vous avez été déconnecté de cet appareil. » - Revoking the current session is a sign-out (web: the cookie is cleared).
- Audit
LOGOUTwith contextrevoked-from-session-list.
Used by — the « Déconnecter » action on each row of the sessions list (RA-35, DA-26, RW-12, BO-68).
Error codes
Registry of every error code the HOCO API returns, with its HTTP status, meaning, typical operations and the handling expected from each client.
Me, devices, app config & reference data
The signed-in user's profile and devices, the start-up configuration of each app (version policy, maintenance, public settings) and the cacheable reference data: wilayas, communes and reference lists.