HOCO B2B
SRSAPI reference

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.

DevelopmentStagingProduction
Base URLhttp://localhost:3100/v1https://api-staging.hoco.dz/v1https://api.hoco.dz/v1
OpenAPI document/openapi.json/openapi.json/openapi.json
Human reference (Scalar)/reference/referencedisabled
Mock servernpx @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

GroupAudienceOperations
Authentication & sessionsall10
Me, devices, app config & reference dataall9
File uploads & downloadsall3
Catalog & favoritesreseller, delegate12
Cart & checkoutreseller8
Reseller ordersreseller11
Reseller financereseller6
Promotions, banners & packsreseller5
Loyalty (V2)reseller5
Reseller profile, addresses & documentsreseller9
Support ticketsreseller7
Notifications & announcementsall10
Invitations & attribution (public + reseller)public, reseller3
Delegate home, profile, objectives & reportsdelegate6
Tours & visitsdelegate19
Prospects, points of sale, follow-ups & invitationsdelegate13
Assisted ordersdelegate6
Offline sync & locationdelegate3
Dashboards & KPIsback-office4
CRMback-office21
Catalog & pricingback-office19
Stockback-office8
Orders & deliveryback-office21
Financeback-office12
Promotions & loyaltyback-office10
Field operationsback-office20
Support & communicationback-office10
Reports & exportsback-office5
Administration, settings & auditback-office22
Real-time events (SSE)back-office1

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

HeaderRequiredExamplePurpose
Authorizationmobile, when signed inBearer hs_01J9…Session token returned by auth.login / auth.signup.reseller
Cookieweb, when signed inhoco.session_token=…httpOnly session cookie scoped to .hoco.dz (set by the API)
Accept-LanguageyesfrLanguage of messages and labels: fr (default), ar, en
X-App-Versionmobile1.4.2Below the minimum version the API answers 426 UPGRADE_REQUIRED
X-App-Platformmobileandroidandroid or ios
X-Device-Idmobile01J9Z3…Stable install id (UUID) — ties devices, sessions and push tokens
Idempotency-Keymutations (see below)0192f4b8-…UUID chosen by the client for this logical action
X-Request-Idoptionalreq_…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"
  }
}
StatusUsed for
400VALIDATION_FAILED — malformed or invalid input
401UNAUTHENTICATED (no or expired session) · INVALID_CREDENTIALS
403PERMISSION_DENIED (role lacks the permission) · OUT_OF_SCOPE (record outside the delegate's zones or not the reseller's) · ACCOUNT_SUSPENDED · ACCOUNT_PENDING_VALIDATION
404NOT_FOUND — also returned for records that exist but are out of scope for readers, so existence is not leaked
409State conflicts: ORDER_TRANSITION_NOT_ALLOWED, VERSION_CONFLICT, IDEMPOTENCY_KEY_REUSED, PHONE_ALREADY_EXISTS, VISIT_LOCKED…
422Business-rule refusals on valid input: ORDER_MIN_AMOUNT_NOT_MET, STOCK_INSUFFICIENT, CREDIT_LIMIT_EXCEEDED, GPS_OUT_OF_TOLERANCE, OCCURRED_AT_OUT_OF_RANGE…
426UPGRADE_REQUIRED — app version below the minimum (details.storeUrl)
429RATE_LIMITED (Retry-After header) · OTP_RESEND_TOO_SOON · TOO_MANY_ATTEMPTS
500 / 503INTERNAL_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 optional in 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_GROS or DETAIL), declared at sign-up and confirmed at validation; promotions apply on top.
  • Account status gates everything: SUSPENDED and DISABLED accounts get 403 ACCOUNT_SUSPENDED on any call; PENDING_VALIDATION resellers see the catalog with their prices but get 403 ACCOUNT_PENDING_VALIDATION on checkout.
  • Orders are confirmed by people. cart.checkout always creates an order in SUBMITTED; only a back-office user with orders.validate moves it to VALIDATED.

Live events

Two Server-Sent Events streams push changes as they happen (text/event-stream, heartbeat every 25 s, resume with Last-Event-ID):

StreamWhoCarries
GET /eventsReseller app and web (and the delegate app) while openThe caller's own events: order.status_changed, order.modified, assisted_order.awaiting_confirmation, return.status_changed, ticket.replied, notification.created
GET /admin/eventsBack-officePermission-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 in v1.
  • Breaking changes need a new operation or /v2; the old one is marked deprecated (Deprecation and Sunset headers) 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

ScopeLimit
auth.login5 attempts / minute per phone + IP; 15-minute lockout after 5 consecutive failures
auth.otp.send1 per 60 s and 5 per hour per phone; 20 per hour per IP
auth.otp.verify5 attempts per code
Public invitation endpoints30 / minute per IP
Everything else600 requests / minute per user

Limits answer 429 RATE_LIMITED with Retry-After.

On this page