Notifications matrix
Every event that notifies someone — trigger, recipients, channels, preference category and group, deep link, template and release, smart notifications included — with the French copy, quiet hours, grouping, retries, preferences, channel costs and the checklist for adding an event.
The cahier asks for notifications « Push, in-app, WhatsApp, SMS ou email selon le type d'événement » (cahier §8), « Notifications automatiques à chaque changement important » of an order (§4.4) and « Chaque changement de statut doit être daté et notifié » (§5.4); important events are also kept in the activity journal (§2.1). This page turns that into one table per domain: which event, who receives it, through which channels, in which preference category, where a tap leads, and which template renders it.
It covers NOT-01 to NOT-08, ORD-06, ORD-15 and SYN-07. It is the seed of the NotificationRule and NotificationTemplate tables built in SRV-08 and SRV-26, edited in the back-office on BO-58 (DSH-10), rendered by the apps in MOB-08, MRA-08, MRA-10 and MDA-11, and worded by DQA-10. After go-live, BO-58 is authoritative; changes made there are reported here.
Two product decisions taken by the team on 17 October 2026 shape this page: no order is confirmed automatically — every new order lands in the back-office « Commandes à confirmer » queue (ORD-14, BO-41); and every order status change reaches the reseller live and as a notification — the open reseller app or web updates through Server-Sent Events, and a push plus an inbox entry are always produced (ORD-15). Notifications never show stock quantities to resellers (STK-07).
HOCO's document 03 (18 October 2026) adds the smart notifications: every reseller notification gets a NotificationGroup (the section of the Notification Centre) and an action button, and rule-based commercial events are added (smart notifications, rows in Smart notifications).
How a notification is produced
- A module changes state and writes an
OutboxEvent(eventKey,entityType,entityId,actorUserId, versionedpayload) in the same database transaction. Nothing is sent from a request handler. - The worker dispatches each outbox row at least once to: the back-office SSE stream, the per-user SSE stream, analytics aggregates, CRM automation and the notification service.
- The notification service loads the
NotificationRulerows of the event key: for each recipient resolver, the channels, the category, the collapse key, the merge window and the time-to-live. - Recipients are resolved (table below), then filtered: actor rule, account status, preferences, quiet hours and caps.
- Templates are rendered per recipient locale (
frin V1;arandenkeys exist and fall back tofr, NFR-06). - One
Notificationrow (the inbox entry, channelIN_APP) is written per recipient, then oneNotificationDeliveryrow per external channel, each sent from its own queue. The user's open apps receivenotification.createdon their SSE stream to update the badge. - Re-processing an outbox row never sends twice:
NotificationDeliveryis unique on (outboxEventId,recipientUserId,channel).
Live updates versus notifications
| Mechanism | Who | When | Carries | Operation |
|---|---|---|---|---|
| Back-office SSE | Staff with a back-office session, filtered by permission | While a back-office page is open | order.created, order.status_changed, visit.checked_in, visit.completed, tour.started, tour.closed, delegate.position, stock.alert, account.pending, plus return and claim events | admin.events.stream |
| User SSE | Resellers (RA, RW) and delegates (DA), own events only | While the app or page is open | order.status_changed, order.modified, assisted_order.awaiting_confirmation, ticket.replied, return.status_changed, notification.created | events.stream |
Inbox (IN_APP) | Every recipient with an account | Always, written before any external channel | Title, body, route, read state | notifications.list |
Push (PUSH) | Reseller and delegate apps | Always for order status changes; per matrix otherwise | Title, body, route | FCM, see integrations |
For an order status change the reseller receives all three: the SSE event refreshes the open screen within seconds, the push reaches a closed app, and the inbox keeps the dated history. When the app is in the foreground, it shows the push as an in-app banner instead of a system notification, and shows nothing if the user is already on that order's screen (the screen has just been refreshed by SSE). Both SSE streams resume with Last-Event-ID; events missed while the app was closed are not replayed — the app reloads the screen on resume and the inbox covers history.
Assumption — the return and claim events of the back-office stream (return.requested, ticket.created for AFTER_SALES) are named here and in SRV-26; they still have to be added to the event list of admin.events.stream in endpoints.json.
Channels
| Channel | Apps | Used for | Release | Notes |
|---|---|---|---|---|
IN_APP | RA, RW, DA, BO | Every notification of a user with an account — the history | V1 | Never disabled by preferences; unread badge via notifications.unreadCount |
PUSH | RA, DA (FCM) | Transactional, field, promotional and announcement alerts | V1 | No web push in V1: the reseller web and the back-office rely on SSE and the inbox (Assumption) |
SMS | Any phone | One-time codes and the short list of critical messages (below) | V1 | GSM-7 French copy only, one segment |
EMAIL | Staff; resellers who gave an address | Staff invitations, daily staff digest, export and import results, invoices to resellers, security alerts to staff | V1 | Resellers without an email simply do not get this channel |
WHATSAPP | — | V1: none from the server. WhatsApp is used through user-initiated links (invitations, regional support button, integrations). V2: templates through the WhatsApp Business Platform (NOT-08) | V2 | Rows marked V2 WHATSAPP below are the candidates for V2 templates |
SSE appears in the matrix as a reminder that the open app updates live; it is not a NotificationChannel and writes no inbox row.
Critical SMS whitelist
SMS costs money per message and per segment, so it is reserved for one-time codes and for messages a user must receive even with no app installed or push blocked. The whitelist is a setting (notifications.smsEvents); adding an event to it is a cost decision for HOCO.
An announcement (message) whose sender ticks the SMS channel (communication.validate) is the one exception: that SMS goes out outside the whitelist, with the announcement's smsText — the cost decision is taken per announcement. Like an announcement's e-mail, it is on for a recipient unless they switched the channel off (SRV-42). Until the SMS gateway is chosen (D-13), SMS are simulated (logged) outside production and fail in production.
| Event | Why SMS |
|---|---|
otp.code | The phone is the identity; no other channel exists before sign-up |
assisted_order.awaiting_confirmation | Time-bound (72 h) and often a reseller who has just installed the app; matches the order lifecycle |
account.approved | The reseller may not reopen the app while waiting; sent once per reseller |
account.suspended | Sessions are revoked, so push and inbox can no longer reach the user |
security.password_changed | Must reach the owner through a channel an intruder does not control |
user.invited (delegates) | The delegate has no app yet |
Recipients
| Resolver | Resolves to | Notes |
|---|---|---|
reseller | The active user account(s) of the reseller concerned | A pending account receives only account events |
assignedDelegate | The delegate currently assigned to the reseller (ResellerAssignment) | Category FIELD |
sourceDelegate | The delegate who prepared an assisted order (sourceDelegateId) | Falls back to assignedDelegate |
inviter | The delegate who owns the invitation | Invitation funnel |
holders:<permission> | Every active staff user holding the permission (defaults from the permissions matrix) | Queue notifications: when one holder handles the item (order confirmed, payment verified, account validated, return decided, claim resolved), the others' notifications for that entity are marked read automatically |
assignee | The user a task or ticket is assigned to | Unassigned tickets go to holders:support.update |
requester | The user who started an export or import | — |
self | The account the security event is about | Not suppressed by the actor rule |
audience | Resellers or delegates matching an announcement's or promotion's targeting | Batched sending |
phone | A phone number, not yet a user | One-time codes only; no inbox row |
Filters applied to every recipient:
- Actor rule — the user who performed the action is not notified of it (the screen already confirms it), except
SECURITYevents and the reseller's ownorder.submitted(its push confirms reception when the app is closed right after checkout). - Account status —
SUSPENDEDandDISABLEDusers receive nothing butaccount.suspendedandSECURITYmessages. - Preferences — external channels only; the inbox always receives (preferences).
- Quiet hours and caps — below.
The matrix
Channels: IN_APP inbox · PUSH · SMS · EMAIL · SSE live update of the open app. Deep links give the screen and the in-app route ({…} are identifiers). Digest means the event is batched into the staff email digest of 08:00 rather than sent alone. Rel. is the product release.
Orders
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
order.submitted | Order reaches SUBMITTED (or PENDING on hold) through cart.checkout, a confirmed assisted order or admin.orders.create | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-22 /orders/{orderId} · RW-09 | order.submitted.reseller | V1 |
holders:orders.validate — « Commandes à confirmer » queue | IN_APP · SSE (order.created) · EMAIL digest | TRANSACTIONAL | BO-41 /orders/to-confirm → BO-39 | order.submitted.staff | V1 | ||
assignedDelegate | IN_APP · PUSH | FIELD | DA-18 /orders/{orderId} | order.submitted.delegate | V1 | ||
order.confirmation_overdue | Job: an order still SUBMITTED or PENDING after orders.confirmationReminderHours working hours (default 4) | holders:orders.validate | IN_APP · EMAIL | TRANSACTIONAL | BO-41 | order.confirmation_overdue | V1 |
order.validated | admin.orders.validate — the explicit back-office confirmation | Reseller | IN_APP · PUSH · SSE · V2 WHATSAPP | TRANSACTIONAL | RA-22 · RW-09 | order.validated | V1 |
sourceDelegate or assignedDelegate | IN_APP | FIELD | DA-18 | order.validated.delegate | V1 | ||
order.refused | admin.orders.refuse, reason from ORDER_REFUSAL_REASON | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-22 | order.refused | V1 |
sourceDelegate or assignedDelegate | IN_APP · PUSH | FIELD | DA-18 | order.refused.delegate | V1 | ||
order.modified | admin.orders.modify — controlled line change with reason | Reseller | IN_APP · PUSH · SSE (order.modified) | TRANSACTIONAL | RA-22 | order.modified | V1 |
sourceDelegate (assisted orders) | IN_APP | FIELD | DA-18 | order.modified.delegate | V1 | ||
order.status_changed | admin.orders.transition, admin.deliveries.proof: PENDING (put on hold), PREPARING, READY, SHIPPED, DELIVERED, RETURNED | Reseller — every status change | IN_APP · PUSH · SSE · V2 WHATSAPP (SHIPPED) | TRANSACTIONAL | RA-22 · RW-09 | order.status_changed.<status> | V1 |
assignedDelegate | IN_APP (DELIVERED, RETURNED only) | FIELD | DA-18 | order.status_changed.delegate | V1 | ||
delivery.failed | admin.deliveries.update sets DeliveryStatus FAILED | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-22 | delivery.failed | V1 |
holders:delivery.update | IN_APP | TRANSACTIONAL | BO-42 /deliveries | delivery.failed.staff | V1 | ||
order.cancelled | orders.cancel by the reseller or admin.orders.cancel; reserved quantities return to stock automatically | Reseller (when the back-office cancels) | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-22 | order.cancelled | V1 |
holders:orders.validate (when the reseller cancels) | IN_APP · SSE | TRANSACTIONAL | BO-39 /orders/{orderId} | order.cancelled.staff | V1 | ||
assignedDelegate | IN_APP | FIELD | DA-18 | order.cancelled.delegate | V1 |
Assumption — order.confirmation_overdue and its setting are proposed here because every order now waits for a human (§1b); they need adding to the settings catalogue.
Assisted orders
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
assisted_order.awaiting_confirmation | delegate.assisted.submit: order PENDING, AssistedConfirmation AWAITING_RESELLER | Reseller | IN_APP · PUSH · SMS · SSE · V2 WHATSAPP | TRANSACTIONAL | RA-23 /orders/{orderId}/confirm · RW-09 | assisted_order.awaiting_confirmation | V1 |
holders:orders.validate | IN_APP | TRANSACTIONAL | BO-41 | assisted_order.awaiting_confirmation.staff | V1 | ||
assisted_order.reminder | Job orders.expireAssisted, 24 h before assistedExpiresAt | Reseller | IN_APP · PUSH | TRANSACTIONAL | RA-23 | assisted_order.reminder | V1 |
assisted_order.confirmed | orders.confirmAssisted or admin.orders.confirmAssisted; the order then follows order.submitted | sourceDelegate | IN_APP · PUSH | FIELD | DA-18 | assisted_order.confirmed | V1 |
| Reseller, only when the administration confirmed for them | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-22 | assisted_order.confirmed.by_admin | V1 | ||
assisted_order.rejected | orders.rejectAssisted with a reason | sourceDelegate | IN_APP · PUSH | FIELD | DA-18 | assisted_order.rejected | V1 |
holders:orders.validate | IN_APP | TRANSACTIONAL | BO-41 | assisted_order.rejected.staff | V1 | ||
assisted_order.expired | Job orders.expireAssisted (every 15 min): AssistedConfirmation EXPIRED, order CANCELLED | sourceDelegate | IN_APP · PUSH | FIELD | DA-18 | assisted_order.expired.delegate | V1 |
| Reseller | IN_APP · SSE | TRANSACTIONAL | RA-22 | assisted_order.expired | V1 |
Returns and after-sales
Returns and after-sales claims are V1 (ORD-13, SUP-04, FIN-08); the open reseller app follows them live through return.status_changed and ticket.replied.
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
return.requested | returns.create on a delivered order (lines, quantities, reason, photos), RET-YYYY-NNNNNN | holders:orders.validate — returns queue | IN_APP · SSE · EMAIL digest | TRANSACTIONAL | BO-69 /returns/{returnId} | return.requested.staff | V1 |
| Reseller (acknowledgement) | IN_APP · SSE (return.status_changed) | TRANSACTIONAL | RA-40 /returns/{returnId} · RW-14 | return.requested | V1 | ||
return.approved | admin.returns.decide — approved (status APPROVED, then IN_TRANSIT when goods are on their way) | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-40 | return.approved | V1 |
holders:stock.update — reception expected | IN_APP | TRANSACTIONAL | BO-69 | return.approved.staff | V1 | ||
return.rejected | admin.returns.decide — rejected with a reason | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-40 | return.rejected | V1 |
return.received | admin.returns.receive: accepted quantities re-enter stock automatically (RETURN movements) and a credit note is issued | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-40 · RA-30 | return.received | V1 |
holders:finance.validate — credit note issued | IN_APP · EMAIL digest | TRANSACTIONAL | BO-44 /finance/invoices | return.received.staff | V1 | ||
ticket.created (AFTER_SALES) | tickets.create with type AFTER_SALES: order, claimed lines and quantities (TicketItem), AfterSalesIssue, photos | holders:support.update (or the assignee) | IN_APP · SSE · EMAIL digest | TRANSACTIONAL | BO-56 /support/tickets/{ticketId} | ticket.created.after_sales | V1 |
| Reseller (acknowledgement) | IN_APP | TRANSACTIONAL | RA-34 /support/tickets/{ticketId} | ticket.created.reseller | V1 | ||
ticket.resolved | admin.tickets.resolve with REPLACEMENT (free order created), RETURN (return created), CREDIT_NOTE or REPAIR; or a non-SAV ticket set RESOLVED | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-34; replacement → RA-22, return → RA-40 | ticket.resolved.<resolution> | V1 |
ticket.rejected | admin.tickets.resolve with REJECTED and a reason (out of warranty, misuse…) | Reseller | IN_APP · PUSH · SSE | TRANSACTIONAL | RA-34 | ticket.rejected | V1 |
A replacement order created by REPLACEMENT is an order like any other: it enters the « Commandes à confirmer » queue and produces the order events above.
Accounts and users
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
account.pending_validation | auth.signup.reseller (with declared customer type) or a new point of sale from delegate.resellers.create | holders:crm.validate | IN_APP · SSE (account.pending) · EMAIL digest | TRANSACTIONAL | BO-13 /crm/validation | account.pending_validation.staff | V1 |
| Reseller | IN_APP | TRANSACTIONAL | RA-06 | account.pending_validation | V1 | ||
account.approved | admin.resellers.validate approves and confirms the customer type | Reseller | IN_APP · PUSH · SMS | TRANSACTIONAL | RA-09 / · RW-02 | account.approved | V1 |
assignedDelegate | IN_APP · PUSH | FIELD | DA-09 /resellers/{resellerId} | account.approved.delegate | V1 | ||
account.rejected | admin.resellers.validate rejects with a reason | Reseller | IN_APP · PUSH | TRANSACTIONAL | RA-06 | account.rejected | V1 |
assignedDelegate | IN_APP · PUSH | FIELD | DA-09 | account.rejected.delegate | V1 | ||
account.customer_type_changed | Back-office changes Reseller.customerType after validation (audited, AUTH-11) — the reseller's prices change | Reseller | IN_APP · PUSH | TRANSACTIONAL | RA-09 | account.customer_type_changed | V1 |
account.suspended | admin.users.setStatus SUSPENDED, or ResellerStatus SUSPENDED | The user | SMS · IN_APP | TRANSACTIONAL | RA-07 / DA-02 (sign-in shows the explicit message) | account.suspended | V1 |
assignedDelegate (reseller suspended) | IN_APP | FIELD | DA-09 | account.suspended.delegate | V1 | ||
user.invited | admin.users.create | New staff user | EMAIL | SECURITY | BO-01 sign-in | user.invited.email | V1 |
| New delegate | SMS | SECURITY | DA-02 (store link) | user.invited.sms | V1 |
Finance
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
payment.declared | payments.declare (transfer, CCP, BaridiMob with proof) | holders:finance.validate | IN_APP · EMAIL digest | TRANSACTIONAL | BO-45 /finance/payments/{paymentId} | payment.declared.staff | V1 |
payment.confirmed | admin.payments.verify confirms, or admin.payments.record | Reseller | IN_APP · PUSH | TRANSACTIONAL | RA-31 /finance/payments · RW-13 | payment.confirmed | V1 |
payment.rejected | admin.payments.verify rejects with a PAYMENT_REJECT_REASON | Reseller | IN_APP · PUSH | TRANSACTIONAL | RA-31 | payment.rejected | V1 |
invoice.issued | Order reaches billing.invoiceOn (default SHIPPED) | Reseller | IN_APP · EMAIL (PDF link, if an address is known) | TRANSACTIONAL | RA-30 /finance/invoices/{invoiceId} · RW-13 | invoice.issued | V1 |
invoice.overdue | Job invoices.markOverdue (01:00) and the reminder policy | Reseller | IN_APP · PUSH · SMS or WHATSAPP per policy | TRANSACTIONAL | RA-30 | invoice.overdue | V2 |
holders:finance.view | IN_APP · EMAIL digest | TRANSACTIONAL | BO-47 | invoice.overdue.staff | V2 | ||
assignedDelegate | IN_APP | FIELD | DA-09 | invoice.overdue.delegate | V2 |
The OVERDUE status itself exists in V1 (finance dashboard); only the reminders are V2 (FIN-07).
Stock
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
stock.low | A movement brings a variant's available stock to or below its threshold in a warehouse | holders:stock.update | IN_APP · SSE (stock.alert) · EMAIL digest | TRANSACTIONAL | BO-33 /stock?alert=low | stock.low · stock.low.digest | V1 |
stock.out | Available stock of a variant reaches 0 (the product turns OUT_OF_STOCK when every variant is at 0) | holders:stock.update, holders:orders.validate | IN_APP · SSE · EMAIL | TRANSACTIONAL | BO-33 | stock.out | V1 |
Stock events go to staff only. Resellers and guests never receive a quantity — not in notifications, not in the apps; at most a product shown « En rupture » (STK-07).
Promotions
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
promotion.started | Promotion ACTIVE (job promotions.applySchedule or manual activation) and « Notifier les revendeurs » ticked in BO-50 | audience — signed-in resellers matching the targeting | IN_APP · PUSH | PROMOTIONAL | RA-25 /promotions/{promotionId} · RW-10 | promotion.started | V1 |
| Delegates whose zones intersect the targeting | IN_APP | FIELD | DA-23 | promotion.started.delegate | V1 | ||
daily_offer.published | A daily offer goes live | audience | IN_APP · PUSH | PROMOTIONAL | RA-24 /promotions | daily_offer.published | V1 |
Guests receive no notification (no account, no device registration with a user).
Smart notifications
From HOCO's document 03 (18 October 2026), specified on smart notifications (NOT-09 to NOT-17). Every row is PROMOTIONAL, goes to resellers only, carries a NotificationGroup and an action, and is subject to the caps and priority. None uses SMS. The Action column gives the default button label.
| Event | Trigger | Recipient | Channels | Category · group | Action → deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
product.arrived | Job smart.newArrivalsDigest (10:00, 16:00): products published for the first time with « Annoncer comme nouveauté » | Eligible resellers who can see the product | IN_APP · PUSH | PROMOTIONAL · NEW_PRODUCTS | Commander maintenant → RA-14 /products/{productId}; grouped → RA-12 /catalog?tab=new | product.arrived · .grouped | V1 |
variant.restocked | Availability OUT_OF_STOCK → IN_STOCK after at least 24 h out | Resellers who ordered the variant in the last 90 days, favorited the product, or hold it in their cart | IN_APP · PUSH | PROMOTIONAL · NEW_PRODUCTS | Commander maintenant → /products/{productId}?variantId={variantId} | variant.restocked · .grouped | V1 |
variant.low_stock_reached | Available stock crosses the alert threshold downwards (once until back above); no quantity in the payload | Favorites, carts, resellers who ordered it twice in 90 days | IN_APP · PUSH | PROMOTIONAL · NEW_PRODUCTS | Commander maintenant → /products/{productId}?variantId={variantId} | variant.low_stock_reached | V1 |
flash_sale.started | An isFlashSale promotion (2 h–48 h) becomes ACTIVE | audience of the promotion | IN_APP · PUSH (TTL = time left) | PROMOTIONAL · PROMOTIONS | Commander maintenant → RA-24 /promotions/{promotionId} | flash_sale.started | V1 |
price.dropped | PriceChange lowers a customer-type price by 5 % or more | Resellers of that type with the product in favorites, cart, or orders of the last 90 days | IN_APP · PUSH | PROMOTIONAL · PROMOTIONS | Voir le produit → /products/{productId}?variantId={variantId} | price.dropped | V1 |
commercial_push.sent | admin.announcements.send of kind COMMERCIAL_PUSH, or its scheduled time | audience chosen in BO-57 | IN_APP · PUSH | PROMOTIONAL · PROMOTIONS | Editable, default Commander maintenant → /products/{productId} | commercial_push.sent | V1 |
catalogue.published | Announcement of kind NEW_CATALOGUE published | audience (default every reseller) | IN_APP · PUSH | PROMOTIONAL · NEW_PRODUCTS | Voir le catalogue → /messages/{announcementId} | catalogue.published | V1 |
trending.computed | Job smart.trendingWeekly (Sunday 09:00): top product by units over 7 days | Resellers who did not order it in 30 days | IN_APP · PUSH | PROMOTIONAL · NEW_PRODUCTS | Voir le produit → /products/{productId} | trending.computed | V1 |
cart.abandoned | Job smart.abandonedCarts (every 15 min): cart unchanged for 2 h, no checkout | The reseller | IN_APP · PUSH | PROMOTIONAL · SUGGESTIONS | Finaliser ma commande → RA-17 /cart | cart.abandoned | V1 |
reorder.reminder_due | Job smart.reorderReminders (09:30): days since the last order of a sub-category reach the reseller's cadence + 1 | The reseller | IN_APP · PUSH | PROMOTIONAL · SUGGESTIONS | Commander à nouveau → RA-22 /orders/{orderId}?reorder=1 | reorder.reminder_due | V1 |
complement.suggested | Job smart.complementSuggestions (10:30): the day after an order containing a product with a configured complement is VALIDATED | The reseller | IN_APP · PUSH | PROMOTIONAL · SUGGESTIONS | Voir le produit → /products/{complementProductId} | complement.suggested | V1 |
promotion.started, daily_offer.published | As in Promotions | — | — | PROMOTIONAL · PROMOTIONS | Voir l'offre | — | V1 |
loyalty.double_points_started, loyalty.level_reached, mission.assigned, mission.completed, reward.unlocked, loyalty.anniversary_reached, loyalty.level_almost_reached, mission.personalised, challenge.started, leaderboard.published | Loyalty engine (NOT-18) | The reseller or the campaign audience | IN_APP · PUSH | PROMOTIONAL or TRANSACTIONAL · LOYALTY | See V2 types | same as the event | V2 |
academy.video_published, academy.quiz_published, suggestion.bought_together | New modules (D-40, D-41) | — | — | PROMOTIONAL · ACADEMY / SUGGESTIONS | — | — | Later |
The French copy of these templates is on smart notifications.
Smart notification settings
Business settings (BO-64, admin.settings) that tune the rows above; defaults from HOCO's document 03 (D-39, D-43, D-44).
| Key | Default (range) | Effect |
|---|---|---|
notifications.promotionalDailyCap | 1 (0–10) | Promotional pushes per reseller and day; above it the item goes to the inbox only |
notifications.promotionalWeeklyCap | 3 (0–50) | Promotional pushes per reseller and week |
smart.pushWindowMinutes | 5 (1–60) | Window in which several smart events for a reseller merge into one push |
smart.productCooldownDays | 7 (0–30) | No second push about the same product within this many days |
smart.backoffUnopenedPushes | 5 (0–50) | After this many unopened promotional pushes in a row, the reseller is backed off |
smart.backoffWeeklyCap | 1 (0–10) | Weekly push cap while backed off |
smart.backoffDays | 30 (1–90) | Length of the back-off |
smart.restockMinOutHours | 24 (0–720) | variant.restocked only after this long out of stock |
smart.restockLookbackDays | 90 (1–365) | Orders this recent make a reseller a restock recipient |
smart.priceDropMinPercent | 5 (1–90) | Smallest drop of a customer-type price that sends price.dropped |
smart.abandonedCartDelayHours | 2 (1–72) | A cart unchanged this long is abandoned |
smart.abandonedCartMaxAgeDays | 3 (1–30) | Older carts are no longer reminded |
smart.reorderMinOrders | 3 (2–20) | Orders of a sub-category needed to learn a reorder cadence |
smart.reorderLookbackDays | 180 (30–730) | Orders considered for the cadence |
smart.reorderGraceDays | 1 (0–30) | Days after the cadence before reorder.reminder_due |
smart.reorderMaxLateDays | 7 (1–60) | No reminder once this late |
smart.complementLookbackDays | 60 (0–365) | A complement bought this recently is not suggested |
smart.trendingMinBuyers | 5 (1–100) | Distinct buyers a product needs to be « tendance » |
smart.trendingExcludeDays | 30 (0–365) | Resellers who ordered the trending product this recently are skipped |
Field operations
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
tour.assigned | admin.tours.create (a recurring series sends one notification) | Delegate | IN_APP · PUSH | FIELD | DA-07 /tours/{tourId} | tour.assigned · tour.assigned.series | V1 |
tour.updated | admin.tours.update, admin.tours.setStops, admin.tours.cancel | Delegate | IN_APP · PUSH (+ data message that triggers sync.delta) | FIELD | DA-06 /tours/{tourId} | tour.updated · tour.updated.cancelled | V1 |
tour.started | delegate.tours.start | holders:field.view | SSE (tour.started) only by default; IN_APP can be enabled in BO-58 | FIELD | BO-23 /field/tours/{tourId} | tour.started.staff | V1 |
tour.exceptions | Job tours.activateToday (00:05): tours not started or not closed the day before | holders:field.view | IN_APP · EMAIL digest | FIELD | BO-21 /field/tours | tour.exceptions.digest | V1 |
tour.closed | delegate.tours.close | holders:field.view | IN_APP · SSE (tour.closed) | FIELD | BO-23 | tour.closed.staff | V1 |
tour.reopened | admin.tours.reopen with a reason | Delegate | IN_APP · PUSH (+ sync data message) | FIELD | DA-20 /tours/{tourId}/closing | tour.reopened | V1 |
visit.not_done | delegate.visits.notDone | holders:field.view — immediately for HIGH priority stops, otherwise only inside the tour.closed summary | IN_APP | FIELD | BO-25 /field/visits/{visitId} | visit.not_done.staff | V1 |
task.due | Job at 08:00 Africa/Algiers on the due date (open and overdue tasks) | assignee | IN_APP · PUSH (one digest per morning) | FIELD | DA-19 /tasks · BO-14 | task.due.digest | V1 |
task.assigned | admin.tasks.create or reassignment by someone else | assignee | IN_APP · PUSH (delegates) | FIELD | DA-19 · BO-14 | task.assigned | V1 |
invitation.step_completed | Funnel step reached (invitation funnel) | inviter | IN_APP (every step) · PUSH (ACCOUNT_CREATED, FIRST_ORDER) | FIELD | DA-16 /invitations/{invitationId} | invitation.step_completed.<step> | V1 |
Support and communication
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
ticket.created (other types) | tickets.create — ORDER_ISSUE, DELIVERY_ISSUE, BILLING, QUESTION, OTHER | holders:support.update | IN_APP · EMAIL digest | TRANSACTIONAL | BO-56 | ticket.created.staff | V1 |
ticket.replied | admin.tickets.reply public reply (internal notes never notify) | Reseller | IN_APP · PUSH · SSE (ticket.replied) | TRANSACTIONAL | RA-34 · RW-14 | ticket.replied | V1 |
tickets.reply by the reseller | assignee (else holders:support.update) | IN_APP | TRANSACTIONAL | BO-56 | ticket.replied.staff | V1 | |
ticket.status_changed | admin.tickets.update → WAITING_RESELLER, RESOLVED (non-SAV); job support.autoClose → CLOSED | Reseller | IN_APP · PUSH (WAITING_RESELLER, RESOLVED) · IN_APP only (CLOSED) | TRANSACTIONAL | RA-34 | ticket.status_changed.<status> | V1 |
announcement.published | admin.announcements.send or scheduled publication | audience — delegates and/or resellers | IN_APP · PUSH, SMS, EMAIL (each when the announcement asks for it — SMS and e-mail for messages only, SRV-42) | ANNOUNCEMENT | DA-23 /messages/{announcementId} · RA-26 | announcement.published | V1 |
Security
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
otp.code | auth.otp.send — SIGNUP, PASSWORD_RESET, PHONE_CHANGE | phone | SMS only — never stored in the inbox, never logged | SECURITY | — | otp.code.<purpose> | V1 |
security.password_changed | auth.password.change, auth.password.reset, admin.users.resetPassword | self | IN_APP · PUSH · SMS (phone accounts) · EMAIL (staff) | SECURITY | RA-35 · DA-26 · BO-68 | security.password_changed | V1 |
security.new_device | Sign-in from an X-Device-Id not seen for this user in 90 days | self | IN_APP · PUSH (other devices) · EMAIL (staff) | SECURITY | RA-35 · BO-68 | security.new_device | V1 |
Platform jobs
| Event | Trigger | Recipient | Channels | Category | Deep link | Template | Rel. |
|---|---|---|---|---|---|---|---|
export.ready | ExportJob SUCCEEDED or FAILED | requester | IN_APP · SSE · EMAIL (when the job took over 2 minutes) | TRANSACTIONAL | BO-60 /reports/exports | export.ready · export.failed | V1 |
import.finished | ImportJob dry-run or apply finished | requester | IN_APP · SSE · EMAIL | TRANSACTIONAL | BO-67 /admin/imports/{importId} · BO-32 | import.finished.dry_run · .applied · .failed | V1 |
digest.daily | Job 08:00, Sunday–Thursday | Each staff user with at least one queued item | EMAIL | TRANSACTIONAL | Links to BO-41, BO-13, BO-45, BO-56, BO-69, BO-33 | digest.daily | V1 |
Deliberately not notified
| Event | Why |
|---|---|
| Cart changes, favorites, price increases | The cart re-validates at every read and shows « Prix modifié »; a push per change would be noise. Since change request 03, a cart left 2 h (cart.abandoned) and a price drop of 5 % or more on a product the reseller is interested in (price.dropped) are notified (smart notifications) |
| Stock quantity changes for resellers | Quantities are back-office only (STK-07); availability simply changes in the catalog. The targeted variant.restocked and variant.low_stock_reached (smart notifications) never carry a number |
Automatic ResellerStatus changes (APP_INSTALLED, FIRST_ORDER, ACTIVE…) | Reported to the delegate through invitation.step_completed and the CRM timeline |
visit.checked_in, visit.completed, delegate.position | Back-office SSE only (live map, BO-06) |
| Successful sign-in on a known device | Audit log only (AuditAction LOGIN) |
French copy
Proposed copy for every template, to be finalised by Akram in DQA-10 and loaded as the fr seed. Rules:
- Lengths are measured on the rendered text with typical values: push/in-app title ≤ 40 characters, body ≤ 120; SMS ≤ 160 characters in the GSM-7 alphabet (one segment); email subject ≤ 70.
- Variables are written
{{camelCase}}. Free-text variables are truncated by the renderer:reasonandsubjectto 40 characters,excerptto 50, store names to 25, product names to 30 — with an ellipsis. - Formats: amounts
125 000,00 DA(TTC unless stated); dates03/11/2026or03/11; times14:32(24 h,Africa/Algiers); business numbers as stored (CMD-2026-000158). - Tone: vous, short, factual, one action per message; no quantity of stock; no personal data beyond the store name in a push (pushes transit through Google); never a code or a password in a push.
- SMS use only GSM-7 characters: é, è, à, ù and straight apostrophes are fine; avoid ê, â, î, ô, û, ç, curly apostrophes and « » — one such character switches the SMS to UCS-2 (70 characters per segment) and doubles the cost. Arabic SMS are always UCS-2.
Typical values used for the length check: orderNumber CMD-2026-000158 · totalTtc 125 000,00 DA · storeName Phone Center Bahia Oran · delegateFirstName Abdelkader · reason 40 characters · productName BOROFONE BX51 câble USB-C 1 m · expiresAt 05/11 à 14:32.
Orders and assisted orders
| Template | Channel | Title | Body |
|---|---|---|---|
order.submitted.reseller | PUSH · IN_APP | Commande envoyée | Votre commande {{orderNumber}} de {{totalTtc}} est reçue. Elle sera confirmée par notre équipe. |
order.submitted.staff | IN_APP | Commande à confirmer | {{storeName}} ({{wilayaName}}) : {{orderNumber}}, {{totalTtc}}. {{controlSummary}} |
order.submitted.delegate | PUSH · IN_APP | Commande d'un de vos revendeurs | {{storeName}} a passé la commande {{orderNumber}} de {{totalTtc}}. |
order.confirmation_overdue | IN_APP · EMAIL | Commandes en attente de confirmation | {{count}} commandes attendent depuis plus de {{waitingHours}} h. La plus ancienne : {{orderNumber}}. |
order.validated | PUSH · IN_APP | Commande confirmée | Votre commande {{orderNumber}} de {{totalTtc}} est confirmée. Nous la préparons. |
order.validated.delegate | IN_APP | Commande confirmée | La commande {{orderNumber}} de {{storeName}} est confirmée. |
order.refused | PUSH · IN_APP | Commande refusée | Votre commande {{orderNumber}} est refusée : {{reason}}. Contactez-nous. |
order.refused.delegate | PUSH · IN_APP | Commande refusée | {{orderNumber}} de {{storeName}} refusée : {{reason}}. |
order.modified | PUSH · IN_APP | Commande modifiée | HOCO a modifié {{orderNumber}} : {{changeSummary}}. Nouveau total : {{totalTtc}}. |
order.modified.delegate | IN_APP | Commande modifiée | {{orderNumber}} de {{storeName}} : {{changeSummary}}. |
order.status_changed.pending | PUSH · IN_APP | Commande en attente | Votre commande {{orderNumber}} est en attente : {{reason}}. |
order.status_changed.preparing | PUSH · IN_APP | Commande en préparation | Votre commande {{orderNumber}} est en cours de préparation. |
order.status_changed.ready | PUSH · IN_APP | Commande prête | Votre commande {{orderNumber}} est prête et attend son expédition. |
order.status_changed.ready_pickup | PUSH · IN_APP | Commande prête à retirer | Votre commande {{orderNumber}} vous attend au {{warehouseName}}. |
order.status_changed.shipped | PUSH · IN_APP | Commande expédiée | {{orderNumber}} est en route avec {{carrierName}}. Suivi : {{trackingNumber}}. |
order.status_changed.delivered | PUSH · IN_APP | Commande livrée | Votre commande {{orderNumber}} a été livrée. Merci pour votre confiance. |
order.status_changed.returned | PUSH · IN_APP | Commande retournée | Votre commande {{orderNumber}} nous est revenue. Notre équipe vous contacte. |
order.status_changed.delegate | IN_APP | Commande {{statusLabel}} | {{orderNumber}} de {{storeName}} : {{statusLabel}}. |
delivery.failed | PUSH · IN_APP | Échec de livraison | Nous n'avons pas pu livrer {{orderNumber}}. Nous vous recontactons pour une nouvelle date. |
delivery.failed.staff | IN_APP | Échec de livraison | {{orderNumber}} ({{storeName}}) : {{reason}}. |
order.cancelled | PUSH · IN_APP | Commande annulée | Votre commande {{orderNumber}} est annulée : {{reason}}. |
order.cancelled.staff | IN_APP | Commande annulée par le client | {{storeName}} a annulé {{orderNumber}} ({{totalTtc}}). |
order.cancelled.delegate | IN_APP | Commande annulée | {{orderNumber}} de {{storeName}} est annulée. |
assisted_order.awaiting_confirmation | PUSH · IN_APP | Commande à confirmer | {{delegateFirstName}} a préparé {{orderNumber}} ({{totalTtc}}). Confirmez-la avant le {{expiresAt}}. |
assisted_order.awaiting_confirmation | SMS | — | HOCO : {{delegateFirstName}} a préparé votre commande {{orderNumber}}. Confirmez-la dans l'application HOCO avant le {{expiresAt}}. |
assisted_order.awaiting_confirmation.staff | IN_APP | Commande assistée en attente | {{delegateFirstName}} a préparé {{orderNumber}} pour {{storeName}} ({{totalTtc}}). |
assisted_order.reminder | PUSH · IN_APP | Rappel : commande à confirmer | La commande {{orderNumber}} préparée par {{delegateFirstName}} expire le {{expiresAt}}. |
assisted_order.confirmed | PUSH · IN_APP | Commande confirmée par le client | {{storeName}} a confirmé {{orderNumber}} ({{totalTtc}}). |
assisted_order.confirmed.by_admin | PUSH · IN_APP | Commande enregistrée | Votre commande {{orderNumber}} préparée avec {{delegateFirstName}} est enregistrée par HOCO. |
assisted_order.rejected | PUSH · IN_APP | Commande refusée par le client | {{storeName}} a refusé {{orderNumber}} : {{reason}}. |
assisted_order.rejected.staff | IN_APP | Commande assistée refusée | {{storeName}} a refusé {{orderNumber}} préparée par {{delegateFirstName}}. |
assisted_order.expired.delegate | PUSH · IN_APP | Commande assistée expirée | {{storeName}} n'a pas confirmé {{orderNumber}} à temps. Relancez-le. |
assisted_order.expired | IN_APP | Commande expirée | La commande {{orderNumber}} n'a pas été confirmée à temps et a été annulée. |
Returns and after-sales
| Template | Channel | Title | Body |
|---|---|---|---|
return.requested.staff | IN_APP | Demande de retour | {{storeName}} demande le retour de {{itemCount}} articles de {{orderNumber}} ({{returnNumber}}). |
return.requested | IN_APP | Demande de retour envoyée | Votre demande {{returnNumber}} est enregistrée. Nous vous répondons rapidement. |
return.approved | PUSH · IN_APP | Retour accepté | Votre retour {{returnNumber}} est accepté. {{returnInstructions}} |
return.approved.staff | IN_APP | Retour à réceptionner | {{returnNumber}} de {{storeName}} accepté : {{itemCount}} articles attendus. |
return.rejected | PUSH · IN_APP | Retour refusé | Votre demande {{returnNumber}} est refusée : {{reason}}. |
return.received | PUSH · IN_APP | Retour reçu | Nous avons reçu {{returnNumber}}. Avoir {{creditNoteNumber}} de {{creditAmount}} émis. |
return.received.staff | IN_APP | Avoir émis | Avoir {{creditNoteNumber}} ({{creditAmount}}) pour {{storeName}}, retour {{returnNumber}}. |
ticket.created.after_sales | IN_APP | Nouvelle réclamation SAV | {{ticketNumber}} · {{storeName}} · {{issueLabel}} sur {{orderNumber}}. |
ticket.created.reseller | IN_APP | Demande enregistrée | Votre demande {{ticketNumber}} est enregistrée. Nous vous répondons ici. |
ticket.resolved.replacement | PUSH · IN_APP | Réclamation : remplacement | {{ticketNumber}} : nous remplaçons vos articles. Commande {{replacementOrderNumber}} créée. |
ticket.resolved.return | PUSH · IN_APP | Réclamation : retour accepté | {{ticketNumber}} : renvoyez les articles concernés. Retour {{returnNumber}} ouvert. |
ticket.resolved.credit_note | PUSH · IN_APP | Réclamation : avoir émis | {{ticketNumber}} : avoir {{creditNoteNumber}} de {{creditAmount}} émis à votre nom. |
ticket.resolved.repair | PUSH · IN_APP | Réclamation : réparation | {{ticketNumber}} : vos articles sont réparés sous garantie. |
ticket.resolved | PUSH · IN_APP | Demande résolue | Votre demande {{ticketNumber}} est résolue. Dites-nous si tout est en ordre. |
ticket.rejected | PUSH · IN_APP | Réclamation refusée | {{ticketNumber}} est refusée : {{reason}}. |
Accounts, finance, stock and promotions
| Template | Channel | Title | Body |
|---|---|---|---|
account.pending_validation.staff | IN_APP | Compte à valider | {{storeName}} ({{communeName}}, {{wilayaName}}), type {{customerTypeLabel}}, attend sa validation. |
account.pending_validation | IN_APP | Compte en cours de validation | Merci ! Nous vérifions vos informations. Vous serez prévenu dès la validation. |
account.approved | PUSH · IN_APP | Compte validé | Bienvenue chez HOCO ! Votre compte est validé, vous pouvez passer commande. |
account.approved | SMS | — | HOCO : votre compte est validé. Vous pouvez désormais commander dans l'application HOCO. |
account.approved.delegate | PUSH · IN_APP | Compte validé | Le compte de {{storeName}} est validé. Accompagnez sa première commande. |
account.rejected | PUSH · IN_APP | Compte non validé | Votre demande n'est pas validée : {{reason}}. Contactez-nous pour la compléter. |
account.rejected.delegate | PUSH · IN_APP | Compte refusé | Le compte de {{storeName}} est refusé : {{reason}}. |
account.customer_type_changed | PUSH · IN_APP | Vos tarifs ont changé | Votre compte est désormais en tarif {{customerTypeLabel}}. Les prix affichés sont mis à jour. |
account.suspended | SMS | — | HOCO : votre compte est suspendu. Pour plus d'informations, appelez le {{supportPhone}}. |
account.suspended | IN_APP | Compte suspendu | Votre compte est suspendu. Contactez le service client au {{supportPhone}}. |
account.suspended.delegate | IN_APP | Revendeur suspendu | {{storeName}} est suspendu : ses commandes sont bloquées. |
user.invited.sms | SMS | — | HOCO : votre compte délégué est créé. Mot de passe provisoire {{tempPassword}}, valable 72 h, à changer à la 1re connexion. |
user.invited.email | EMAIL | Votre accès au back-office HOCO | (body: sign-in link, temporary password valid 72 h, password rules, support contact) |
payment.declared.staff | IN_APP | Paiement à vérifier | {{storeName}} déclare {{amount}} par {{methodLabel}} (réf. {{reference}}). |
payment.confirmed | PUSH · IN_APP | Paiement confirmé | Votre paiement de {{amount}} ({{paymentNumber}}) est confirmé. Merci ! |
payment.rejected | PUSH · IN_APP | Paiement non confirmé | Votre déclaration de {{amount}} n'est pas confirmée : {{reason}}. |
invoice.issued | IN_APP | Nouvelle facture | Votre facture {{invoiceNumber}} de {{totalTtc}} est disponible. |
invoice.issued | EMAIL | Votre facture {{invoiceNumber}} — HOCO | (body: amount, due date, PDF link valid 5 minutes after opening the app or web, payment instructions) |
invoice.overdue (V2) | PUSH · IN_APP | Facture en retard | La facture {{invoiceNumber}} ({{balance}}) est échue depuis le {{dueDate}}. |
stock.low | IN_APP | Stock bas | {{productName}} : {{availableQty}} disponibles (seuil {{threshold}}) au {{warehouseName}}. |
stock.low.digest | EMAIL | {{count}} références sous le seuil d'alerte | (body: table of variants, available, threshold, warehouse; staff only) |
stock.out | IN_APP | Rupture de stock | {{productName}} est en rupture au {{warehouseName}}. {{pendingOrders}} commandes à confirmer le contiennent. |
promotion.started | PUSH · IN_APP | Nouvelle promotion | {{promotionLabel}} jusqu'au {{endDate}}. À découvrir dans l'application. |
promotion.started.delegate | IN_APP | Promotion dans votre zone | {{promotionLabel}} jusqu'au {{endDate}}. Présentez-la pendant vos visites. |
daily_offer.published | PUSH · IN_APP | Offre du jour | {{productName}} à {{offerPrice}} au lieu de {{regularPrice}}, aujourd'hui seulement. |
daily_offer.published.grouped | PUSH | {{count}} offres pour vous aujourd'hui | Dont {{productName}} à {{offerPrice}}. Découvrez-les avant ce soir. |
stock.low and stock.out are staff-only; they are the only templates that may carry a quantity.
Field, support, security and platform
| Template | Channel | Title | Body |
|---|---|---|---|
tour.assigned | PUSH · IN_APP | Nouvelle tournée planifiée | {{tourNumber}} le {{tourDate}} : {{stopCount}} visites dans {{zoneName}}. |
tour.assigned.series | PUSH · IN_APP | Tournées planifiées | {{count}} tournées du {{fromDate}} au {{toDate}} ajoutées à votre planning. |
tour.updated | PUSH · IN_APP | Tournée modifiée | Votre tournée du {{tourDate}} a changé : {{changeSummary}}. Synchronisez avant de partir. |
tour.updated.cancelled | PUSH · IN_APP | Tournée annulée | Votre tournée du {{tourDate}} est annulée : {{reason}}. |
tour.started.staff | IN_APP | Tournée démarrée | {{delegateName}} a démarré {{tourNumber}} à {{time}} ({{stopCount}} visites). |
tour.exceptions.digest | IN_APP | Tournées à vérifier | Hier : {{notStartedCount}} non démarrées, {{notClosedCount}} non clôturées. |
tour.closed.staff | IN_APP | Tournée clôturée | {{delegateName}} : {{doneCount}}/{{stopCount}} visites, {{signupCount}} inscriptions, {{orderCount}} commandes. |
tour.reopened | PUSH · IN_APP | Tournée réouverte | Votre tournée du {{tourDate}} est réouverte par {{actorName}} : {{reason}}. |
visit.not_done.staff | IN_APP | Visite prioritaire non réalisée | {{delegateName}} n'a pas visité {{storeName}} : {{reason}}. |
task.due.digest | PUSH · IN_APP | Vos relances du jour | {{count}} relances aujourd'hui, dont {{overdueCount}} en retard. Première : {{storeName}}. |
task.assigned | PUSH · IN_APP | Nouvelle relance | {{assignerName}} vous confie : {{taskTypeLabel}}, {{storeName}}, pour le {{dueDate}}. |
invitation.step_completed.account_created | PUSH · IN_APP | Compte créé | {{storeName}} a créé son compte grâce à votre invitation. |
invitation.step_completed.first_order | PUSH · IN_APP | Première commande ! | {{storeName}} a passé sa première commande ({{totalTtc}}). Bravo ! |
invitation.step_completed.other | IN_APP | Invitation : {{stepLabel}} | {{storeName}} : {{stepLabel}}. |
ticket.created.staff | IN_APP | Nouvelle demande client | {{ticketNumber}} · {{ticketTypeLabel}} · {{storeName}} : {{subject}} |
ticket.replied | PUSH · IN_APP | Réponse du service client | Nouvelle réponse sur votre demande {{ticketNumber}} : {{excerpt}} |
ticket.replied.staff | IN_APP | Le client a répondu | {{storeName}} sur {{ticketNumber}} : {{excerpt}} |
ticket.status_changed.waiting_reseller | PUSH · IN_APP | Votre réponse est attendue | Nous avons besoin de précisions pour traiter {{ticketNumber}}. Répondez dans l'application. |
ticket.status_changed.closed | IN_APP | Demande fermée | Votre demande {{ticketNumber}} est fermée. Ouvrez-en une nouvelle si besoin. |
announcement.published | PUSH · IN_APP | {{announcementTitle}} (BO-57 limits titles to 40 characters) | {{announcementExcerpt}} (first 120 characters) |
announcement.published | SMS | — | {{smsText}} (the announcement's own SMS text, ≤ 160) |
announcement.published | EMAIL | {{announcementTitle}} | {{announcementBody}} |
otp.code.signup | SMS | — | HOCO : votre code d'inscription est {{code}}. Il expire dans 5 minutes. Ne le partagez avec personne. |
otp.code.password_reset | SMS | — | HOCO : code de réinitialisation {{code}}, valable 5 minutes. HOCO ne vous le demandera jamais. |
otp.code.phone_change | SMS | — | HOCO : code {{code}} pour confirmer votre nouveau numéro. Valable 5 minutes. |
security.password_changed | PUSH · IN_APP | Mot de passe modifié | Votre mot de passe a été modifié le {{date}} à {{time}}. Pas vous ? Contactez-nous. |
security.password_changed | SMS | — | HOCO : le mot de passe de votre compte a été modifié. Si ce n'est pas vous, appelez le {{supportPhone}}. |
security.new_device | PUSH · IN_APP | Nouvelle connexion | Connexion depuis {{deviceLabel}} le {{date}} à {{time}}. Pas vous ? Changez votre mot de passe. |
export.ready | IN_APP | Export prêt | {{exportLabel}} ({{rowCount}} lignes) est prêt, téléchargeable pendant 7 jours. |
export.failed | IN_APP | Export échoué | {{exportLabel}} n'a pas pu être généré. Réessayez ou contactez l'administrateur. |
import.finished.dry_run | IN_APP | Simulation d'import terminée | {{importLabel}} : {{rowsOk}} lignes valides, {{rowsError}} à corriger avant l'import. |
import.finished.applied | IN_APP | Import terminé | {{importLabel}} : {{rowsOk}} lignes importées, {{rowsError}} en erreur. Consultez le rapport. |
digest.daily | EMAIL | HOCO — à traiter aujourd'hui ({{count}}) | (body: counts and links per queue — orders to confirm, accounts, payments, tickets and claims, returns, stock alerts) |
The OTP SMS may end with the 11-character app hash of the Android SMS Retriever API so the reseller app fills the code automatically; one hash exists per signing key and flavor. Without it the app uses the SMS User Consent API (a system prompt, no hash).
Quiet hours
Quiet hours are 21:00–08:00 Africa/Algiers (setting notifications.quietHours); the inbox row is always written immediately — only external channels wait.
| Category | During quiet hours |
|---|---|
SECURITY (OTP included) | Sent immediately, sound on |
TRANSACTIONAL | Push sent immediately on the silent Android channel (no sound or vibration); SMS of the whitelist sent immediately except account.approved, deferred to 08:00; staff emails batched into the 08:00 digest |
FIELD | Deferred to 07:00 unless it concerns today's tour (a stop changed on a tour IN_PROGRESS is sent at once) |
PROMOTIONAL | Deferred to the next window (09:00–20:00); never sent on Friday before 14:00 |
ANNOUNCEMENT | Deferred to 08:00 unless the author ticks « Urgent » (communication.validate required) |
Assumption — Friday morning and the Ramadan window (no promotional push from one hour before to one hour after sunset) are proposals to validate with HOCO; the Ramadan profile is a second quiet-hours setting enabled by date range.
The apps create one Android notification channel per category at first launch, named with the French labels of NotificationCategory (Commandes et compte, Sécurité, Activité terrain, Offres et promotions, Annonces) plus Commandes et compte (silencieux) for quiet hours. Users can mute a channel in Android settings; the app reflects a muted channel in its own preferences screen.
De-duplication and grouping
- Exactly once per channel —
NotificationDeliveryis unique on (outboxEventId,recipientUserId,channel); a replayed outbox row or a retried job never sends twice. - Collapse per entity — pushes carry
collapse_keyand Androidtag=order:{orderId}(orticket:…,return:…,tour:…): a newer status replaces the older one in the notification tray; the inbox keeps every row. - Merge window — same recipient, same entity, events within 60 seconds → one notification with the latest state (
order.modifiedfollowed byorder.validatedbecomes « Commande confirmée » with the modification summary in the body). - One assisted-order notification —
assisted_order.confirmedand the followingorder.submittedfor the same order are merged for the reseller. - Digests —
task.due(one per assignee per morning),stock.low(immediate in-app, email in the 08:00 digest), staff email notifications (onedigest.daily), invitation steps reached within 10 minutes (one notification with the furthest step). - Hysteresis —
stock.lowfires once per variant and warehouse until available stock goes back above the threshold. - Promotional caps — at most one
PROMOTIONALpush per reseller per day (settingnotifications.promotionalDailyCap, default 1) and three per rolling 7 days (notifications.promotionalWeeklyCap, default 3, D-39); several offers the same day becomedaily_offer.published.grouped. Over the cap the inbox row is still written; the push slot goes to the highest-priority candidate (anti-fatigue rules). - Queue resolution — queue notifications (
order.submitted.staff,account.pending_validation.staff,payment.declared.staff,return.requested.staff,ticket.created.*) are marked read for every holder when the item is handled.
Retries and fallbacks
| Channel | Retries | Failure handling | Fallback |
|---|---|---|---|
IN_APP | Part of the notification job (BullMQ, 5 attempts, exponential backoff from 30 s) | Job moves to the failed set; alert above 20 failures per hour | None needed — the row is the source of truth |
PUSH | On FCM 429 and 5xx: 5 attempts (30 s, 2 min, 10 min, 30 min, 2 h) within the TTL | UNREGISTERED or invalid token → the Device token is deleted, no retry | None. The inbox row exists; the app refreshes the badge on resume. Push failure never triggers an SMS |
SMS | Transport errors: 3 attempts within 2 minutes (OTP), 3 within 1 hour (others) | Delivery report FAILED or EXPIRED recorded on the delivery | OTP: the user taps « Renvoyer le code » after 60 s; the secondary SMS route takes over if the provider's circuit breaker is open (integrations) |
EMAIL | 5 attempts over 1 hour | Hard bounce → the address is flagged and skipped until edited | None |
SSE | Client reconnects with Last-Event-ID | Server keeps 5 minutes of events per stream for resume | The app reloads the screen on resume |
Time-to-live: OTP 5 min · PROMOTIONAL 12 h · FIELD 12 h · TRANSACTIONAL and SECURITY 24 h · ANNOUNCEMENT 72 h. A message past its TTL is dropped, not delivered late (FCM ttl, SMS validity period). FCM delivery is weakened by aggressive battery savers on several Android brands common in Algeria; the delegate app's onboarding asks to exclude it from battery optimisation (DA-04).
Preferences
Preferences are stored in NotificationPreference (user × category × channel) and edited on RA-35, RW-12 and DA-26 through notifications.preferences.update (NOT-06).
| Category | Reseller | Delegate | Staff |
|---|---|---|---|
TRANSACTIONAL | Locked on | Locked on | Locked on (inbox); emails come as the daily digest |
SECURITY | Locked on | Locked on | Locked on |
FIELD | — | Locked on (Assumption — it carries work instructions) | — |
PROMOTIONAL | Push on by default, can be turned off | In-app only | — |
ANNOUNCEMENT | Push on by default, can be turned off | Push on by default, can be turned off | — |
Since change request 03, resellers can also mute push per NotificationGroup — Promotions, Nouveautés, Suggestions (V2 Points et niveaux); Urgent is locked (NOT-17).
Rules: preferences gate external channels only — the inbox always receives; locked toggles are shown disabled with « Indispensable au suivi de vos commandes » or « Indispensable à la sécurité de votre compte »; an opt-out applies from the next dispatch; marketing over SMS or WhatsApp (V2) requires a prior opt-in recorded with its date (to validate with HOCO's counsel under Law 18-07, see cross-cutting requirements).
Cost notes per channel
| Channel | Pricing model | Volume estimate (generalisation, Assumption) | Cost driver to watch |
|---|---|---|---|
IN_APP, SSE | Our infrastructure only | All notifications | Inbox rows kept 12 months, delivery rows 90 days (retention) |
PUSH (FCM) | No per-message charge | A few thousand per day | None in practice; per-project rate quotas are far above HOCO's volume |
SMS | Per segment, per network, from the chosen gateway (D-13) | Launch month: about 1.5 OTP per new reseller + resets + whitelisted events ≈ 2 000–4 000 SMS; then ≈ 500–1 000 per month | Segments (accents outside GSM-7, Arabic), resend abuse (rate limits of AUTH-07) |
EMAIL | Per message or monthly tier of the transactional provider | Under 3 000 per month (staff digests, invoices, exports) | Negligible |
WHATSAPP (V2) | Meta charges per delivered template message by category (marketing, utility, authentication) and country; the rate card changes | To size in V2 | Template category; marketing messages cost the most |
Formula for the SMS budget HOCO validates (cahier §11 « Dépendances externes », INT-07): monthly cost = Σ (messages × segments × unit price per network).
Push payload and deep links
{
"message": {
"token": "fcm-device-token",
"notification": {
"title": "Commande confirmée",
"body": "Votre commande CMD-2026-000158 de 125 000,00 DA est confirmée. Nous la préparons."
},
"android": {
"collapse_key": "order:0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01",
"ttl": "86400s",
"priority": "high",
"notification": {
"channel_id": "transactional",
"tag": "order:0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01"
}
},
"data": {
"notificationId": "0192f4c1-2b3a-7d44-8e21-3f9a0c7d5e02",
"eventKey": "order.validated",
"route": "/orders/0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01",
"entityType": "ORDER",
"entityId": "0192f4b8-7c1e-7a10-9f00-5b6d2c8e1a01"
}
}
}Rules for the apps:
datavalues are strings (FCM requirement). The app routes onroute; when the route is unknown to its version, it opens the inbox (RA-26, DA-23).- Opening a notification marks it read (
notifications.markRead) withnotificationId. - The reseller app and the reseller web use the same route paths (
/orders/{orderId},/returns/{returnId},/support/tickets/{ticketId},/promotions/{promotionId},/finance/invoices/{invoiceId}), so one template serves both; email links arehttps://app.hoco.dz+ route. Assumption — route names are proposals; the apps' routers own them and the payload change is a template edit. - The route only identifies the record; the app always fetches it with the user's own session, so a notification opened on a device where another account is signed in shows « Cette notification concerne un autre compte » and nothing else.
- Data-only messages (
tour.updatedon a tourIN_PROGRESS,tour.reopened) carry{"type": "sync", "scope": "tours"}and triggersync.delta; they always accompany a visible notification because Android throttles silent high-priority messages. - If iOS is decided (D-10), the same message adds
apns.headers.apns-collapse-idequal to the Android tag.
Adding a new event
- Name it
<entity>.<past_participle>in snake case (return.received), unique, never reused; add it to the emitting module in the service catalogue. - Emit it as an
OutboxEventin the same transaction as the state change, with a versioned payload of identifiers and display values only — no secrets, no codes, no stock quantity for reseller-facing events. - Decide the audience and channels with Anis: recipient resolvers, category, whether it is a queue notification, SMS only if it meets the whitelist rule (cost approved by HOCO).
- Write the rule in the seed (
NotificationRule: channels, category, collapse key, merge window, TTL, quiet-hours policy) and expose it in BO-58. - Write the copy for each channel in
fr(and emptyar,enkeys that fall back), respecting the copy rules; run the length and GSM-7 check. - Wire the deep link in RA, RW, DA or BO, with the inbox fallback; add the SSE event to
events.streamoradmin.events.streamif open screens must update live. - Update this page (matrix row and copy row) in the same pull request, and the copy deck of DQA-10.
- Test it: unit test of recipient resolution and preference filtering; contract snapshot of the payload; integration test asserting one inbox row and one delivery per channel through the dev simulators of SRV-08; a deep-link check in AT-29.
- Re-estimate costs if SMS, email or WhatsApp volume changes, and tell HOCO.
Permissions matrix
The permission catalogue (module × action), what each of the eight roles holds, sensitive-field and data-scope rules, guard rails, auditing of permission changes and the test checklist for ACL-05.
Smart notifications
HOCO's document 03 (Smart Notifications) treated as a change request: the 24 notification types, the Notification Centre, the action button, the Top 3 and the promotion tabs, each with its trigger, audience, channel, group, action and release, plus anti-fatigue rules, data and API impact and the French and Arabic copy.