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
| Source | URL | Use |
|---|---|---|
| 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.Z | The version the apps pin. Default source of api:sync |
| Staging server | https://api-staging.hoco.dz/openapi.json | A change merged on dev of hoco but not yet tagged — only when Anis announces it |
| Local server | http://localhost:3100/openapi.json | Pairing 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:
| Check | Why 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 name | Inline objects become InlineResponse200-style class names |
Nullable fields use type: [T, "null"] (Zod .nullable()), optional fields are simply not required | Generated 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 fallback | Old app versions must survive a new enum value (versioning policy) |
Unions are avoided in responses, or carry a discriminator | Dart 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 details | Becomes Map<String, dynamic> instead of a broken class |
Money is integer, timestamps string + format: date-time, date-only format: date, ids format: uuid | Typed 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 optional | Guests call them without a token |
| Error responses reference the shared envelope schema with examples per status | Prism 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_parser | openapi-generator (dart-dio) | |
|---|---|---|
| Runtime | Dart (dart run swagger_parser), nothing else to install | Java CLI (or Docker), slower in CI |
| Output | retrofit interfaces on our dio instance + freezed / json_serializable models — the stack the apps already use | A full package with its own Dio wrapper; built_value by default (json_serializable optional) |
| OpenAPI 3.1 | Supported | Support for 3.1 is partial; nullable unions are the usual pain point |
| Interceptors | Ours, untouched | Possible, but the package brings its own auth helpers |
| Customisation | YAML 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-Languagemelos 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:4010from the emulator). Assumption — the document's paths are relative to the/v1server URL, as inendpoints.json, so the mock base URL has no/v1. - In
dev, a debug menu sets thePreferheader per operation (code=422,example=orderMinAmountNotMet,dynamic=true) through aMockScenarioInterceptor, 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
422and anapplication/problem+jsonbody — not our envelope. The error parser must survive that (it maps unknown bodies toUnexpectedException). - Prism keeps no state: adding to the cart does not change
GET /cart; starting a tour does not changeGET /delegate/tours/today. Stateful flows (cart, checkout, tour day, sync) also get in-memory fakes behind the same repository interfaces, selected indevfrom 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;
}| Interceptor | Adds or does | Detail |
|---|---|---|
| Client headers | Accept-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-Id | The 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 |
| Auth | Authorization: Bearer <token> when signed in; nothing for a guest | The 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 |
| Idempotency | Idempotency-Key: <uuid v7> on POST, PUT, PATCH, DELETE without one | A safety net only. Keys for real actions are chosen by the caller (below) and passed explicitly |
| Retry | Retries per the policy | Re-sends the same RequestOptions, so the same idempotency key |
| Gate | 426 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
| Situation | Retried automatically? | How |
|---|---|---|
GET, HEAD with a network error, timeout, 502, 503 without MAINTENANCE, 504 | Yes | 3 attempts, 1 s, 2 s, 4 s ± 20 % jitter |
Mutation with an Idempotency-Key, same conditions | Yes | Same, same key — the server replays instead of applying twice |
409 IDEMPOTENCY_IN_PROGRESS | Yes | After 1 s, at most 3 times |
429 RATE_LIMITED | Once | After Retry-After if it is under 10 s; otherwise surfaced |
Mutation without a key, any 4xx, 500, 503 MAINTENANCE, 426 | Never | Surfaced to the caller |
| Uploads (stream bodies) | Never by dio | The 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
SignedInand the app lifecycle isresumed; close (cancel token) onpaused/hidden, on sign-out and on426. Guests never open it. The delegate app may open it fornotification.created(fresh badges) but does not depend on it: push andsync.deltacarry its data. - Resume with the last received
idinLast-Event-ID; the server replays its 10-minute window, or sendsresyncwhen 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.summaryand the unread count (notifications matrix › live updates). - Reconnect after an error or a 60 s silence (heartbeat
: pingevery 25 s) with backoff 1, 2, 4, 8, 16, then 30 s (± 20 % jitter, at least the server'sretry); reset after the first event.401→ session expired flow, no reconnect;429means 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.
| Event | App 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.modified | Refetch the order detail (lines or totals changed), banner « Commande modifiée par HOCO » |
assisted_order.awaiting_confirmation | Badge on Commandes, banner linking to /orders/:orderId/confirm |
ticket.replied | Refetch the conversation if open, badge on Assistance |
return.status_changed | Patch the return and its order |
ticket.resolved | Show the after-sales resolution; refresh the linked order, return or credit note |
resync | Refetch 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
ttcorhtpersettings.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 becomesunknown(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
DateTimeand formatted withintlinfr; date-only fields (YYYY-MM-DD) are a smallLocalDatevalue type, never aDateTimeat midnight. - Every enum DTO is mapped to a domain enum with an
unknownmember; labels come fromhoco_l10n(generated fromenums.json), tones fromhoco_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_pickerwithimageQuality: 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
PUTmust carry exactly the returnedheadersand nothing signed differently — never the API'sAuthorizationorX-*headers. - Limits are checked before creating the upload (
maxSizeper purpose: 5 MB photos, 10 MB documents); refusals422 FILE_TOO_LARGE/FILE_TYPE_NOT_ALLOWEDshow the limit fromdetails. - 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).
| Screen | Purpose | Attached by |
|---|---|---|
| Store photos and documents (RA-28) | STORE_PHOTO, STORE_DOCUMENT | profile.documents.add |
| Payment declaration (RA-31) | PAYMENT_PROOF | payments.declare |
| Ticket and after-sales claim (RA-33) | TICKET_ATTACHMENT | tickets.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_PHOTO | delegate.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.
| Status | Code | Reseller app | Delegate app | Auto retry |
|---|---|---|---|---|
| — | Network error, timeout | Offline banner; cached content stays; actions disabled with « Pas de connexion » | Nothing blocks: the action is queued; header shows En attente | Per policy |
| 400 | VALIDATION_FAILED | Inline errors from details.fields[].path mapped to form fields; unmatched ones in a banner | Same online; queued item REJECTED in the sync centre with the field message | No |
| 401 | UNAUTHENTICATED | Back to guest, « Session expirée, reconnectez-vous », return to the same screen after sign-in | Sign-in screen; outbox kept and replayed after sign-in by the same user | No |
| 401 | INVALID_CREDENTIALS | Inline under the password field; lockout message after 5 failures | Same | No |
| 403 | WRONG_APP | « Ce compte n'est pas un compte revendeur » | « Ce compte n'est pas un compte délégué » | No |
| 403 | ACCOUNT_SUSPENDED | Signed out, sign-in shows the explicit suspended or disabled message | Same; outbox kept | No |
| 403 | ACCOUNT_PENDING_VALIDATION | Checkout banner « Votre compte est en cours de validation »; RA-06 | — | No |
| 403 | PASSWORD_CHANGE_REQUIRED | Change-password screen | Same | No |
| 403 | PERMISSION_DENIED, OUT_OF_SCOPE | « Action non autorisée », refresh the screen | Item REJECTED; record refreshed or removed by the next delta (reassigned reseller) | No |
| 404 | NOT_FOUND | Not-found state with back action | Local record marked stale; removed by the next delta | No |
| 409 | IDEMPOTENCY_KEY_REUSED | A client bug: generic error, Sentry event with the key and op | Item REJECTED, Sentry event | No |
| 409 | IDEMPOTENCY_IN_PROGRESS | Invisible | Invisible | Yes, after 1 s |
| 409 | ORDER_TRANSITION_NOT_ALLOWED, VERSION_CONFLICT | Refresh the order, show the server message | Item CONFLICT with the server state | No |
| 409 | PHONE_ALREADY_EXISTS | Inline on the phone field at sign-up, offer sign-in | Item CONFLICT: « Ce numéro existe déjà » with « Ouvrir la fiche » when it is in scope | No |
| 409 | VISIT_LOCKED | — | Visit becomes read-only; queued edits CONFLICT | No |
| 422 | ORDER_MIN_AMOUNT_NOT_MET, STOCK_INSUFFICIENT, CREDIT_LIMIT_EXCEEDED, PRICE_CHANGED | Cart issue banner from the server message and details (amounts formatted from centimes, never stock numbers) | Same in the assisted order | No |
| 422 | RETURN_WINDOW_EXPIRED, AFTER_SALES_WINDOW_EXPIRED | Dialog with the window from settings and the WhatsApp button | — | No |
| 422 | GPS_OUT_OF_TOLERANCE | — | Out-of-tolerance dialog (location) | No |
| 422 | OCCURRED_AT_OUT_OF_RANGE | — | Item REJECTED: « Action trop ancienne (plus de 72 h) ou horloge du téléphone incorrecte » | No |
| 422 | FIELD_NOT_EDITABLE | Read-only field, « Contactez HOCO » | — | No |
| 426 | UPGRADE_REQUIRED | Forced-update screen with details.storeUrl | Same; outbox kept | No |
| 429 | RATE_LIMITED, OTP_RESEND_TOO_SOON, TOO_MANY_ATTEMPTS | Countdown from Retry-After or the OTP resend timer; lockout message | Same | Once if short |
| 500 | INTERNAL_ERROR | « Une erreur est survenue » with « Réessayer » and the requestId | Item stays pending (RETRY) | Reads only |
| 503 | MAINTENANCE | Maintenance screen with until | Same; local work continues | No |
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).
App architecture
How both Flutter apps are organised: feature-first layers, Riverpod 3 state, go_router routes for every RA and DA screen, guest mode and status gating, the facet-driven catalog query, errors, configuration, observability and performance rules.
Offline sync (delegate app)
The client side of the delegate app's offline-first design: drift schema, outbox model, ordering and dependency rules, replay through sync.batch, result handling, backoff, delta pulls and merge rules, photo queue, conflicts UX, purge and the test matrix.