API reference
Conventions, shared schemas and the full operation catalogue of the HOCO API — the contract every app is built against.
One API serves the four clients: the back-office, the reseller web, the reseller app and the delegate app. It is plain REST over HTTPS with JSON bodies, declared contract-first in packages/contracts (oRPC routes with Zod request/response schemas) and published as an OpenAPI 3.1 document. Flutter generates its Dart client from that document; the Next.js apps use the typed oRPC client. Nothing is implemented before it exists in the contract — see the API-contract workflow.
| Development | Staging | Production | |
|---|---|---|---|
| Base URL | http://localhost:3100/v1 | https://api-staging.hoco.dz/v1 | https://api.hoco.dz/v1 |
| OpenAPI document | /openapi.json | /openapi.json | /openapi.json |
| Human reference (Scalar) | /reference | /reference | disabled |
| Mock server | npx @stoplight/prism-cli mock openapi/hoco-v1.json | — | — |
The domain hoco.dz is a placeholder until HOCO confirms the final domains (see the decision register).
Status: v0 baseline
This catalogue is the baseline produced with the SRS. SRV-03 turns it into the contract package and the OpenAPI document; from then on the OpenAPI document is authoritative and these pages are kept in sync with it.
Operation catalogue
Every page lists its operations with method, path, access rule and the apps that call them, then details each operation: purpose, request (path, query, headers, body), response with a full JSON example, errors, business rules and the requirements it serves. Anchors follow the operation id: cart.checkout → /docs/srs/api/cart-and-checkout#cartcheckout.
Request conventions
Headers sent by every client
| Header | Required | Example | Purpose |
|---|---|---|---|
Authorization | mobile, when signed in | Bearer hs_01J9… | Session token returned by auth.login / auth.signup.reseller |
Cookie | web, when signed in | hoco.session_token=… | httpOnly session cookie scoped to .hoco.dz (set by the API) |
Accept-Language | yes | fr | Language of messages and labels: fr (default), ar, en |
X-App-Version | mobile | 1.4.2 | Below the minimum version the API answers 426 UPGRADE_REQUIRED |
X-App-Platform | mobile | android | android or ios |
X-Device-Id | mobile | 01J9Z3… | Stable install id (UUID) — ties devices, sessions and push tokens |
Idempotency-Key | mutations (see below) | 0192f4b8-… | UUID chosen by the client for this logical action |
X-Request-Id | optional | req_… | Echoed back; generated by the API when absent |
Bodies and fields. JSON, UTF-8, camelCase field names. Unknown fields are rejected (400 VALIDATION_FAILED). Omitted optional fields mean "unchanged" on PATCH; null means "clear".
Identifiers. UUIDv7 strings. For records that can be created offline (field prospects, unplanned visits, visit photos, visit reports, notes) the client generates the UUIDv7 id; the server upserts on it, so a replay never creates a duplicate. Human-readable business numbers are separate fields and always server-generated: REV-000123, CMD-2026-000158, FAC-2026-000123, BL-2026-000123, PAY-2026-000077, TRN-2026-000045, VIS-2026-001234, TKT-2026-000045, RET-2026-000012. Invitation codes are 8 characters (AB12CD34, displayed AB12-CD34).
Enums are UPPER_SNAKE_CASE strings exactly as in the lifecycles and statuses page; clients render the French label themselves (from their l10n files) — the API never sends display labels for enum values.
Money is an integer number of centimes of DZD: 249000 = 2 490,00 DA. Amounts are never floats. Percentages and VAT rates are basis points (1900 = 19 %).
Dates. Timestamps are ISO-8601 UTC strings (2026-11-03T08:12:00Z). Date-only fields are YYYY-MM-DD. "Today", tour days, objectives and reports use the business time zone Africa/Algiers (UTC+1, no DST).
Phones are E.164: +213555123456. The API normalises 0555 12 34 56, 00213… and +213… inputs; responses always return E.164.
Locations: { "lat": 35.6969, "lng": -0.6331, "accuracy": 12, "capturedAt": "2026-11-03T08:12:00Z" } — WGS84 degrees, accuracy in metres.
Responses
Single resource: the object itself, no envelope.
Mobile and reseller lists use cursor pagination: ?limit=20&cursor=… (default 20, max 100).
{ "items": [], "nextCursor": "eyJ1IjoiMjAyNi0xMS0wM1QwODoxMjowMFoiLCJpIjoiMDE5MmY0In0" }nextCursor is opaque and null on the last page.
Back-office tables use offset pagination with totals: ?page=1&pageSize=25&sort=createdAt&order=desc (pageSize max 200).
{ "items": [], "total": 312, "page": 1, "pageSize": 25 }Filters are query parameters named after the field (status=SUBMITTED&status=PENDING for multi-values; from/to for date ranges; q for free-text search).
Errors
Every error has an HTTP status and the same envelope:
{
"error": {
"code": "ORDER_MIN_AMOUNT_NOT_MET",
"message": "Le montant minimum de commande est de 10 000,00 DA.",
"details": { "minAmount": 1000000, "currentAmount": 845000 },
"requestId": "req_01J9Z4K7M2"
}
}code is stable and documented; message is localised by Accept-Language and safe to show; details is structured data for the client (field errors, limits, conflicting state). Validation errors list fields:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "Certains champs sont invalides.",
"details": { "fields": [{ "path": "store.phone", "code": "INVALID_PHONE", "message": "Numéro invalide." }] },
"requestId": "req_01J9Z4K7M3"
}
}| Status | Used for |
|---|---|
| 400 | VALIDATION_FAILED — malformed or invalid input |
| 401 | UNAUTHENTICATED (no or expired session) · INVALID_CREDENTIALS |
| 403 | PERMISSION_DENIED (role lacks the permission) · OUT_OF_SCOPE (record outside the delegate's zones or not the reseller's) · ACCOUNT_SUSPENDED · ACCOUNT_PENDING_VALIDATION |
| 404 | NOT_FOUND — also returned for records that exist but are out of scope for readers, so existence is not leaked |
| 409 | State conflicts: ORDER_TRANSITION_NOT_ALLOWED, VERSION_CONFLICT, IDEMPOTENCY_KEY_REUSED, PHONE_ALREADY_EXISTS, VISIT_LOCKED… |
| 422 | Business-rule refusals on valid input: ORDER_MIN_AMOUNT_NOT_MET, STOCK_INSUFFICIENT, CREDIT_LIMIT_EXCEEDED, GPS_OUT_OF_TOLERANCE, OCCURRED_AT_OUT_OF_RANGE… |
| 426 | UPGRADE_REQUIRED — app version below the minimum (details.storeUrl) |
| 429 | RATE_LIMITED (Retry-After header) · OTP_RESEND_TOO_SOON · TOO_MANY_ATTEMPTS |
| 500 / 503 | INTERNAL_ERROR · MAINTENANCE (details.until) |
The full code registry is on Errors.
Idempotency and offline replay
Every mutating operation accepts an Idempotency-Key. It is required on cart.checkout, sync.batch, every delegate.* mutation and every operation marked Idempotency-Key in the tables.
- Same key + same body within 30 days → the original status and body are returned with
Idempotent-Replayed: true. Nothing is executed twice. - Same key + different body →
409 IDEMPOTENCY_KEY_REUSED. - A request still in progress with the same key →
409 IDEMPOTENCY_IN_PROGRESS(retry after a second).
Actions recorded offline carry occurredAt — the device time of the action. The server stores both occurredAt and its own receivedAt, and refuses implausible values (more than 5 minutes in the future or older than 72 hours) with 422 OCCURRED_AT_OUT_OF_RANGE. The delegate app replays its queue through sync.batch — see the offline sync design.
Authentication and authorisation
- Sessions are opaque tokens (Better Auth). Mobile stores the token in secure storage and sends it as a bearer token; the web apps rely on the httpOnly cookie. Reseller and delegate sessions last 30 days and slide with activity; back-office sessions last 7 days and slide (decision pending).
- Permissions are checked on every operation: back-office operations require a permission key
<module>.<action>(shown in the Access column); reseller operations are scoped to the caller's own reseller (self); delegate operations to the delegate's zones and assigned resellers (delegate). See the permissions matrix. - Guests. Operations marked
optionalin the Access column (catalog, facets, product pages, promotions, banners, the WhatsApp support number, app config, reference data) also answer without a session. Guests never receive prices (price: null,priceLocked: true), cannot use favorites or the cart, and never see stock quantities — nobody outside the back-office does. - Customer type. A reseller's price for every variant is the variant's price for the reseller's
customerType(SUPER_GROS,DEMI_GROSorDETAIL), declared at sign-up and confirmed at validation; promotions apply on top. - Account status gates everything:
SUSPENDEDandDISABLEDaccounts get403 ACCOUNT_SUSPENDEDon any call;PENDING_VALIDATIONresellers see the catalog with their prices but get403 ACCOUNT_PENDING_VALIDATIONon checkout. - Orders are confirmed by people.
cart.checkoutalways creates an order inSUBMITTED; only a back-office user withorders.validatemoves it toVALIDATED.
Live events
Two Server-Sent Events streams push changes as they happen (text/event-stream, heartbeat every 25 s, resume with Last-Event-ID):
| Stream | Who | Carries |
|---|---|---|
GET /events | Reseller app and web (and the delegate app) while open | The caller's own events: order.status_changed, order.modified, assisted_order.awaiting_confirmation, return.status_changed, ticket.replied, notification.created |
GET /admin/events | Back-office | Permission-filtered business events: new orders to confirm, status changes, check-ins, tours, positions, stock alerts, pending accounts, returns and claims |
Every event that matters to a user is also delivered as a notification (push + in-app inbox), so nothing depends on the stream being connected.
Shared schemas
These shapes are reused across pages. Field names and types are binding for every operation that returns them.
type Money = number; // integer centimes of DZD
type BasisPoints = number; // 1900 = 19 %
type Uuid = string; // UUIDv7
type Iso = string; // ISO-8601 UTC timestamp
interface GeoPoint { lat: number; lng: number; accuracy?: number; capturedAt?: Iso }
interface Price { // a unit price, always both values
ht: Money;
ttc: Money;
vatRate: BasisPoints;
}
interface Totals {
subtotalHt: Money; // Σ line HT before discounts
discountTotal: Money; // Σ discounts, HT
deliveryFee: Money; // HT; 0 = "Offerts"
vatTotal: Money;
totalTtc: Money; // subtotalHt − discountTotal + deliveryFee + vatTotal
currency: "DZD";
}
interface FileRef {
id: Uuid;
url: string; // public CDN URL, or a signed URL valid 5 minutes for private files
thumbnailUrl?: string;
mimeType: string;
size: number; // bytes
purpose: string; // FilePurpose
}
interface UserSummary { id: Uuid; displayName: string; avatarUrl?: string }
interface Address {
id: Uuid;
label?: string; // "Magasin", "Dépôt"
recipientName: string;
phone: string; // E.164
wilayaCode: string; // "31"
wilayaName: string;
communeId: Uuid;
communeName: string;
addressLine: string;
landmark?: string;
location?: GeoPoint;
isDefault: boolean;
}
interface ResellerSummary {
id: Uuid;
number: string; // REV-000123
storeName: string;
ownerName: string;
phone: string;
wilayaCode: string;
communeName: string;
status: string; // ResellerStatus
location?: GeoPoint;
}
type CustomerType = "SUPER_GROS" | "DEMI_GROS" | "DETAIL";
interface ProductCard {
id: Uuid;
name: string;
brand: { id: Uuid; name: string };
category: { id: Uuid; name: string; parentId: Uuid | null }; // sub-category when parentId is set
imageUrl?: string;
defaultVariantId: Uuid;
price: Price | null; // the caller's price (their customer type's price); null for guests
priceLocked: boolean; // true for guests — show "Créez un compte pour voir les prix"
recommendedPrice?: Money; // prix conseillé (TTC)
availability: "IN_STOCK" | "OUT_OF_STOCK"; // never a quantity
promotion?: { id: Uuid; label: string; price: Price | null };
isNew: boolean;
isFavorite: boolean; // always false for guests
}
interface Issue { // a non-blocking or blocking problem attached to a cart/order
code: string; // e.g. PRICE_CHANGED, STOCK_INSUFFICIENT
severity: "BLOCKING" | "WARNING";
lineId?: Uuid;
message: string;
details?: Record<string, unknown>;
}
interface StatusChange {
from: string | null;
to: string;
at: Iso;
actor: UserSummary | null; // null = system
reason?: string;
}Versioning and change policy
- The version is in the path (
/v1). Additive changes (new optional fields, new operations, new enum values announced in advance) stay inv1. - Breaking changes need a new operation or
/v2; the old one is marked deprecated (DeprecationandSunsetheaders) and kept for at least two mobile releases. - Clients must ignore unknown response fields and treat unknown enum values gracefully (generic label, no crash).
- Every contract change is listed in the changelog of the API-contract workflow and announced to Imad before merge.
Rate limits
| Scope | Limit |
|---|---|
auth.login | 5 attempts / minute per phone + IP; 15-minute lockout after 5 consecutive failures |
auth.otp.send | 1 per 60 s and 5 per hour per phone; 20 per hour per IP |
auth.otp.verify | 5 attempts per code |
| Public invitation endpoints | 30 / minute per IP |
| Everything else | 600 requests / minute per user |
Limits answer 429 RATE_LIMITED with Retry-After.
Platform
Files, audit journal, settings, business-number sequences, idempotency records, the transactional outbox, export and import jobs, app version policy, dashboard layouts and reference lists.
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.