HOCO B2B
Mobile (Flutter)

API integration

How the apps consume the contract: pinning the OpenAPI document, generating hoco_api, the Prism mock, the dio interceptors, the SSE client for live order status, pagination, money and date models, uploads, error UX and the contract-change procedure.

The API is contract-first (ADR-002): Anis declares every operation in packages/contracts of the hoco repository, the server exports an OpenAPI 3.1 document, and the apps consume only what that document says. Conventions (headers, idempotency, error envelope, pagination, money) are on the API reference; this page is how the Flutter side implements them (MOB-02, MOB-03).

Where the contract comes from

SourceURLUse
Published contract/openapi/hoco-v1.json on this docs site (committed as apps/docs/public/openapi/hoco-v1.json in hoco), tagged contracts-vX.Y.ZThe version the apps pin. Default source of api:sync
Staging serverhttps://api-staging.hoco.dz/openapi.jsonA change merged on dev of hoco but not yet tagged — only when Anis announces it
Local serverhttp://localhost:3100/openapi.jsonPairing sessions with Anis
Human reference/reference on the local and staging servers (Scalar)Reading, trying calls

The pinned copy lives in packages/hoco_api/openapi/hoco-v1.json with CONTRACT_VERSION next to it (the document's info.version). The generated code is committed; CI regenerates and fails if the output differs, so nobody edits lib/src/generated by hand.

tool/api_sync.sh:

#!/usr/bin/env bash
# Usage: tool/api_sync.sh [docs|staging|local|<url>]   (default: docs)
set -euo pipefail
case "${1:-docs}" in
  docs)    URL="${DOCS_BASE_URL:?set DOCS_BASE_URL to the docs site origin}/openapi/hoco-v1.json" ;;
  staging) URL="https://api-staging.hoco.dz/openapi.json" ;;
  local)   URL="http://localhost:3100/openapi.json" ;;
  *)       URL="$1" ;;
esac
DIR=packages/hoco_api/openapi
curl -fsSL "$URL" -o "$DIR/hoco-v1.next.json"
VERSION=$(node -p "require('./$DIR/hoco-v1.next.json').info.version")
mv "$DIR/hoco-v1.next.json" "$DIR/hoco-v1.json"
echo "$VERSION" > "$DIR/CONTRACT_VERSION"
melos run api:gen
git --no-pager diff --stat -- packages/hoco_api
echo "Pinned contract $VERSION. Fix compile errors, run tests, commit: chore(api): contract $VERSION"

Contract review checklist

Imad is the first reviewer of every contract PR that touches RA or DA operations (API-contract workflow). Besides behaviour, he checks what Dart generation needs — these points change the contract, so they are agreed with Anis, not patched in the app:

CheckWhy it matters for Dart
Every operation has a unique operationId equal to the op id (cart.checkout) and one tag per endpoint group (cart, delegate-tours, sync…)Method names (cartCheckout) and client classes (CartClient, DelegateToursClient) come from them
Request and response objects are named schemas in components (Order, CartLine, SyncBatchRequest), not anonymous inline objects. Objects nested in them are named too, after where they sit (CartLine.product → CartLineProduct, an array's item in the singular, a union member by its literal or distinguishing field), and identical ones share one nameInline objects become InlineResponse200-style class names
Nullable fields use type: [T, "null"] (Zod .nullable()), optional fields are simply not requiredGenerated fields get the right T? and required flags
Enums are string enums with exactly the enums.json values; the app maps unknown values to a fallbackOld app versions must survive a new enum value (versioning policy)
Unions are avoided in responses, or carry a discriminatorDart generators handle non-discriminated oneOf poorly (Object typed fields)
Free-form JSON is explicit (additionalProperties: true) where it is meant: sync.batch item body, error detailsBecomes Map<String, dynamic> instead of a broken class
Money is integer, timestamps string + format: date-time, date-only format: date, ids format: uuidTyped int, DateTime, value types
Bearer auth is a securitySchemes entry; X-App-Version, X-App-Platform, X-Device-Id, Accept-Language are documented once, not as parameters of every operation; Idempotency-Key is a header parameter where the operation accepts it (required where the API says so)Interceptors add the cross-cutting headers; the generated signature forces an idempotency key where it matters
Operations with auth: optional (catalog, facets, promotions, support.whatsapp, appConfig.get) declare security as optionalGuests call them without a token
Error responses reference the shared envelope schema with examples per statusPrism can return them; the app's error parser is tested against them
Response schemas carry examples (Zod .meta({ examples }))Prism returns realistic data instead of "string" and 0

Client generation

Recommendation: swagger_parser (pure Dart) generating retrofit clients over dio and freezed models.

swagger_parseropenapi-generator (dart-dio)
RuntimeDart (dart run swagger_parser), nothing else to installJava CLI (or Docker), slower in CI
Outputretrofit interfaces on our dio instance + freezed / json_serializable models — the stack the apps already useA full package with its own Dio wrapper; built_value by default (json_serializable optional)
OpenAPI 3.1SupportedSupport for 3.1 is partial; nullable unions are the usual pain point
InterceptorsOurs, untouchedPossible, but the package brings its own auth helpers
CustomisationYAML options (enum fallback, skipped parameters, replacement rules)Mustache templates

packages/hoco_api/swagger_parser.yaml (option names to check against the version pinned on day 1):

swagger_parser:
  schema_path: openapi/hoco-v1.json
  output_directory: lib/src/generated
  name: hoco_api
  language: dart
  json_serializer: freezed          # models as freezed classes with fromJson
  root_client: true
  root_client_name: HocoApiClient   # one entry point exposing every tag client
  put_clients_in_folder: true
  enums_to_json: true
  unknown_enum_value: true          # unknown server values decode to a fallback instead of throwing
  mark_files_as_generated: true
  skipped_parameters:               # added by interceptors, not by callers
    - X-App-Version
    - X-App-Platform
    - X-Device-Id
    - Accept-Language

melos run api:gen runs the parser then build_runner inside hoco_api. The package exports one facade so features never construct clients:

@Riverpod(keepAlive: true)
HocoApiClient hocoApi(Ref ref) => HocoApiClient(ref.watch(dioProvider), baseUrl: Env.apiBaseUrl);
// usage in a repository: ref.watch(hocoApiProvider).cart.cartCheckout(idempotencyKey: key, body: request)

Mock server

The contract ships with a Prism mock (SRV-03), so screens are built before the server modules exist.

melos run mock
# = npx --yes @stoplight/prism-cli@5 mock packages/hoco_api/openapi/hoco-v1.json --host 0.0.0.0 --port 4010
# random data generated from the schemas instead of the examples:
npx --yes @stoplight/prism-cli@5 mock -d packages/hoco_api/openapi/hoco-v1.json --port 4010

# a specific status or example, e.g. the minimum-amount refusal of checkout:
curl -s -X POST http://localhost:4010/cart/checkout \
  -H "Authorization: Bearer test" -H "Idempotency-Key: 0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01" \
  -H "Content-Type: application/json" -H "Prefer: code=422" -d '{}'
  • The app runs against it with --dart-define-from-file=env/mock.json (http://10.0.2.2:4010 from the emulator). Assumption — the document's paths are relative to the /v1 server URL, as in endpoints.json, so the mock base URL has no /v1.
  • In dev, a debug menu sets the Prefer header per operation (code=422, example=orderMinAmountNotMet, dynamic=true) through a MockScenarioInterceptor, to walk every error state of a screen without a server.
  • Prism checks that a bearer token is present on secured operations but not its validity; it validates request bodies and answers violations with 422 and an application/problem+json body — not our envelope. The error parser must survive that (it maps unknown bodies to UnexpectedException).
  • Prism keeps no state: adding to the cart does not change GET /cart; starting a tour does not change GET /delegate/tours/today. Stateful flows (cart, checkout, tour day, sync) also get in-memory fakes behind the same repository interfaces, selected in dev from the debug menu. The fakes double as test doubles.

Progressive switch to staging

Server modules reach staging one by one. Instead of waiting for all of them (MOB-12), the dev flavor can route some endpoint groups to staging and keep the rest on the mock:

/// dev only: env/hybrid.json sets API_BASE_URL=staging and MOCK_BASE_URL=http://10.0.2.2:4010;
/// paths whose prefix is listed in MOCK_PREFIXES go to Prism.
final class HybridRoutingInterceptor extends Interceptor {
  HybridRoutingInterceptor(this.mockBaseUrl, this.mockPrefixes);
  final String mockBaseUrl;
  final List<String> mockPrefixes; // e.g. ['/sync', '/delegate/location-pings', '/returns']

  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    if (mockPrefixes.any(options.path.startsWith)) options.baseUrl = mockBaseUrl;
    handler.next(options);
  }
}

Sign in on staging once authentication is deployed (SRV-05): the real token satisfies Prism too.

HTTP client

One Dio for the API, built in hoco_core; a second bare Dio for pre-signed storage URLs; a third for the SSE stream (no receive timeout).

Dio buildApiDio({required ClientInfo info, required SessionStore session, required AppGate gate}) {
  final dio = Dio(BaseOptions(
    baseUrl: Env.apiBaseUrl,
    connectTimeout: const Duration(seconds: 10),
    sendTimeout: const Duration(seconds: 20),
    receiveTimeout: const Duration(seconds: 20),
    contentType: Headers.jsonContentType,
  ));
  dio.interceptors.addAll([
    ClientHeadersInterceptor(info),      // 1 Accept-Language, X-App-Version, X-App-Platform, X-Device-Id
    AuthInterceptor(session),            // 2 Bearer when a session exists; 401 → session expired
    IdempotencyInterceptor(),            // 3 a key on every mutation that has none
    RetryInterceptor(dio),               // 4 safe retries only
    GateInterceptor(gate),               // 5 426 → forced update, 503 MAINTENANCE → maintenance
    if (!Env.isProd) LogInterceptor(requestHeader: false, responseBody: false),
  ]);
  dio.addSentry();                       // sentry_dio: breadcrumbs and spans
  return dio;
}
InterceptorAdds or doesDetail
Client headersAccept-Language: fr (app locale: fr, later ar, en), X-App-Version: 1.4.2 (package_info_plus, semver without build number), X-App-Platform: android, X-Device-IdThe install id is a UUIDv7 generated at first launch and kept in secure storage; it must equal deviceId in me.devices.register. Disable Android auto-backup for the secure-storage file so a restored phone does not clone an install id
AuthAuthorization: Bearer <token> when signed in; nothing for a guestThe token comes from auth.login / auth.signup.reseller and is cached in memory after the first secure-storage read. 401 UNAUTHENTICATED on any call → session.expire(): the reseller app becomes a guest with a « Session expirée » message; the delegate app goes to sign-in and keeps its outbox
IdempotencyIdempotency-Key: <uuid v7> on POST, PUT, PATCH, DELETE without oneA safety net only. Keys for real actions are chosen by the caller (below) and passed explicitly
RetryRetries per the policyRe-sends the same RequestOptions, so the same idempotency key
Gate426 UPGRADE_REQUIRED → gate.updateRequired(details.storeUrl); 503 MAINTENANCE → gate.maintenance(details.message, details.until)Both re-check with appConfig.get, which never answers 426 or 503

One key per logical action. A timeout does not tell whether the server applied the request. The key therefore belongs to the user's action, not to the HTTP attempt: the same key is reused when the user taps « Réessayer » with the same payload, and a new key is drawn as soon as the payload changes (otherwise 409 IDEMPOTENCY_KEY_REUSED).

final class IdempotencyKeyHolder {
  IdempotencyKeyHolder({required String Function() newKey}) : _newKey = newKey;
  final String Function() _newKey;
  Object? _payload;
  String? _key;

  /// Same payload (by value) → same key; different payload → new key.
  String keyFor(Object payload) {
    if (_key == null || payload != _payload) {
      _payload = payload;
      _key = _newKey();
    }
    return _key!;
  }

  void reset() => _key = _payload = null; // after success
}

cart.checkout builds its payload from the cart version, address, payment method and note: a retry after a timeout replays the original order (Idempotent-Replayed: true), and changing the address draws a new key. The delegate app's outbox stores the key with each queued action (offline sync).

Retry policy

SituationRetried automatically?How
GET, HEAD with a network error, timeout, 502, 503 without MAINTENANCE, 504Yes3 attempts, 1 s, 2 s, 4 s ± 20 % jitter
Mutation with an Idempotency-Key, same conditionsYesSame, same key — the server replays instead of applying twice
409 IDEMPOTENCY_IN_PROGRESSYesAfter 1 s, at most 3 times
429 RATE_LIMITEDOnceAfter Retry-After if it is under 10 s; otherwise surfaced
Mutation without a key, any 4xx, 500, 503 MAINTENANCE, 426NeverSurfaced to the caller
Uploads (stream bodies)Never by dioThe upload queue retries with a fresh stream

Live order updates — SSE

Order status changes reach the reseller live (ORD-15, ADR-008): while the reseller app is in the foreground and signed in, it keeps one Server-Sent Events connection to events.stream (GET /v1/events). In the background the connection is closed and push notifications take over (notifications). Built in MRA-10.

Dart has no EventSource, and the popular SSE packages either cannot send a bearer header or are unmaintained, so hoco_core carries a small client over dio's streamed response:

final class ServerEvent {
  const ServerEvent({required this.id, required this.type, required this.data});
  final String? id;
  final String type;   // order.status_changed, order.modified, assisted_order.awaiting_confirmation,
                       // return.status_changed, ticket.replied, ticket.resolved, notification.created, resync
  final String data;   // JSON
}

final class EventStreamClient {
  EventStreamClient(this._sseDio); // same headers and auth interceptors, no receiveTimeout
  final Dio _sseDio;

  Stream<ServerEvent> connect({String? lastEventId, CancelToken? cancel}) async* {
    final response = await _sseDio.get<ResponseBody>(
      '/events',
      cancelToken: cancel,
      options: Options(
        responseType: ResponseType.stream,
        headers: {
          'Accept': 'text/event-stream',
          'Cache-Control': 'no-cache',
          if (lastEventId != null) 'Last-Event-ID': lastEventId,
        },
      ),
    );
    final lines = response.data!.stream
        .cast<List<int>>()
        .transform(utf8.decoder)
        .transform(const LineSplitter())
        .timeout(const Duration(seconds: 60)); // the server sends a heartbeat every 25 s
    String? id;
    String? type;
    final data = StringBuffer();
    await for (final line in lines) {
      if (line.isEmpty) {
        if (data.isNotEmpty) yield ServerEvent(id: id, type: type ?? 'message', data: data.toString());
        type = null;
        data.clear();
      } else if (line.startsWith(':')) {
        continue; // heartbeat comment
      } else if (line.startsWith('id:')) {
        id = line.substring(3).trim();
      } else if (line.startsWith('event:')) {
        type = line.substring(6).trim();
      } else if (line.startsWith('data:')) {
        if (data.isNotEmpty) data.write('\n');
        data.write(line.substring(5).trimLeft());
      }
    }
  }
}

Connection rules (a keepAlive notifier, LiveEventsController):

  • Open when the gate is SignedIn and the app lifecycle is resumed; close (cancel token) on paused/hidden, on sign-out and on 426. Guests never open it. The delegate app may open it for notification.created (fresh badges) but does not depend on it: push and sync.delta carry its data.
  • Resume with the last received id in Last-Event-ID; the server replays its 10-minute window, or sends resync when the id is older — then refetch the open screen and the unread count. Events missed while the app was in the background are not replayed: on every resume the app invalidates the visible order screens, orders.summary and the unread count (notifications matrix › live updates).
  • Reconnect after an error or a 60 s silence (heartbeat : ping every 25 s) with backoff 1, 2, 4, 8, 16, then 30 s (± 20 % jitter, at least the server's retry); reset after the first event. 401 → session expired flow, no reconnect; 429 means more than three open streams for the user — close stale connections on lifecycle changes.
  • Dedup by event id: a replayed id already handled is ignored.
EventApp reaction
order.status_changed { orderId, number, from, to, at, reason }Patch the order in the detail and list providers, refresh orders.summary; in-app banner unless that order's screen is open
order.modifiedRefetch the order detail (lines or totals changed), banner « Commande modifiée par HOCO »
assisted_order.awaiting_confirmationBadge on Commandes, banner linking to /orders/:orderId/confirm
ticket.repliedRefetch the conversation if open, badge on Assistance
return.status_changedPatch the return and its order
ticket.resolvedShow the after-sales resolution; refresh the linked order, return or credit note
resyncRefetch the open screen and the unread count
notification.created { notification, unread }Set the unread badge, prepend to the open inbox; for account.customer_type_changed also refresh Me and invalidate catalog and cart

Payloads are defined on events.stream; the parser maps them to small freezed classes in hoco_core, ignores unknown event types and ignores an id already processed (delivery is at-least-once).

Pagination

Mobile lists use cursors: ?cursor=&limit= (default 20, max 100) → { items, nextCursor }, nextCursor: null on the last page.

final class CursorPage<T> {
  const CursorPage(this.items, this.nextCursor);
  final List<T> items;
  final String? nextCursor;
  bool get hasMore => nextCursor != null;
}

@riverpod
class OrdersList extends _$OrdersList {
  String? _cursor;
  bool _loadingMore = false;

  @override
  Future<List<OrderSummary>> build(OrderFilter filter) async {
    final page = await ref.watch(ordersRepositoryProvider).list(filter, limit: 20);
    _cursor = page.nextCursor;
    return page.items;
  }

  bool get hasMore => _cursor != null;

  Future<void> loadMore() async {
    if (_cursor == null || _loadingMore || !state.hasValue) return;
    _loadingMore = true;
    try {
      final page = await ref.read(ordersRepositoryProvider).list(filter, cursor: _cursor, limit: 20);
      if (!ref.mounted) return;
      _cursor = page.nextCursor;
      state = AsyncData([...state.requireValue, ...page.items]);
    } finally {
      _loadingMore = false;
    }
  }
}

The list calls loadMore() when the scroll position is within 600 px of the end; a failed page shows an inline « Réessayer » row instead of replacing the list. Pull-to-refresh invalidates the provider (back to the first page). Live updates patch items in place instead of refetching every page.

Money, dates, phones and availability

/// Integer centimes of DZD (125000 = 1 250,00 DA). Never a double.
extension type const Money(int centimes) {
  Money operator +(Money other) => Money(centimes + other.centimes);
  Money operator -(Money other) => Money(centimes - other.centimes);
  Money times(int quantity) => Money(centimes * quantity);
}

/// "1 250 DA", or "1 250,50 DA" when there are centimes. Division happens for display only.
String formatDa(Money amount, {String locale = 'fr'}) {
  final whole = amount.centimes % 100 == 0;
  final f = NumberFormat.decimalPatternDigits(locale: locale, decimalDigits: whole ? 0 : 2);
  return '${f.format(amount.centimes / 100)} DA';
}

/// A price the caller may see — null for a guest (shown as a lock).
@freezed
abstract class Price with _$Price {
  const factory Price({required Money ht, required Money ttc, required int vatRateBasisPoints}) = _Price;
}

/// The only stock information outside the back-office (STK-07).
enum Availability { inStock, outOfStock, unknown }

abstract final class BusinessTime {
  static const _offset = Duration(hours: 1); // Africa/Algiers: UTC+1, no daylight saving
  /// Wall-clock fields in Algiers, for formatting only (never call toLocal(): the phone's zone may be wrong).
  static DateTime wall(DateTime utc) => utc.toUtc().add(_offset);
  /// The business day (YYYY-MM-DD) an instant belongs to — tours, objectives, reports.
  static String day(DateTime utc) => DateFormat('yyyy-MM-dd').format(wall(utc));
}

/// Mirrors normalizeDzPhone of @hoco/shared in hoco — same test vectors in both repositories.
String? toE164(String input) {
  final s = input.replaceAll(RegExp(r'[\s.\-()]'), '');
  final national = switch (s) {
    _ when s.startsWith('+213') => s.substring(4),
    _ when s.startsWith('00213') => s.substring(5),
    _ when s.startsWith('0') => s.substring(1),
    _ => null,
  };
  if (national == null || !RegExp(r'^[2-9]\d{7,8}$').hasMatch(national)) return null;
  return '+213$national';
}

bool isMobile(String e164) => RegExp(r'^\+213[567]\d{8}$').hasMatch(e164); // OTP and WhatsApp need one

/// +213555123456 → 0555 12 34 56
String displayPhone(String e164) {
  final n = e164.substring(4);
  return n.length == 9
      ? '0${n.substring(0, 3)} ${n.substring(3, 5)} ${n.substring(5, 7)} ${n.substring(7)}'
      : '0$n';
}
  • Prices: the large figure is ttc or ht per settings.pricing.priceDisplay (TTC by default, decision to confirm); the reseller's price comes from the server for their customer type, the app never picks among three prices.
  • Availability maps IN_STOCK / OUT_OF_STOCK; any other value becomes unknown (rendered as unavailable, logged). No quantity is ever displayed — a quantity above what is available is reported by the cart as a blocking issue without numbers. The stepper's maximum is the product's order limit, not the stock.
  • Timestamps are parsed as UTC DateTime and formatted with intl in fr; date-only fields (YYYY-MM-DD) are a small LocalDate value type, never a DateTime at midnight.
  • Every enum DTO is mapped to a domain enum with an unknown member; labels come from hoco_l10n (generated from enums.json), tones from hoco_ui.

File uploads

Every photo and document follows the upload flow: files.createUpload → PUT to the pre-signed URL → files.completeUpload → attach the fileId in the business call. Built in MOB-11.

final class UploadService {
  UploadService(this._files, this._storageDio); // _storageDio: bare Dio, no API interceptors
  final FilesClient _files;
  final Dio _storageDio;

  Future<FileRef> upload(PreparedFile file, {required FilePurpose purpose, required UploadKeys keys}) async {
    final request = CreateUploadRequest(
      purpose: purpose,
      fileName: file.name,
      mimeType: file.mimeType,
      size: file.length,
      checksumSha256: file.sha256Base64,
    );
    var ticket = await _files.filesCreateUpload(idempotencyKey: keys.create, body: request);
    try {
      await _put(ticket, file);
    } on DioException catch (e) {
      if (e.response?.statusCode != 403) rethrow;
      // URL expired (10 minutes): same key → same fileId with a freshly signed URL
      ticket = await _files.filesCreateUpload(idempotencyKey: keys.create, body: request);
      await _put(ticket, file);
    }
    return _files.filesCompleteUpload(fileId: ticket.fileId, idempotencyKey: keys.complete);
  }

  Future<void> _put(UploadTicket ticket, PreparedFile file) => _storageDio.put<void>(
        ticket.uploadUrl,
        data: file.openRead(),
        options: Options(headers: {...ticket.headers, Headers.contentLengthHeader: file.length}),
      );
}
  • Prepare: image_picker with imageQuality: 85, maxWidth/maxHeight: 2560 (a 12-megapixel photo drops under 1 MB). The server strips all metadata after reading the capture time and position of visit and store photos, so the app does not remove EXIF itself; capture time and position also travel in the business call.
  • Bare storage client: the PUT must carry exactly the returned headers and nothing signed differently — never the API's Authorization or X-* headers.
  • Limits are checked before creating the upload (maxSize per purpose: 5 MB photos, 10 MB documents); refusals 422 FILE_TOO_LARGE / FILE_TYPE_NOT_ALLOWED show the limit from details.
  • Reseller app: uploads are online and immediate, with a progress bar per file and a retry per file. Delegate app: uploads are queued in the local database and run when the network returns (offline sync › uploads).
ScreenPurposeAttached by
Store photos and documents (RA-28)STORE_PHOTO, STORE_DOCUMENTprofile.documents.add
Payment declaration (RA-31)PAYMENT_PROOFpayments.declare
Ticket and after-sales claim (RA-33)TICKET_ATTACHMENTtickets.create, tickets.reply
Return request (RA-40)TICKET_ATTACHMENT (proposal of the files page)returns.create
Visit photos, façade (DA-10, DA-14)VISIT_PHOTO, STORE_PHOTOdelegate.visits.addPhoto, delegate.resellers.addPhoto (through the outbox)

After-sales claim (SUP-04) — from the order detail (« Signaler un problème », offered on delivered orders within settings.afterSales.windowDays) the app opens /support/new?type=AFTER_SALES&orderId=…: the order's lines with a quantity stepper each (at most the delivered quantity), the issue (AfterSalesIssue: Produit défectueux, Produit endommagé, Mauvais article, Article manquant, Non conforme, Autre), a description and photos. Photos upload first; then tickets.create with type AFTER_SALES, the lines and the fileIds, under one idempotency key for the submission. The ticket conversation (RA-34) shows the resolution when it arrives (AfterSalesResolution): a replacement order links to /orders/:orderId, a return to /returns/:returnId, a credit note to its document. Assumption — at least one photo is required for Produit défectueux, Produit endommagé and Mauvais article; confirm with HOCO.

Return request (ORD-13) — on a DELIVERED order within settings.returns.windowDays (default 7, 422 RETURN_WINDOW_EXPIRED after), « Demander un retour » opens /orders/:orderId/return: lines and quantities (at most delivered minus already returned), reason, comment and photos, then returns.create. The return then appears on the order and at /returns/:returnId with its ReturnStatus pill (Demandé, Accepté, En retour, Reçu, Remboursé / avoir émis, Refusé), updated live by return.status_changed; the credit note opens through orders.document-style signed URLs once issued.

Error UX mapping

The code registry is on errors. This is how the apps present each family; anything not listed shows the server message in a snackbar with the requestId in the details.

StatusCodeReseller appDelegate appAuto retry
—Network error, timeoutOffline banner; cached content stays; actions disabled with « Pas de connexion »Nothing blocks: the action is queued; header shows En attentePer policy
400VALIDATION_FAILEDInline errors from details.fields[].path mapped to form fields; unmatched ones in a bannerSame online; queued item REJECTED in the sync centre with the field messageNo
401UNAUTHENTICATEDBack to guest, « Session expirée, reconnectez-vous », return to the same screen after sign-inSign-in screen; outbox kept and replayed after sign-in by the same userNo
401INVALID_CREDENTIALSInline under the password field; lockout message after 5 failuresSameNo
403WRONG_APP« Ce compte n'est pas un compte revendeur »« Ce compte n'est pas un compte délégué »No
403ACCOUNT_SUSPENDEDSigned out, sign-in shows the explicit suspended or disabled messageSame; outbox keptNo
403ACCOUNT_PENDING_VALIDATIONCheckout banner « Votre compte est en cours de validation »; RA-06—No
403PASSWORD_CHANGE_REQUIREDChange-password screenSameNo
403PERMISSION_DENIED, OUT_OF_SCOPE« Action non autorisée », refresh the screenItem REJECTED; record refreshed or removed by the next delta (reassigned reseller)No
404NOT_FOUNDNot-found state with back actionLocal record marked stale; removed by the next deltaNo
409IDEMPOTENCY_KEY_REUSEDA client bug: generic error, Sentry event with the key and opItem REJECTED, Sentry eventNo
409IDEMPOTENCY_IN_PROGRESSInvisibleInvisibleYes, after 1 s
409ORDER_TRANSITION_NOT_ALLOWED, VERSION_CONFLICTRefresh the order, show the server messageItem CONFLICT with the server stateNo
409PHONE_ALREADY_EXISTSInline on the phone field at sign-up, offer sign-inItem CONFLICT: « Ce numéro existe déjà » with « Ouvrir la fiche » when it is in scopeNo
409VISIT_LOCKED—Visit becomes read-only; queued edits CONFLICTNo
422ORDER_MIN_AMOUNT_NOT_MET, STOCK_INSUFFICIENT, CREDIT_LIMIT_EXCEEDED, PRICE_CHANGEDCart issue banner from the server message and details (amounts formatted from centimes, never stock numbers)Same in the assisted orderNo
422RETURN_WINDOW_EXPIRED, AFTER_SALES_WINDOW_EXPIREDDialog with the window from settings and the WhatsApp button—No
422GPS_OUT_OF_TOLERANCE—Out-of-tolerance dialog (location)No
422OCCURRED_AT_OUT_OF_RANGE—Item REJECTED: « Action trop ancienne (plus de 72 h) ou horloge du téléphone incorrecte »No
422FIELD_NOT_EDITABLERead-only field, « Contactez HOCO »—No
426UPGRADE_REQUIREDForced-update screen with details.storeUrlSame; outbox keptNo
429RATE_LIMITED, OTP_RESEND_TOO_SOON, TOO_MANY_ATTEMPTSCountdown from Retry-After or the OTP resend timer; lockout messageSameOnce if short
500INTERNAL_ERROR« Une erreur est survenue » with « Réessayer » and the requestIdItem stays pending (RETRY)Reads only
503MAINTENANCEMaintenance screen with untilSame; local work continuesNo

Contract-change procedure

Anis announces. A contract PR in hoco (packages/contracts, label contract, plus mobile-impact when RA or DA operations change) requests Imad's review. Additive changes stay in v1; a breaking change needs a new operation and a deprecation kept for at least two mobile releases (versioning).

Changelog and tag. After merge, the contract changelog of the API-contract workflow lists the operations added or changed; release builds of the contract are tagged contracts-vX.Y.Z and published at /openapi/hoco-v1.json.

Regenerate. On a branch feature/<TASK-ID>-contract-x-y-z: melos run api:sync (or bash tool/api_sync.sh staging for an untagged change), fix compile errors in the mappers, update fakes and Prism scenarios, run the tests. Commit chore(api): contract X.Y.Z [MOB-03] with the spec diff.

Adapt. Features that use the new fields follow in their own PRs. If the mobile side needs something different, Imad opens an issue or a contract PR in hoco — never an edit of generated code or a local workaround.

Ship compatibly. Apps ignore unknown fields and map unknown enum values to unknown. When a server change needs a new app version, Anis raises minVersion in the app-version policy only after that version is live on the store — and never during field hours while delegates have unsynchronised work (build and release).

On this page