Notifications & announcements
The in-app inbox, push notifications and their deep links, the live event stream of the reseller and delegate apps, channel preferences and back-office announcements.
Every business event that concerns a user produces a notification: one entry in the user's in-app inbox (always) plus, depending on the event's rule and the user's preferences, a push (Firebase Cloud Messaging), an SMS or an email (NOT-01 to NOT-06). While an app or page is open, the reseller apps (and the delegate app) also keep a live event stream — GET /events — so an order status changed by the back-office appears on screen at once (ORD-15). The stream is a convenience: everything it carries is also delivered as a notification, so nothing is lost when it is disconnected.
Announcements (« Messages ») are messages written in the back-office (BO-57) for a target — all delegates, the resellers of a wilaya or a segment… (NOT-07). Each published announcement is listed by announcements.list and also creates an announcement.published notification.
How it is produced: a business change writes an OutboxEvent in its own transaction; the worker matches it against the NotificationRules (event → audience → channels, edited on BO-58), renders the NotificationTemplate in the recipient's locale, filters the channels by the recipient's preferences, stores the Notification and one NotificationDelivery per channel, publishes notification.created on the recipient's live stream and sends the push.
| Method | Path | Operation | Access | Apps | Release |
|---|---|---|---|---|---|
| GET | /notifications | notifications.listMy notification inbox (cursor pagination, unread filter) | session | RA · RW · DA · BO | V1 |
| GET | /notifications/unread-count | notifications.unreadCountUnread badge count | session | RA · RW · DA · BO | V1 |
| POST | /notifications/{notificationId}/read | notifications.markReadMark one notification as read | session | RA · RW · DA · BO | V1 |
| POST | /notifications/read-all | notifications.markAllReadMark all as read | session | RA · RW · DA · BO | V1 |
| GET | /events | events.streamServer-Sent Events stream of the caller's own live events (order.status_changed, order.modified, assisted_order.awaiting_confirmation, ticket.replied, return.status_changed, notification.created) while the app or page is open; push notifications cover the rest | session | RA · RW · DA | V1 |
| GET | /notification-preferences | notifications.preferences.getMy channel preferences per category | session | RA · RW · DA | V1 |
| PUT | /notification-preferences | notifications.preferences.updateUpdate my preferences (transactional ones cannot be disabled) | session | RA · RW · DA | V1 |
| GET | /announcements | announcements.listAnnouncements targeted to me ('Messages') | session | RA · DA | V1 |
| GET | /announcements/{announcementId} | announcements.getOne announcement targeted to me (opened from a notification) | self | RA · RW · DA | V1 |
| POST | /announcements/{announcementId}/read | announcements.markReadAcknowledge an announcement | session | RA · DA | V1 |
Besides the errors listed per operation, every operation can answer 401 UNAUTHENTICATED, 403 ACCOUNT_SUSPENDED, 426 UPGRADE_REQUIRED, 429 RATE_LIMITED, 500 INTERNAL_ERROR and 503 MAINTENANCE. Guests have no inbox and no stream.
Notification object
interface NotificationItem {
id: Uuid;
category: "TRANSACTIONAL" | "SECURITY" | "FIELD" | "PROMOTIONAL" | "ANNOUNCEMENT"; // NotificationCategory
eventKey: string; // "order.validated" — see the event catalogue
title: string; // rendered in the recipient's locale when created
body: string;
imageUrl: string | null; // public image (promotion or announcement banner)
data: {
route: string; // deep link in the recipient's app, e.g. "/orders/01a1a2c4-…" — see Routes
entityType: string | null; // ORDER, RETURN, TICKET, INVOICE, CREDIT_NOTE, PAYMENT, PROMOTION,
// ANNOUNCEMENT, TOUR, VISIT, TASK, RESELLER, INVITATION, OBJECTIVE,
// VARIANT, EXPORT, IMPORT, SESSION
entityId: Uuid | null;
[key: string]: string | null; // event-specific strings, e.g. "orderNumber", "step", "resolution"
};
createdAt: Iso;
readAt: Iso | null;
}{
"id": "01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b90",
"category": "TRANSACTIONAL",
"eventKey": "order.validated",
"title": "Commande confirmée",
"body": "Votre commande CMD-2026-000158 (45 600,00 DA) a été confirmée par HOCO. Elle passe en préparation.",
"imageUrl": null,
"data": {
"route": "/orders/01a1a2c4-6e80-7f12-b345-6789abcdef01",
"entityType": "ORDER",
"entityId": "01a1a2c4-6e80-7f12-b345-6789abcdef01",
"orderNumber": "CMD-2026-000158",
"status": "VALIDATED"
},
"createdAt": "2026-11-05T09:14:27Z",
"readAt": null
}| Field | Notes |
|---|---|
title, body | Final text, ready to display; never re-rendered later (a notification stays as it was sent). |
data | All values are strings or null, so the same object fits the FCM data payload (FCM only carries strings). |
data.route | Opened when the user taps the item or the push; mapped by each app to its screen (Routes). Unknown or unauthorised route → open the inbox. The same event can carry different routes for different audiences. |
entityType | Assumption — a closed list to be added to enums.json as NotificationEntityType. |
Push messages
Push uses the FCM HTTP v1 API with a notification message (displayed by the OS even when the app is killed) and the same data as the inbox item:
{
"message": {
"token": "fJ3k9L0aQ2W:APA91bH7xY4Zr2mTq8VwN1cPs5dE6gK0uB3oR9tL7yX2nM4",
"notification": {
"title": "Commande confirmée",
"body": "Votre commande CMD-2026-000158 (45 600,00 DA) a été confirmée par HOCO. Elle passe en préparation."
},
"data": {
"notificationId": "01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b90",
"eventKey": "order.validated",
"category": "TRANSACTIONAL",
"route": "/orders/01a1a2c4-6e80-7f12-b345-6789abcdef01",
"entityType": "ORDER",
"entityId": "01a1a2c4-6e80-7f12-b345-6789abcdef01"
},
"android": {
"priority": "HIGH",
"notification": { "channel_id": "hoco_transactional", "tag": "order:01a1a2c4-6e80-7f12-b345-6789abcdef01" }
},
"apns": { "payload": { "aps": { "thread-id": "order:01a1a2c4-6e80-7f12-b345-6789abcdef01", "badge": 3 } } }
}
}- Android channels — the apps create one channel per category at first start:
hoco_transactional(Commandes et compte, high importance),hoco_security(Sécurité, high),hoco_field(Activité terrain, high),hoco_promotional(Offres et promotions, default),hoco_announcement(Annonces, default). - Grouping —
tag/thread-id=<entityType>:<entityId>: a newer push about the same order replaces the older one in the tray. - Tap — open
data.route, then callnotifications.markReadwithnotificationId. - Foreground — no system banner: the app shows an in-app banner, increments the badge and refreshes the screen concerned (the live stream usually did already).
- Badge — iOS
badgeis the unread count after this notification. - Promotional frequency — at most one
PROMOTIONALpush per reseller per day; further promotional notifications go to the inbox only.
Event catalogue
The eventKey values clients receive in the inbox and in push data. This list follows the notifications matrix, which is the reference for triggers, recipients, channels, quiet hours, caps and the French copy (template keys such as order.submitted.staff are variants of one event key for one audience). Audience: R reseller, D delegate (assigned, source of an assisted order, or inviter), S staff holding the permission shown. SSE marks events also pushed on the recipient's live stream. An unknown eventKey must still be displayed (title and body are ready) and its route opened when known.
Accounts and security
| Event key | Audience → category | Channels | data.route |
|---|---|---|---|
account.pending_validation | R → TRANSACTIONAL · S crm.validate | R in-app · S in-app, back-office SSE account.pending, email digest | R /account/status · S /crm/validation |
account.approved | R → TRANSACTIONAL | in-app, push, SMS | / |
account.rejected | R → TRANSACTIONAL | in-app, push (data.reason) | /account/status |
account.customer_type_changed | R → TRANSACTIONAL | in-app, push (data.customerType) — refresh Me and the cart | / |
account.suspended | the user → TRANSACTIONAL | SMS, in-app — sessions are revoked, sign-in shows the message | — |
security.password_changed | the user → SECURITY | in-app, push, SMS (phone accounts), email (staff) | /settings/security |
security.new_device | the user → SECURITY | in-app, push to the other devices, email (staff) | /settings/security |
security.phone_changed | the user → SECURITY | SMS to the old number, in-app (Assumption — to add to the matrix) | /settings/security |
otp.code, user.invited | a phone or a new user | SMS or email only — never in an inbox | — |
Orders and assisted orders — every order waits for an explicit back-office confirmation (ORD-14); every status change reaches the reseller as a notification and, while the app is open, as an order.status_changed live event (ORD-15).
| Event key | Audience → category | Channels | data.route |
|---|---|---|---|
order.submitted | R → TRANSACTIONAL · S orders.validate · D | R in-app, push, SSE (« Commande envoyée, elle sera confirmée par notre équipe ») · S in-app (« Commande à confirmer ») · D in-app, push | R, D /orders/{orderId} · S /orders/to-confirm |
order.confirmation_overdue | S orders.validate | in-app, email | /orders/to-confirm |
order.validated | R → TRANSACTIONAL · D | R in-app, push, SSE · D in-app | /orders/{orderId} |
order.refused | R → TRANSACTIONAL · D | in-app, push, SSE (data.reason) | /orders/{orderId} |
order.modified | R → TRANSACTIONAL · D | in-app, push, SSE order.modified (data.changeSummary) | /orders/{orderId} |
order.status_changed | R → TRANSACTIONAL · D | in-app, push, SSE — data.status: PENDING, PREPARING, READY, SHIPPED (data.carrierName, data.trackingNumber), DELIVERED, RETURNED | /orders/{orderId} |
delivery.failed | R → TRANSACTIONAL · S | in-app, push, SSE | /orders/{orderId} |
order.cancelled | R → TRANSACTIONAL (when HOCO cancels) · S orders.validate (when the reseller cancels) · D | in-app, push, SSE (data.reason); stock is released automatically | /orders/{orderId} |
assisted_order.awaiting_confirmation | R → TRANSACTIONAL · S | R in-app, push, SMS, SSE (data.delegateFirstName, data.expiresAt) · S in-app | R /orders/{orderId}/confirm · S /orders/to-confirm |
assisted_order.reminder | R → TRANSACTIONAL | in-app, push, 24 h before expiry | /orders/{orderId}/confirm |
assisted_order.confirmed | D → FIELD · R when HOCO confirms on the reseller's behalf | in-app, push | /orders/{orderId} |
assisted_order.rejected | D → FIELD · S | in-app, push (data.reason) | /orders/{orderId} |
assisted_order.expired | D → FIELD · R | in-app, push (D), in-app (R); the order is cancelled | /orders/{orderId} |
Returns, after-sales and support
| Event key | Audience → category | Channels | data.route |
|---|---|---|---|
return.requested | S orders.validate · R → TRANSACTIONAL | S in-app, back-office SSE, email digest · R in-app (receipt) | /returns/{returnId} |
return.approved | R → TRANSACTIONAL · S | R in-app, push, SSE return.status_changed (data.instructions) · S in-app (« Retour à réceptionner ») | /returns/{returnId} |
return.rejected | R → TRANSACTIONAL | in-app, push, SSE (data.reason) | /returns/{returnId} |
return.received | R → TRANSACTIONAL · S | in-app, push, SSE (data.creditNoteNumber, data.creditAmount) | /returns/{returnId} |
ticket.created | S support.update (or the assignee) · R → TRANSACTIONAL | S in-app, email digest (data.ticketType; AFTER_SALES claims first) · R in-app (receipt) | /support/tickets/{ticketId} |
ticket.replied | R → TRANSACTIONAL · S assignee | R in-app, push, SSE · S in-app | /support/tickets/{ticketId} |
ticket.status_changed | R → TRANSACTIONAL | push + in-app for WAITING_RESELLER and RESOLVED (non-SAV), in-app for CLOSED | /support/tickets/{ticketId} |
ticket.resolved | R → TRANSACTIONAL | in-app, push, SSE ticket.resolved — data.resolution: REPLACEMENT (data.replacementOrderId), RETURN (data.returnId), CREDIT_NOTE, REPAIR | /support/tickets/{ticketId} |
ticket.rejected | R → TRANSACTIONAL | in-app, push, SSE ticket.resolved with resolution REJECTED (data.reason) | /support/tickets/{ticketId} |
Finance, promotions and messaging
| Event key | Audience → category | Channels | data.route |
|---|---|---|---|
payment.declared | S finance.validate | in-app, email digest | /finance/payments/{paymentId} |
payment.confirmed, payment.rejected | R → TRANSACTIONAL | in-app, push (data.reason when rejected) | /finance/payments/{paymentId} |
invoice.issued | R → TRANSACTIONAL | in-app, email (PDF link, when an address is known) | /finance/invoices/{invoiceId} |
invoice.overdue | R → TRANSACTIONAL — V2 | in-app, push, SMS | /finance/invoices/{invoiceId} |
promotion.started | R → PROMOTIONAL | in-app, push (frequency cap) | /promotions/{promotionId} |
daily_offer.published | R → PROMOTIONAL | in-app, push | /promotions |
announcement.published | R, D → ANNOUNCEMENT | in-app, push when Announcement.sendPush is true | /messages/{announcementId} |
Field operations
| Event key | Audience → category | Channels | data.route |
|---|---|---|---|
tour.assigned | D → FIELD | in-app, push | /tours/{tourId} |
tour.updated | D → FIELD | in-app, push, plus a data message that triggers sync.delta; also sent when a tour is cancelled (data.status: CANCELLED) | /tours/{tourId} |
tour.reopened | D → FIELD | in-app, push (data.reason) | /tours/{tourId}/closing |
tour.started | S field.view | back-office SSE only by default | /field/tours/{tourId} |
tour.closed | S field.view | in-app, back-office SSE | /field/tours/{tourId} |
tour.exceptions | S field.view | in-app, email digest (tours not started or not closed the day before) | /field/tours |
visit.not_done | S field.view | in-app — immediately for HIGH priority stops, otherwise inside the tour.closed summary | /field/visits/{visitId} |
task.assigned | assignee → FIELD | in-app, push (delegates) | /tasks/{taskId} |
task.due | assignee → FIELD | in-app, push — one digest at 08:00 Africa/Algiers | /tasks |
invitation.step_completed | D (inviter) → FIELD | in-app for every step, push for ACCOUNT_CREATED and FIRST_ORDER (data.step) | /invitations/{invitationId} |
Back-office platform
| Event key | Audience | Channels | data.route |
|---|---|---|---|
stock.low, stock.out | S stock.update (stock.out also orders.validate) | in-app, back-office SSE stock.alert, email digest | /stock?alert=low |
export.ready | requester | in-app, back-office SSE, email when the job took over 2 minutes (data.status: SUCCEEDED, FAILED) | /reports/exports |
import.finished | requester | in-app, back-office SSE, email | /admin/imports/{importId} |
digest.daily | staff with queued items | email only (08:00, Sunday–Thursday) | — |
V2 loyalty events (loyalty.*) will be added with the loyalty programme (LOY-01 to LOY-04). Visit check-ins and delegate positions are never notified: they only feed the back-office live map through /admin/events.
Routes
data.route is a path in the recipient's app. Each app maps it to a screen; an unknown or unauthorised route opens the inbox.
| Route | App → screen |
|---|---|
/ | RA → RA-09 · RW → RW-02 |
/account/status | RA → RA-06 · RW → RW-12 |
/orders/{orderId} | RA → RA-22 · RW → RW-09 · DA → DA-18 · BO → BO-39 |
/orders/{orderId}/confirm | RA → RA-23 · RW → RW-09 |
/orders/to-confirm | BO → BO-41 |
/returns/{returnId} | RA → RA-40 · RW → RW-09 · BO → BO-69 |
/support/tickets/{ticketId} | RA → RA-34 · RW → RW-14 · BO → BO-56 |
/finance/invoices/{invoiceId} | RA → RA-30 · RW → RW-13 · BO → BO-44 |
/finance/payments/{paymentId} | RA → RA-31 · RW → RW-13 · BO → BO-45 |
/promotions, /promotions/{promotionId} | RA → RA-24, RA-25 · RW → RW-10 |
/messages/{announcementId} | RA → RA-26 · RW → RW-11 · DA → DA-23 |
/settings/security | RA → RA-35 · RW → RW-12 · DA → DA-26 · BO → BO-68 |
/tours/{tourId}, /tours/{tourId}/closing | DA → DA-06, DA-20 |
/tasks, /tasks/{taskId} | DA → DA-19 · BO → BO-14 |
/invitations/{invitationId} | DA → DA-16 |
/crm/validation | BO → BO-13 |
/field/tours, /field/tours/{tourId}, /field/visits/{visitId} | BO → BO-21, BO-23, BO-25 |
/stock?alert=low | BO → BO-33 |
/reports/exports | BO → BO-60 |
/admin/imports/{importId} | BO → BO-67 |
| inbox (fallback) | RA → RA-26 · RW → RW-11 · DA → DA-23 · BO → BO-03 |
Reseller-web screens are on the reseller web page.
Operations
GET /notifications
notifications.list · Access session (any user type, own inbox) · Apps RA, RW, DA, BO · Idempotency-Key — · Release V1 · Requirements NOT-01
Purpose. Returns the user's inbox, newest first, with read state.
Request
| Query parameter | Required | Notes |
|---|---|---|
cursor, limit | no | Cursor pagination (default 20, max 100). |
unread | no | true → unread only. |
category | no | Repeatable NotificationCategory. |
Response — 200 OK.
{
"items": [
{
"id": "01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b90",
"category": "TRANSACTIONAL",
"eventKey": "order.validated",
"title": "Commande confirmée",
"body": "Votre commande CMD-2026-000158 (45 600,00 DA) a été confirmée par HOCO. Elle passe en préparation.",
"imageUrl": null,
"data": {
"route": "/orders/01a1a2c4-6e80-7f12-b345-6789abcdef01",
"entityType": "ORDER",
"entityId": "01a1a2c4-6e80-7f12-b345-6789abcdef01",
"orderNumber": "CMD-2026-000158",
"status": "VALIDATED"
},
"createdAt": "2026-11-05T09:14:27Z",
"readAt": null
},
{
"id": "01a1a1f2-3a4b-7c5d-8e6f-7a8b9c0d1e2f",
"category": "ANNOUNCEMENT",
"eventKey": "announcement.published",
"title": "Nouveaux chargeurs ACEFAST 65 W",
"body": "La gamme ACEFAST GaN 65 W est disponible dès aujourd'hui. Profitez du lancement.",
"imageUrl": "https://cdn.hoco.dz/banners/2026/11/01a1a1f0-9d8c-7b6a-8f5e-4d3c2b1a0f9e.webp",
"data": {
"route": "/messages/01a1a1f0-2c3d-7e4f-9a0b-1c2d3e4f5a6b",
"entityType": "ANNOUNCEMENT",
"entityId": "01a1a1f0-2c3d-7e4f-9a0b-1c2d3e4f5a6b"
},
"createdAt": "2026-11-04T08:00:00Z",
"readAt": "2026-11-04T12:31:09Z"
}
],
"nextCursor": "eyJ0IjoiMjAyNi0xMS0wNFQwODowMDowMFoiLCJpIjoiMDFhMWExZjIifQ"
}Errors — 400 VALIDATION_FAILED for an unknown category or a bad cursor.
Rules & side effects
- The inbox keeps notifications for 12 months (
Notification.expiresAt, proposal to validate with HOCO in D-11); older items are purged by a nightly job. - Read state is per notification; reading the inbox does not mark anything read.
Used by — RA-26, RW-11, the notifications tab of DA-23, the bell panel of BO-03; the latest items on the home screen RA-09.
GET /notifications/unread-count
notifications.unreadCount · Access session (any user type) · Apps RA, RW, DA, BO · Idempotency-Key — · Release V1 · Requirements NOT-01
Purpose. Returns the badge counts, cheaply enough to call on every foreground.
Request — no parameters.
Response — 200 OK, Cache-Control: private, no-store.
{
"unread": 3,
"byCategory": { "TRANSACTIONAL": 2, "SECURITY": 0, "FIELD": 0, "PROMOTIONAL": 0, "ANNOUNCEMENT": 1 },
"unreadAnnouncements": 1
}| Field | Notes |
|---|---|
unread | Badge of the bell / inbox tab. Includes announcement.published items. |
unreadAnnouncements | Badge of the « Messages » tab (DA-23): announcements not yet acknowledged. |
Errors — none specific.
Rules & side effects — counts are cached per user in Redis and updated on every new notification and read; while the live stream is connected, each notification.created event carries the new unread value, so polling is only needed on app start and resume.
Used by — tab badges of the reseller app and the delegate app, the bell of BO-03 and the header of the reseller web.
POST /notifications/{notificationId}/read
notifications.markRead · Access session (own notifications) · Apps RA, RW, DA, BO · Idempotency-Key optional · Release V1 · Requirements NOT-01
Purpose. Marks one notification as read when the user opens it (from the inbox or from a push).
Request
| Path parameter | Type | Notes |
|---|---|---|
notificationId | Uuid | NotificationItem.id or data.notificationId of the push. |
Response — 200 OK.
{ "id": "01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b90", "readAt": "2026-11-05T09:16:02Z", "unread": 2 }Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown id or another user's notification. |
Rules & side effects — idempotent: an already read notification keeps its first readAt. Reading an announcement.published notification does not acknowledge the announcement (that is announcements.markRead, which marks the notification too).
Used by — tap on an inbox row or a push in every app.
POST /notifications/read-all
notifications.markAllRead · Access session (own notifications) · Apps RA, RW, DA, BO · Idempotency-Key optional · Release V1 · Requirements NOT-01
Contract 1.1.0 (SRV-41): group (a NotificationGroup) marks only the items of one Notification Centre tab; it combines with category and before.
Purpose. Marks every unread notification (optionally of one category) as read.
Request
interface MarkAllReadRequest {
category?: "TRANSACTIONAL" | "SECURITY" | "FIELD" | "PROMOTIONAL" | "ANNOUNCEMENT";
before?: Iso; // only items created up to this time (the newest item the user saw)
}{ "before": "2026-11-05T09:14:27Z" }Response — 200 OK.
{ "updated": 3, "unread": 0 }Errors — 400 VALIDATION_FAILED for an unknown category or a malformed date.
Rules & side effects — before prevents marking a notification that arrived after the list was displayed. Announcements are not acknowledged by this call.
Used by — « Tout marquer comme lu » on RA-26, RW-11, DA-23 and BO-03.
GET /events
events.stream · Access session (RESELLER, DELEGATE; own events only) · Apps RA, RW, DA · Idempotency-Key — · Release V1 · Requirements ORD-15, SYN-07, NOT-01
Purpose. Keeps an open Server-Sent Events connection that pushes the caller's own live events — order status changes first of all — while the app or the page is open, so screens update without polling.
Request
| Header | Required | Notes |
|---|---|---|
Accept | yes | text/event-stream |
Authorization | mobile | Bearer hs_… — Flutter has no browser EventSource; use an SSE client that sends headers (for example a dio streamed GET or a package such as flutter_client_sse). |
Cookie | web | The browser's EventSource cannot set headers: the reseller web opens new EventSource("https://api.hoco.dz/v1/events", { withCredentials: true }) and the session cookie authenticates it. |
Last-Event-ID | on reconnect | Id of the last event received; the server replays what was missed. Browsers send it automatically; mobile clients must send it themselves. |
X-App-Version, X-App-Platform, X-Device-Id | mobile | As on every call. |
Response — 200 OK, Content-Type: text/event-stream, Cache-Control: no-cache, X-Accel-Buffering: no; the connection stays open. Each event has an id (UUIDv7, increasing), an event type and one line of JSON data:
retry: 5000
id: 01a1a2f0-7b00-7a11-8c22-0d1e2f3a4b5c
event: order.status_changed
data: {"orderId":"01a1a2c4-6e80-7f12-b345-6789abcdef01","number":"CMD-2026-000158","from":"SUBMITTED","to":"VALIDATED","at":"2026-11-05T09:14:26Z","reason":null}
id: 01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b91
event: notification.created
data: {"notification":{"id":"01a1a2f0-7c1d-7e2a-9b3c-4d5e6f7a8b90","category":"TRANSACTIONAL","eventKey":"order.validated","title":"Commande confirmée","body":"Votre commande CMD-2026-000158 (45 600,00 DA) a été confirmée par HOCO. Elle passe en préparation.","imageUrl":null,"data":{"route":"/orders/01a1a2c4-6e80-7f12-b345-6789abcdef01","entityType":"ORDER","entityId":"01a1a2c4-6e80-7f12-b345-6789abcdef01","orderNumber":"CMD-2026-000158","status":"VALIDATED"},"createdAt":"2026-11-05T09:14:27Z","readAt":null},"unread":3}
: pingEvent types
event | data | Sent to | Client reaction |
|---|---|---|---|
order.status_changed | { orderId, number, from, to, at, reason } (OrderStatus values) | Reseller of the order; the delegate who prepared it (assisted orders) | Update the order list counters and the open order detail (RA-21, RA-22, RW-08, RW-09) without refetching the list; refetch the detail if it is open. |
order.modified | { orderId, number, at, changeSummary } | Reseller | Refetch the open order detail (lines, totals) and show « Commande modifiée par HOCO ». |
assisted_order.awaiting_confirmation | { orderId, number, delegateName, totalTtc, expiresAt } | Reseller | Show the confirmation banner / open RA-23 on tap. |
return.status_changed | { returnId, number, orderId, from, to, at, reason } (ReturnStatus) | Reseller | Update RA-40 / the order's returns section. |
ticket.replied | { ticketId, number, messageId, authorName, at } | Reseller of the ticket | Append to the open conversation (RA-34, RW-14) or mark the ticket unread. |
ticket.resolved | { ticketId, number, resolution, at } (AfterSalesResolution or null) | Reseller of the ticket | Show the resolution; refresh linked order, return or credit note. |
notification.created | { notification: NotificationItem, unread } | The recipient | Prepend to the inbox if open, update badges; no system banner (the push is suppressed in foreground). |
resync | { reason: "REPLAY_WINDOW_EXCEEDED" } | — | Last-Event-ID is older than the 10-minute replay window: refetch the open screen and the unread count, then continue. |
A comment line : ping is sent every 25 seconds (heartbeat); a client that receives nothing for 60 s reconnects.
Errors (answered before the stream opens, as JSON)
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHENTICATED | No or expired session. The browser EventSource sees a failed connection: the web app then checks auth.session. |
| 403 | PERMISSION_DENIED | Staff session (the back-office uses /admin/events). |
| 429 | RATE_LIMITED | More than 3 open streams for the same user (the oldest is closed instead when it belongs to the same device). |
Rules & side effects
- Scope. Only the caller's own events: the reseller's orders, returns and tickets; the delegate's notifications and the assisted orders they prepared. Delegates mainly use it for
notification.created. - Replay. Events are kept 10 minutes in a Redis stream per user — enough to cover a network drop. On reconnect with
Last-Event-IDthe server sends everything after it, in order, before live events; an older id getsresync. Events missed while the app was closed or in the background are not replayed: on resume the app reloads the screen it shows, and the inbox holds the history. - Lifecycle. Mobile apps open the stream when the app is in the foreground and signed in, close it in the background (push takes over) and reconnect with exponential backoff (1 s → 30 s, honouring
retry). The reseller web keeps it open while a tab is visible. - Session end. Sign-out, revocation, password change or suspension closes the stream from the server; the reconnection then gets
401. - Delivery guarantee. At-least-once with ids: clients ignore an
idthey already processed. The stream never carries anything that is not also available through the regular operations (orders.get,notifications.list…). - Infrastructure. The reverse proxy must not buffer (
X-Accel-Buffering: no) and must allow idle connections of at least 60 s; one Node process holds the connections and reads the per-user Redis streams fed by the outbox worker.
Used by — reseller app shell (all screens, mainly RA-21, RA-22, RA-23, RA-34, RA-40); reseller web layout (RW-08, RW-09, RW-14); delegate app shell (badges, DA-18).
GET /notification-preferences
notifications.preferences.get · Access session (RESELLER, DELEGATE) · Apps RA, RW, DA · Idempotency-Key — · Release V1 · Requirements NOT-06
Purpose. Returns, per notification category, which channels the user accepts and which ones are locked.
Request — no parameters.
Response — 200 OK.
interface NotificationPreferences {
categories: {
category: "TRANSACTIONAL" | "SECURITY" | "FIELD" | "PROMOTIONAL" | "ANNOUNCEMENT";
channels: { channel: "IN_APP" | "PUSH" | "SMS" | "EMAIL"; enabled: boolean; locked: boolean }[];
}[];
updatedAt: Iso | null; // null = defaults never changed
}{
"categories": [
{
"category": "TRANSACTIONAL",
"channels": [
{ "channel": "IN_APP", "enabled": true, "locked": true },
{ "channel": "PUSH", "enabled": true, "locked": true },
{ "channel": "SMS", "enabled": true, "locked": true }
]
},
{
"category": "SECURITY",
"channels": [
{ "channel": "IN_APP", "enabled": true, "locked": true },
{ "channel": "PUSH", "enabled": true, "locked": true },
{ "channel": "SMS", "enabled": true, "locked": true }
]
},
{
"category": "PROMOTIONAL",
"channels": [
{ "channel": "IN_APP", "enabled": true, "locked": true },
{ "channel": "PUSH", "enabled": true, "locked": false },
{ "channel": "SMS", "enabled": false, "locked": false }
]
},
{
"category": "ANNOUNCEMENT",
"channels": [
{ "channel": "IN_APP", "enabled": true, "locked": true },
{ "channel": "PUSH", "enabled": true, "locked": false },
{ "channel": "SMS", "enabled": false, "locked": false }
]
}
],
"updatedAt": null
}Errors — 403 PERMISSION_DENIED for staff (staff notification settings are part of the back-office rules, BO-58).
Rules & side effects
- Categories by user type: resellers
TRANSACTIONAL,SECURITY,PROMOTIONAL,ANNOUNCEMENT; delegatesSECURITY,FIELD,ANNOUNCEMENT. - Channels:
IN_APP,PUSH,SMS;EMAILappears only when the user has an email.WHATSAPParrives with the WhatsApp Business integration (V2, NOT-08). - Locks:
TRANSACTIONALandSECURITYare entirely locked (enum rule);IN_APPis locked on in every category (the inbox is the record). - Defaults:
PROMOTIONALandANNOUNCEMENTpush on, SMS and email off (marketing SMS needs explicit opt-in);FIELDpush on. - A preference only removes channels: an enabled channel is used only for the events whose rule includes it. Example: transactional SMS are sent only for the few events whose rule says SMS (
account.approved,order.refused,assisted_order.awaiting_confirmation…).
Used by — « Notifications » section of RA-35, RW-12 and DA-26.
PUT /notification-preferences
notifications.preferences.update · Access session (RESELLER, DELEGATE) · Apps RA, RW, DA · Idempotency-Key optional · Release V1 · Requirements NOT-06
Purpose. Saves the user's channel choices for the categories they may change.
Request — the categories to set (omitted categories keep their values; locked entries may be sent with their current value).
interface NotificationPreferencesUpdate {
categories: {
category: "TRANSACTIONAL" | "SECURITY" | "FIELD" | "PROMOTIONAL" | "ANNOUNCEMENT";
channels: { channel: "IN_APP" | "PUSH" | "SMS" | "EMAIL"; enabled: boolean }[];
}[];
}{
"categories": [
{
"category": "PROMOTIONAL",
"channels": [
{ "channel": "PUSH", "enabled": false },
{ "channel": "SMS", "enabled": false }
]
}
]
}Response — 200 OK: the full NotificationPreferences as returned by the GET.
Errors
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_FAILED | Unknown category or channel, a category or channel not offered to this user type. |
| 403 | PERMISSION_DENIED | Staff session. |
| 422 | NOTIFICATION_PREFERENCE_LOCKED | A locked channel sent with a value different from its current one (details.category, details.channel). |
Rules & side effects — stored as NotificationPreference rows (user × category × channel); applies to notifications created after the call. Audit UPDATE (consent trail for marketing channels).
Used by — the switches of RA-35, RW-12 and DA-26.
GET /announcements
announcements.list · Access session (RESELLER, DELEGATE; announcements targeting the caller) · Apps RA, DA · Idempotency-Key — · Release V1 · Requirements NOT-07
Purpose. Lists the announcements published for the user (« Messages »), newest first, with acknowledgement state.
Request
| Query parameter | Required | Notes |
|---|---|---|
cursor, limit | no | Cursor pagination. |
unread | no | true → not yet acknowledged. |
Response — 200 OK.
interface AnnouncementItem {
id: Uuid;
title: string;
body: string; // plain text with line breaks; links are written as full URLs
image: FileRef | null; // Announcement.imageFileId, public banner image
publishedAt: Iso;
expiresAt: Iso | null; // hidden after this date
readAt: Iso | null; // AnnouncementRead.readAt — acknowledgement
}{
"items": [
{
"id": "01a1a1f0-2c3d-7e4f-9a0b-1c2d3e4f5a6b",
"title": "Nouveaux chargeurs ACEFAST 65 W",
"body": "La gamme ACEFAST GaN 65 W est disponible dès aujourd'hui.\nPrésentez-la en priorité lors de vos visites cette semaine.",
"image": {
"id": "01a1a1f0-9d8c-7b6a-8f5e-4d3c2b1a0f9e",
"url": "https://cdn.hoco.dz/banners/2026/11/01a1a1f0-9d8c-7b6a-8f5e-4d3c2b1a0f9e.webp",
"thumbnailUrl": "https://cdn.hoco.dz/banners/2026/11/01a1a1f0-9d8c-7b6a-8f5e-4d3c2b1a0f9e_t.webp",
"mimeType": "image/webp",
"size": 214530,
"purpose": "BANNER_IMAGE"
},
"publishedAt": "2026-11-04T08:00:00Z",
"expiresAt": "2026-11-30T23:00:00Z",
"readAt": null
}
],
"nextCursor": null
}Errors — 400 VALIDATION_FAILED for a bad cursor.
Rules & side effects
- Targeting (
targetUserTypes,targetZoneIds,targetWilayaCodes,targetSegmentIds— empty means all) is resolved when the announcement is sent (admin.announcements.send); users matching later (a new delegate in the zone) see it while it has not expired. - Expired announcements disappear from the list; acknowledgements are kept for reporting (
AnnouncementRead).
Used by — the « Messages » tab of DA-23 and the announcements section of RA-26; the delegate home DA-05 shows the latest unread one.
GET /announcements/{announcementId}
announcements.get · Access session — reseller, delegate · Apps RA, RW, DA · Release V1 · Requirements NOT-07 · Added in contract 1.1.0 (SRV-41)
Purpose. One announcement targeted to me, opened from its notification (/messages/{id}) without paging the list.
Response 200 OK — an AnnouncementItem, as in announcements.list; body is the Arabic one for Arabic readers when the back-office wrote it.
Errors
| Status | Code | When |
|---|---|---|
| 401 | UNAUTHENTICATED | No session |
| 403 | PERMISSION_DENIED | Staff caller |
| 404 | NOT_FOUND | Unknown, unpublished, expired, or not targeted to me |
Used by RW-11 · RA-26
POST /announcements/{announcementId}/read
announcements.markRead · Access session (RESELLER, DELEGATE; announcements targeting the caller) · Apps RA, DA · Idempotency-Key optional · Release V1 · Requirements NOT-07
See also announcements.get — GET /announcements/{announcementId} (contract 1.1.0): one announcement targeted to me, the same AnnouncementItem as the list (the Arabic body for Arabic readers when the back-office wrote one), 404 NOT_FOUND when it is not published, expired or not targeted to me. Used by the deep link of a notification (/messages/{id}).
Purpose. Records that the user read (acknowledged) an announcement.
Request
| Path parameter | Type | Notes |
|---|---|---|
announcementId | Uuid | AnnouncementItem.id. |
Response — 200 OK.
{ "id": "01a1a1f0-2c3d-7e4f-9a0b-1c2d3e4f5a6b", "readAt": "2026-11-05T07:52:40Z", "unreadAnnouncements": 0 }Errors
| Status | Code | When |
|---|---|---|
| 404 | NOT_FOUND | Unknown announcement or not targeted to the caller. |
Rules & side effects — idempotent (the first readAt is kept); creates the AnnouncementRead row and marks the matching announcement.published notification read. Read rates are shown to the sender on BO-57.
File uploads & downloads
Two-step uploads to S3-compatible storage with pre-signed URLs, per-purpose limits, image processing (EXIF stripping, thumbnails) and the rules for reading private files.
Catalog & favorites
Home aggregate, brands, categories, product search with advanced filters and facets, product detail, barcode lookup, similar products and favorites — for guests, resellers and delegates.