HOCO B2B
SRS

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

  1. A module changes state and writes an OutboxEvent (eventKey, entityType, entityId, actorUserId, versioned payload) in the same database transaction. Nothing is sent from a request handler.
  2. 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.
  3. The notification service loads the NotificationRule rows of the event key: for each recipient resolver, the channels, the category, the collapse key, the merge window and the time-to-live.
  4. Recipients are resolved (table below), then filtered: actor rule, account status, preferences, quiet hours and caps.
  5. Templates are rendered per recipient locale (fr in V1; ar and en keys exist and fall back to fr, NFR-06).
  6. One Notification row (the inbox entry, channel IN_APP) is written per recipient, then one NotificationDelivery row per external channel, each sent from its own queue. The user's open apps receive notification.created on their SSE stream to update the badge.
  7. Re-processing an outbox row never sends twice: NotificationDelivery is unique on (outboxEventId, recipientUserId, channel).

Live updates versus notifications

MechanismWhoWhenCarriesOperation
Back-office SSEStaff with a back-office session, filtered by permissionWhile a back-office page is openorder.created, order.status_changed, visit.checked_in, visit.completed, tour.started, tour.closed, delegate.position, stock.alert, account.pending, plus return and claim eventsadmin.events.stream
User SSEResellers (RA, RW) and delegates (DA), own events onlyWhile the app or page is openorder.status_changed, order.modified, assisted_order.awaiting_confirmation, ticket.replied, return.status_changed, notification.createdevents.stream
Inbox (IN_APP)Every recipient with an accountAlways, written before any external channelTitle, body, route, read statenotifications.list
Push (PUSH)Reseller and delegate appsAlways for order status changes; per matrix otherwiseTitle, body, routeFCM, 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

ChannelAppsUsed forReleaseNotes
IN_APPRA, RW, DA, BOEvery notification of a user with an account — the historyV1Never disabled by preferences; unread badge via notifications.unreadCount
PUSHRA, DA (FCM)Transactional, field, promotional and announcement alertsV1No web push in V1: the reseller web and the back-office rely on SSE and the inbox (Assumption)
SMSAny phoneOne-time codes and the short list of critical messages (below)V1GSM-7 French copy only, one segment
EMAILStaff; resellers who gave an addressStaff invitations, daily staff digest, export and import results, invoices to resellers, security alerts to staffV1Resellers 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)V2Rows 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.

EventWhy SMS
otp.codeThe phone is the identity; no other channel exists before sign-up
assisted_order.awaiting_confirmationTime-bound (72 h) and often a reseller who has just installed the app; matches the order lifecycle
account.approvedThe reseller may not reopen the app while waiting; sent once per reseller
account.suspendedSessions are revoked, so push and inbox can no longer reach the user
security.password_changedMust reach the owner through a channel an intruder does not control
user.invited (delegates)The delegate has no app yet

Recipients

ResolverResolves toNotes
resellerThe active user account(s) of the reseller concernedA pending account receives only account events
assignedDelegateThe delegate currently assigned to the reseller (ResellerAssignment)Category FIELD
sourceDelegateThe delegate who prepared an assisted order (sourceDelegateId)Falls back to assignedDelegate
inviterThe delegate who owns the invitationInvitation 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
assigneeThe user a task or ticket is assigned toUnassigned tickets go to holders:support.update
requesterThe user who started an export or import—
selfThe account the security event is aboutNot suppressed by the actor rule
audienceResellers or delegates matching an announcement's or promotion's targetingBatched sending
phoneA phone number, not yet a userOne-time codes only; no inbox row

Filters applied to every recipient:

  1. Actor rule — the user who performed the action is not notified of it (the screen already confirms it), except SECURITY events and the reseller's own order.submitted (its push confirms reception when the app is closed right after checkout).
  2. Account status — SUSPENDED and DISABLED users receive nothing but account.suspended and SECURITY messages.
  3. Preferences — external channels only; the inbox always receives (preferences).
  4. 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

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
order.submittedOrder reaches SUBMITTED (or PENDING on hold) through cart.checkout, a confirmed assisted order or admin.orders.createResellerIN_APP · PUSH · SSETRANSACTIONALRA-22 /orders/{orderId} · RW-09order.submitted.resellerV1
holders:orders.validate — « Commandes à confirmer » queueIN_APP · SSE (order.created) · EMAIL digestTRANSACTIONALBO-41 /orders/to-confirm → BO-39order.submitted.staffV1
assignedDelegateIN_APP · PUSHFIELDDA-18 /orders/{orderId}order.submitted.delegateV1
order.confirmation_overdueJob: an order still SUBMITTED or PENDING after orders.confirmationReminderHours working hours (default 4)holders:orders.validateIN_APP · EMAILTRANSACTIONALBO-41order.confirmation_overdueV1
order.validatedadmin.orders.validate — the explicit back-office confirmationResellerIN_APP · PUSH · SSE · V2 WHATSAPPTRANSACTIONALRA-22 · RW-09order.validatedV1
sourceDelegate or assignedDelegateIN_APPFIELDDA-18order.validated.delegateV1
order.refusedadmin.orders.refuse, reason from ORDER_REFUSAL_REASONResellerIN_APP · PUSH · SSETRANSACTIONALRA-22order.refusedV1
sourceDelegate or assignedDelegateIN_APP · PUSHFIELDDA-18order.refused.delegateV1
order.modifiedadmin.orders.modify — controlled line change with reasonResellerIN_APP · PUSH · SSE (order.modified)TRANSACTIONALRA-22order.modifiedV1
sourceDelegate (assisted orders)IN_APPFIELDDA-18order.modified.delegateV1
order.status_changedadmin.orders.transition, admin.deliveries.proof: PENDING (put on hold), PREPARING, READY, SHIPPED, DELIVERED, RETURNEDReseller — every status changeIN_APP · PUSH · SSE · V2 WHATSAPP (SHIPPED)TRANSACTIONALRA-22 · RW-09order.status_changed.<status>V1
assignedDelegateIN_APP (DELIVERED, RETURNED only)FIELDDA-18order.status_changed.delegateV1
delivery.failedadmin.deliveries.update sets DeliveryStatus FAILEDResellerIN_APP · PUSH · SSETRANSACTIONALRA-22delivery.failedV1
holders:delivery.updateIN_APPTRANSACTIONALBO-42 /deliveriesdelivery.failed.staffV1
order.cancelledorders.cancel by the reseller or admin.orders.cancel; reserved quantities return to stock automaticallyReseller (when the back-office cancels)IN_APP · PUSH · SSETRANSACTIONALRA-22order.cancelledV1
holders:orders.validate (when the reseller cancels)IN_APP · SSETRANSACTIONALBO-39 /orders/{orderId}order.cancelled.staffV1
assignedDelegateIN_APPFIELDDA-18order.cancelled.delegateV1

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

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
assisted_order.awaiting_confirmationdelegate.assisted.submit: order PENDING, AssistedConfirmation AWAITING_RESELLERResellerIN_APP · PUSH · SMS · SSE · V2 WHATSAPPTRANSACTIONALRA-23 /orders/{orderId}/confirm · RW-09assisted_order.awaiting_confirmationV1
holders:orders.validateIN_APPTRANSACTIONALBO-41assisted_order.awaiting_confirmation.staffV1
assisted_order.reminderJob orders.expireAssisted, 24 h before assistedExpiresAtResellerIN_APP · PUSHTRANSACTIONALRA-23assisted_order.reminderV1
assisted_order.confirmedorders.confirmAssisted or admin.orders.confirmAssisted; the order then follows order.submittedsourceDelegateIN_APP · PUSHFIELDDA-18assisted_order.confirmedV1
Reseller, only when the administration confirmed for themIN_APP · PUSH · SSETRANSACTIONALRA-22assisted_order.confirmed.by_adminV1
assisted_order.rejectedorders.rejectAssisted with a reasonsourceDelegateIN_APP · PUSHFIELDDA-18assisted_order.rejectedV1
holders:orders.validateIN_APPTRANSACTIONALBO-41assisted_order.rejected.staffV1
assisted_order.expiredJob orders.expireAssisted (every 15 min): AssistedConfirmation EXPIRED, order CANCELLEDsourceDelegateIN_APP · PUSHFIELDDA-18assisted_order.expired.delegateV1
ResellerIN_APP · SSETRANSACTIONALRA-22assisted_order.expiredV1

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.

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
return.requestedreturns.create on a delivered order (lines, quantities, reason, photos), RET-YYYY-NNNNNNholders:orders.validate — returns queueIN_APP · SSE · EMAIL digestTRANSACTIONALBO-69 /returns/{returnId}return.requested.staffV1
Reseller (acknowledgement)IN_APP · SSE (return.status_changed)TRANSACTIONALRA-40 /returns/{returnId} · RW-14return.requestedV1
return.approvedadmin.returns.decide — approved (status APPROVED, then IN_TRANSIT when goods are on their way)ResellerIN_APP · PUSH · SSETRANSACTIONALRA-40return.approvedV1
holders:stock.update — reception expectedIN_APPTRANSACTIONALBO-69return.approved.staffV1
return.rejectedadmin.returns.decide — rejected with a reasonResellerIN_APP · PUSH · SSETRANSACTIONALRA-40return.rejectedV1
return.receivedadmin.returns.receive: accepted quantities re-enter stock automatically (RETURN movements) and a credit note is issuedResellerIN_APP · PUSH · SSETRANSACTIONALRA-40 · RA-30return.receivedV1
holders:finance.validate — credit note issuedIN_APP · EMAIL digestTRANSACTIONALBO-44 /finance/invoicesreturn.received.staffV1
ticket.created (AFTER_SALES)tickets.create with type AFTER_SALES: order, claimed lines and quantities (TicketItem), AfterSalesIssue, photosholders:support.update (or the assignee)IN_APP · SSE · EMAIL digestTRANSACTIONALBO-56 /support/tickets/{ticketId}ticket.created.after_salesV1
Reseller (acknowledgement)IN_APPTRANSACTIONALRA-34 /support/tickets/{ticketId}ticket.created.resellerV1
ticket.resolvedadmin.tickets.resolve with REPLACEMENT (free order created), RETURN (return created), CREDIT_NOTE or REPAIR; or a non-SAV ticket set RESOLVEDResellerIN_APP · PUSH · SSETRANSACTIONALRA-34; replacement → RA-22, return → RA-40ticket.resolved.<resolution>V1
ticket.rejectedadmin.tickets.resolve with REJECTED and a reason (out of warranty, misuse…)ResellerIN_APP · PUSH · SSETRANSACTIONALRA-34ticket.rejectedV1

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

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
account.pending_validationauth.signup.reseller (with declared customer type) or a new point of sale from delegate.resellers.createholders:crm.validateIN_APP · SSE (account.pending) · EMAIL digestTRANSACTIONALBO-13 /crm/validationaccount.pending_validation.staffV1
ResellerIN_APPTRANSACTIONALRA-06account.pending_validationV1
account.approvedadmin.resellers.validate approves and confirms the customer typeResellerIN_APP · PUSH · SMSTRANSACTIONALRA-09 / · RW-02account.approvedV1
assignedDelegateIN_APP · PUSHFIELDDA-09 /resellers/{resellerId}account.approved.delegateV1
account.rejectedadmin.resellers.validate rejects with a reasonResellerIN_APP · PUSHTRANSACTIONALRA-06account.rejectedV1
assignedDelegateIN_APP · PUSHFIELDDA-09account.rejected.delegateV1
account.customer_type_changedBack-office changes Reseller.customerType after validation (audited, AUTH-11) — the reseller's prices changeResellerIN_APP · PUSHTRANSACTIONALRA-09account.customer_type_changedV1
account.suspendedadmin.users.setStatus SUSPENDED, or ResellerStatus SUSPENDEDThe userSMS · IN_APPTRANSACTIONALRA-07 / DA-02 (sign-in shows the explicit message)account.suspendedV1
assignedDelegate (reseller suspended)IN_APPFIELDDA-09account.suspended.delegateV1
user.invitedadmin.users.createNew staff userEMAILSECURITYBO-01 sign-inuser.invited.emailV1
New delegateSMSSECURITYDA-02 (store link)user.invited.smsV1

Finance

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
payment.declaredpayments.declare (transfer, CCP, BaridiMob with proof)holders:finance.validateIN_APP · EMAIL digestTRANSACTIONALBO-45 /finance/payments/{paymentId}payment.declared.staffV1
payment.confirmedadmin.payments.verify confirms, or admin.payments.recordResellerIN_APP · PUSHTRANSACTIONALRA-31 /finance/payments · RW-13payment.confirmedV1
payment.rejectedadmin.payments.verify rejects with a PAYMENT_REJECT_REASONResellerIN_APP · PUSHTRANSACTIONALRA-31payment.rejectedV1
invoice.issuedOrder reaches billing.invoiceOn (default SHIPPED)ResellerIN_APP · EMAIL (PDF link, if an address is known)TRANSACTIONALRA-30 /finance/invoices/{invoiceId} · RW-13invoice.issuedV1
invoice.overdueJob invoices.markOverdue (01:00) and the reminder policyResellerIN_APP · PUSH · SMS or WHATSAPP per policyTRANSACTIONALRA-30invoice.overdueV2
holders:finance.viewIN_APP · EMAIL digestTRANSACTIONALBO-47invoice.overdue.staffV2
assignedDelegateIN_APPFIELDDA-09invoice.overdue.delegateV2

The OVERDUE status itself exists in V1 (finance dashboard); only the reminders are V2 (FIN-07).

Stock

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
stock.lowA movement brings a variant's available stock to or below its threshold in a warehouseholders:stock.updateIN_APP · SSE (stock.alert) · EMAIL digestTRANSACTIONALBO-33 /stock?alert=lowstock.low · stock.low.digestV1
stock.outAvailable stock of a variant reaches 0 (the product turns OUT_OF_STOCK when every variant is at 0)holders:stock.update, holders:orders.validateIN_APP · SSE · EMAILTRANSACTIONALBO-33stock.outV1

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

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
promotion.startedPromotion ACTIVE (job promotions.applySchedule or manual activation) and « Notifier les revendeurs » ticked in BO-50audience — signed-in resellers matching the targetingIN_APP · PUSHPROMOTIONALRA-25 /promotions/{promotionId} · RW-10promotion.startedV1
Delegates whose zones intersect the targetingIN_APPFIELDDA-23promotion.started.delegateV1
daily_offer.publishedA daily offer goes liveaudienceIN_APP · PUSHPROMOTIONALRA-24 /promotionsdaily_offer.publishedV1

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.

EventTriggerRecipientChannelsCategory · groupAction → deep linkTemplateRel.
product.arrivedJob smart.newArrivalsDigest (10:00, 16:00): products published for the first time with « Annoncer comme nouveauté »Eligible resellers who can see the productIN_APP · PUSHPROMOTIONAL · NEW_PRODUCTSCommander maintenant → RA-14 /products/{productId}; grouped → RA-12 /catalog?tab=newproduct.arrived · .groupedV1
variant.restockedAvailability OUT_OF_STOCK → IN_STOCK after at least 24 h outResellers who ordered the variant in the last 90 days, favorited the product, or hold it in their cartIN_APP · PUSHPROMOTIONAL · NEW_PRODUCTSCommander maintenant → /products/{productId}?variantId={variantId}variant.restocked · .groupedV1
variant.low_stock_reachedAvailable stock crosses the alert threshold downwards (once until back above); no quantity in the payloadFavorites, carts, resellers who ordered it twice in 90 daysIN_APP · PUSHPROMOTIONAL · NEW_PRODUCTSCommander maintenant → /products/{productId}?variantId={variantId}variant.low_stock_reachedV1
flash_sale.startedAn isFlashSale promotion (2 h–48 h) becomes ACTIVEaudience of the promotionIN_APP · PUSH (TTL = time left)PROMOTIONAL · PROMOTIONSCommander maintenant → RA-24 /promotions/{promotionId}flash_sale.startedV1
price.droppedPriceChange lowers a customer-type price by 5 % or moreResellers of that type with the product in favorites, cart, or orders of the last 90 daysIN_APP · PUSHPROMOTIONAL · PROMOTIONSVoir le produit → /products/{productId}?variantId={variantId}price.droppedV1
commercial_push.sentadmin.announcements.send of kind COMMERCIAL_PUSH, or its scheduled timeaudience chosen in BO-57IN_APP · PUSHPROMOTIONAL · PROMOTIONSEditable, default Commander maintenant → /products/{productId}commercial_push.sentV1
catalogue.publishedAnnouncement of kind NEW_CATALOGUE publishedaudience (default every reseller)IN_APP · PUSHPROMOTIONAL · NEW_PRODUCTSVoir le catalogue → /messages/{announcementId}catalogue.publishedV1
trending.computedJob smart.trendingWeekly (Sunday 09:00): top product by units over 7 daysResellers who did not order it in 30 daysIN_APP · PUSHPROMOTIONAL · NEW_PRODUCTSVoir le produit → /products/{productId}trending.computedV1
cart.abandonedJob smart.abandonedCarts (every 15 min): cart unchanged for 2 h, no checkoutThe resellerIN_APP · PUSHPROMOTIONAL · SUGGESTIONSFinaliser ma commande → RA-17 /cartcart.abandonedV1
reorder.reminder_dueJob smart.reorderReminders (09:30): days since the last order of a sub-category reach the reseller's cadence + 1The resellerIN_APP · PUSHPROMOTIONAL · SUGGESTIONSCommander à nouveau → RA-22 /orders/{orderId}?reorder=1reorder.reminder_dueV1
complement.suggestedJob smart.complementSuggestions (10:30): the day after an order containing a product with a configured complement is VALIDATEDThe resellerIN_APP · PUSHPROMOTIONAL · SUGGESTIONSVoir le produit → /products/{complementProductId}complement.suggestedV1
promotion.started, daily_offer.publishedAs in Promotions——PROMOTIONAL · PROMOTIONSVoir 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.publishedLoyalty engine (NOT-18)The reseller or the campaign audienceIN_APP · PUSHPROMOTIONAL or TRANSACTIONAL · LOYALTYSee V2 typessame as the eventV2
academy.video_published, academy.quiz_published, suggestion.bought_togetherNew 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).

KeyDefault (range)Effect
notifications.promotionalDailyCap1 (0–10)Promotional pushes per reseller and day; above it the item goes to the inbox only
notifications.promotionalWeeklyCap3 (0–50)Promotional pushes per reseller and week
smart.pushWindowMinutes5 (1–60)Window in which several smart events for a reseller merge into one push
smart.productCooldownDays7 (0–30)No second push about the same product within this many days
smart.backoffUnopenedPushes5 (0–50)After this many unopened promotional pushes in a row, the reseller is backed off
smart.backoffWeeklyCap1 (0–10)Weekly push cap while backed off
smart.backoffDays30 (1–90)Length of the back-off
smart.restockMinOutHours24 (0–720)variant.restocked only after this long out of stock
smart.restockLookbackDays90 (1–365)Orders this recent make a reseller a restock recipient
smart.priceDropMinPercent5 (1–90)Smallest drop of a customer-type price that sends price.dropped
smart.abandonedCartDelayHours2 (1–72)A cart unchanged this long is abandoned
smart.abandonedCartMaxAgeDays3 (1–30)Older carts are no longer reminded
smart.reorderMinOrders3 (2–20)Orders of a sub-category needed to learn a reorder cadence
smart.reorderLookbackDays180 (30–730)Orders considered for the cadence
smart.reorderGraceDays1 (0–30)Days after the cadence before reorder.reminder_due
smart.reorderMaxLateDays7 (1–60)No reminder once this late
smart.complementLookbackDays60 (0–365)A complement bought this recently is not suggested
smart.trendingMinBuyers5 (1–100)Distinct buyers a product needs to be « tendance »
smart.trendingExcludeDays30 (0–365)Resellers who ordered the trending product this recently are skipped

Field operations

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
tour.assignedadmin.tours.create (a recurring series sends one notification)DelegateIN_APP · PUSHFIELDDA-07 /tours/{tourId}tour.assigned · tour.assigned.seriesV1
tour.updatedadmin.tours.update, admin.tours.setStops, admin.tours.cancelDelegateIN_APP · PUSH (+ data message that triggers sync.delta)FIELDDA-06 /tours/{tourId}tour.updated · tour.updated.cancelledV1
tour.starteddelegate.tours.startholders:field.viewSSE (tour.started) only by default; IN_APP can be enabled in BO-58FIELDBO-23 /field/tours/{tourId}tour.started.staffV1
tour.exceptionsJob tours.activateToday (00:05): tours not started or not closed the day beforeholders:field.viewIN_APP · EMAIL digestFIELDBO-21 /field/tourstour.exceptions.digestV1
tour.closeddelegate.tours.closeholders:field.viewIN_APP · SSE (tour.closed)FIELDBO-23tour.closed.staffV1
tour.reopenedadmin.tours.reopen with a reasonDelegateIN_APP · PUSH (+ sync data message)FIELDDA-20 /tours/{tourId}/closingtour.reopenedV1
visit.not_donedelegate.visits.notDoneholders:field.view — immediately for HIGH priority stops, otherwise only inside the tour.closed summaryIN_APPFIELDBO-25 /field/visits/{visitId}visit.not_done.staffV1
task.dueJob at 08:00 Africa/Algiers on the due date (open and overdue tasks)assigneeIN_APP · PUSH (one digest per morning)FIELDDA-19 /tasks · BO-14task.due.digestV1
task.assignedadmin.tasks.create or reassignment by someone elseassigneeIN_APP · PUSH (delegates)FIELDDA-19 · BO-14task.assignedV1
invitation.step_completedFunnel step reached (invitation funnel)inviterIN_APP (every step) · PUSH (ACCOUNT_CREATED, FIRST_ORDER)FIELDDA-16 /invitations/{invitationId}invitation.step_completed.<step>V1

Support and communication

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
ticket.created (other types)tickets.create — ORDER_ISSUE, DELIVERY_ISSUE, BILLING, QUESTION, OTHERholders:support.updateIN_APP · EMAIL digestTRANSACTIONALBO-56ticket.created.staffV1
ticket.repliedadmin.tickets.reply public reply (internal notes never notify)ResellerIN_APP · PUSH · SSE (ticket.replied)TRANSACTIONALRA-34 · RW-14ticket.repliedV1
tickets.reply by the resellerassignee (else holders:support.update)IN_APPTRANSACTIONALBO-56ticket.replied.staffV1
ticket.status_changedadmin.tickets.update → WAITING_RESELLER, RESOLVED (non-SAV); job support.autoClose → CLOSEDResellerIN_APP · PUSH (WAITING_RESELLER, RESOLVED) · IN_APP only (CLOSED)TRANSACTIONALRA-34ticket.status_changed.<status>V1
announcement.publishedadmin.announcements.send or scheduled publicationaudience — delegates and/or resellersIN_APP · PUSH, SMS, EMAIL (each when the announcement asks for it — SMS and e-mail for messages only, SRV-42)ANNOUNCEMENTDA-23 /messages/{announcementId} · RA-26announcement.publishedV1

Security

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
otp.codeauth.otp.send — SIGNUP, PASSWORD_RESET, PHONE_CHANGEphoneSMS only — never stored in the inbox, never loggedSECURITY—otp.code.<purpose>V1
security.password_changedauth.password.change, auth.password.reset, admin.users.resetPasswordselfIN_APP · PUSH · SMS (phone accounts) · EMAIL (staff)SECURITYRA-35 · DA-26 · BO-68security.password_changedV1
security.new_deviceSign-in from an X-Device-Id not seen for this user in 90 daysselfIN_APP · PUSH (other devices) · EMAIL (staff)SECURITYRA-35 · BO-68security.new_deviceV1

Platform jobs

EventTriggerRecipientChannelsCategoryDeep linkTemplateRel.
export.readyExportJob SUCCEEDED or FAILEDrequesterIN_APP · SSE · EMAIL (when the job took over 2 minutes)TRANSACTIONALBO-60 /reports/exportsexport.ready · export.failedV1
import.finishedImportJob dry-run or apply finishedrequesterIN_APP · SSE · EMAILTRANSACTIONALBO-67 /admin/imports/{importId} · BO-32import.finished.dry_run · .applied · .failedV1
digest.dailyJob 08:00, Sunday–ThursdayEach staff user with at least one queued itemEMAILTRANSACTIONALLinks to BO-41, BO-13, BO-45, BO-56, BO-69, BO-33digest.dailyV1

Deliberately not notified

EventWhy
Cart changes, favorites, price increasesThe 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 resellersQuantities 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.positionBack-office SSE only (live map, BO-06)
Successful sign-in on a known deviceAudit 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:

  1. 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.
  2. Variables are written {{camelCase}}. Free-text variables are truncated by the renderer: reason and subject to 40 characters, excerpt to 50, store names to 25, product names to 30 — with an ellipsis.
  3. Formats: amounts 125 000,00 DA (TTC unless stated); dates 03/11/2026 or 03/11; times 14:32 (24 h, Africa/Algiers); business numbers as stored (CMD-2026-000158).
  4. 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.
  5. 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

TemplateChannelTitleBody
order.submitted.resellerPUSH · IN_APPCommande envoyéeVotre commande {{orderNumber}} de {{totalTtc}} est reçue. Elle sera confirmée par notre équipe.
order.submitted.staffIN_APPCommande à confirmer{{storeName}} ({{wilayaName}}) : {{orderNumber}}, {{totalTtc}}. {{controlSummary}}
order.submitted.delegatePUSH · IN_APPCommande d'un de vos revendeurs{{storeName}} a passé la commande {{orderNumber}} de {{totalTtc}}.
order.confirmation_overdueIN_APP · EMAILCommandes en attente de confirmation{{count}} commandes attendent depuis plus de {{waitingHours}} h. La plus ancienne : {{orderNumber}}.
order.validatedPUSH · IN_APPCommande confirméeVotre commande {{orderNumber}} de {{totalTtc}} est confirmée. Nous la préparons.
order.validated.delegateIN_APPCommande confirméeLa commande {{orderNumber}} de {{storeName}} est confirmée.
order.refusedPUSH · IN_APPCommande refuséeVotre commande {{orderNumber}} est refusée : {{reason}}. Contactez-nous.
order.refused.delegatePUSH · IN_APPCommande refusée{{orderNumber}} de {{storeName}} refusée : {{reason}}.
order.modifiedPUSH · IN_APPCommande modifiéeHOCO a modifié {{orderNumber}} : {{changeSummary}}. Nouveau total : {{totalTtc}}.
order.modified.delegateIN_APPCommande modifiée{{orderNumber}} de {{storeName}} : {{changeSummary}}.
order.status_changed.pendingPUSH · IN_APPCommande en attenteVotre commande {{orderNumber}} est en attente : {{reason}}.
order.status_changed.preparingPUSH · IN_APPCommande en préparationVotre commande {{orderNumber}} est en cours de préparation.
order.status_changed.readyPUSH · IN_APPCommande prêteVotre commande {{orderNumber}} est prête et attend son expédition.
order.status_changed.ready_pickupPUSH · IN_APPCommande prête à retirerVotre commande {{orderNumber}} vous attend au {{warehouseName}}.
order.status_changed.shippedPUSH · IN_APPCommande expédiée{{orderNumber}} est en route avec {{carrierName}}. Suivi : {{trackingNumber}}.
order.status_changed.deliveredPUSH · IN_APPCommande livréeVotre commande {{orderNumber}} a été livrée. Merci pour votre confiance.
order.status_changed.returnedPUSH · IN_APPCommande retournéeVotre commande {{orderNumber}} nous est revenue. Notre équipe vous contacte.
order.status_changed.delegateIN_APPCommande {{statusLabel}}{{orderNumber}} de {{storeName}} : {{statusLabel}}.
delivery.failedPUSH · IN_APPÉchec de livraisonNous n'avons pas pu livrer {{orderNumber}}. Nous vous recontactons pour une nouvelle date.
delivery.failed.staffIN_APPÉchec de livraison{{orderNumber}} ({{storeName}}) : {{reason}}.
order.cancelledPUSH · IN_APPCommande annuléeVotre commande {{orderNumber}} est annulée : {{reason}}.
order.cancelled.staffIN_APPCommande annulée par le client{{storeName}} a annulé {{orderNumber}} ({{totalTtc}}).
order.cancelled.delegateIN_APPCommande annulée{{orderNumber}} de {{storeName}} est annulée.
assisted_order.awaiting_confirmationPUSH · IN_APPCommande à confirmer{{delegateFirstName}} a préparé {{orderNumber}} ({{totalTtc}}). Confirmez-la avant le {{expiresAt}}.
assisted_order.awaiting_confirmationSMS—HOCO : {{delegateFirstName}} a préparé votre commande {{orderNumber}}. Confirmez-la dans l'application HOCO avant le {{expiresAt}}.
assisted_order.awaiting_confirmation.staffIN_APPCommande assistée en attente{{delegateFirstName}} a préparé {{orderNumber}} pour {{storeName}} ({{totalTtc}}).
assisted_order.reminderPUSH · IN_APPRappel : commande à confirmerLa commande {{orderNumber}} préparée par {{delegateFirstName}} expire le {{expiresAt}}.
assisted_order.confirmedPUSH · IN_APPCommande confirmée par le client{{storeName}} a confirmé {{orderNumber}} ({{totalTtc}}).
assisted_order.confirmed.by_adminPUSH · IN_APPCommande enregistréeVotre commande {{orderNumber}} préparée avec {{delegateFirstName}} est enregistrée par HOCO.
assisted_order.rejectedPUSH · IN_APPCommande refusée par le client{{storeName}} a refusé {{orderNumber}} : {{reason}}.
assisted_order.rejected.staffIN_APPCommande assistée refusée{{storeName}} a refusé {{orderNumber}} préparée par {{delegateFirstName}}.
assisted_order.expired.delegatePUSH · IN_APPCommande assistée expirée{{storeName}} n'a pas confirmé {{orderNumber}} à temps. Relancez-le.
assisted_order.expiredIN_APPCommande expiréeLa commande {{orderNumber}} n'a pas été confirmée à temps et a été annulée.

Returns and after-sales

TemplateChannelTitleBody
return.requested.staffIN_APPDemande de retour{{storeName}} demande le retour de {{itemCount}} articles de {{orderNumber}} ({{returnNumber}}).
return.requestedIN_APPDemande de retour envoyéeVotre demande {{returnNumber}} est enregistrée. Nous vous répondons rapidement.
return.approvedPUSH · IN_APPRetour acceptéVotre retour {{returnNumber}} est accepté. {{returnInstructions}}
return.approved.staffIN_APPRetour à réceptionner{{returnNumber}} de {{storeName}} accepté : {{itemCount}} articles attendus.
return.rejectedPUSH · IN_APPRetour refuséVotre demande {{returnNumber}} est refusée : {{reason}}.
return.receivedPUSH · IN_APPRetour reçuNous avons reçu {{returnNumber}}. Avoir {{creditNoteNumber}} de {{creditAmount}} émis.
return.received.staffIN_APPAvoir émisAvoir {{creditNoteNumber}} ({{creditAmount}}) pour {{storeName}}, retour {{returnNumber}}.
ticket.created.after_salesIN_APPNouvelle réclamation SAV{{ticketNumber}} · {{storeName}} · {{issueLabel}} sur {{orderNumber}}.
ticket.created.resellerIN_APPDemande enregistréeVotre demande {{ticketNumber}} est enregistrée. Nous vous répondons ici.
ticket.resolved.replacementPUSH · IN_APPRéclamation : remplacement{{ticketNumber}} : nous remplaçons vos articles. Commande {{replacementOrderNumber}} créée.
ticket.resolved.returnPUSH · IN_APPRéclamation : retour accepté{{ticketNumber}} : renvoyez les articles concernés. Retour {{returnNumber}} ouvert.
ticket.resolved.credit_notePUSH · IN_APPRéclamation : avoir émis{{ticketNumber}} : avoir {{creditNoteNumber}} de {{creditAmount}} émis à votre nom.
ticket.resolved.repairPUSH · IN_APPRéclamation : réparation{{ticketNumber}} : vos articles sont réparés sous garantie.
ticket.resolvedPUSH · IN_APPDemande résolueVotre demande {{ticketNumber}} est résolue. Dites-nous si tout est en ordre.
ticket.rejectedPUSH · IN_APPRéclamation refusée{{ticketNumber}} est refusée : {{reason}}.

Accounts, finance, stock and promotions

TemplateChannelTitleBody
account.pending_validation.staffIN_APPCompte à valider{{storeName}} ({{communeName}}, {{wilayaName}}), type {{customerTypeLabel}}, attend sa validation.
account.pending_validationIN_APPCompte en cours de validationMerci ! Nous vérifions vos informations. Vous serez prévenu dès la validation.
account.approvedPUSH · IN_APPCompte validéBienvenue chez HOCO ! Votre compte est validé, vous pouvez passer commande.
account.approvedSMS—HOCO : votre compte est validé. Vous pouvez désormais commander dans l'application HOCO.
account.approved.delegatePUSH · IN_APPCompte validéLe compte de {{storeName}} est validé. Accompagnez sa première commande.
account.rejectedPUSH · IN_APPCompte non validéVotre demande n'est pas validée : {{reason}}. Contactez-nous pour la compléter.
account.rejected.delegatePUSH · IN_APPCompte refuséLe compte de {{storeName}} est refusé : {{reason}}.
account.customer_type_changedPUSH · IN_APPVos tarifs ont changéVotre compte est désormais en tarif {{customerTypeLabel}}. Les prix affichés sont mis à jour.
account.suspendedSMS—HOCO : votre compte est suspendu. Pour plus d'informations, appelez le {{supportPhone}}.
account.suspendedIN_APPCompte suspenduVotre compte est suspendu. Contactez le service client au {{supportPhone}}.
account.suspended.delegateIN_APPRevendeur suspendu{{storeName}} est suspendu : ses commandes sont bloquées.
user.invited.smsSMS—HOCO : votre compte délégué est créé. Mot de passe provisoire {{tempPassword}}, valable 72 h, à changer à la 1re connexion.
user.invited.emailEMAILVotre accès au back-office HOCO(body: sign-in link, temporary password valid 72 h, password rules, support contact)
payment.declared.staffIN_APPPaiement à vérifier{{storeName}} déclare {{amount}} par {{methodLabel}} (réf. {{reference}}).
payment.confirmedPUSH · IN_APPPaiement confirméVotre paiement de {{amount}} ({{paymentNumber}}) est confirmé. Merci !
payment.rejectedPUSH · IN_APPPaiement non confirméVotre déclaration de {{amount}} n'est pas confirmée : {{reason}}.
invoice.issuedIN_APPNouvelle factureVotre facture {{invoiceNumber}} de {{totalTtc}} est disponible.
invoice.issuedEMAILVotre 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_APPFacture en retardLa facture {{invoiceNumber}} ({{balance}}) est échue depuis le {{dueDate}}.
stock.lowIN_APPStock bas{{productName}} : {{availableQty}} disponibles (seuil {{threshold}}) au {{warehouseName}}.
stock.low.digestEMAIL{{count}} références sous le seuil d'alerte(body: table of variants, available, threshold, warehouse; staff only)
stock.outIN_APPRupture de stock{{productName}} est en rupture au {{warehouseName}}. {{pendingOrders}} commandes à confirmer le contiennent.
promotion.startedPUSH · IN_APPNouvelle promotion{{promotionLabel}} jusqu'au {{endDate}}. À découvrir dans l'application.
promotion.started.delegateIN_APPPromotion dans votre zone{{promotionLabel}} jusqu'au {{endDate}}. Présentez-la pendant vos visites.
daily_offer.publishedPUSH · IN_APPOffre du jour{{productName}} à {{offerPrice}} au lieu de {{regularPrice}}, aujourd'hui seulement.
daily_offer.published.groupedPUSH{{count}} offres pour vous aujourd'huiDont {{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

TemplateChannelTitleBody
tour.assignedPUSH · IN_APPNouvelle tournée planifiée{{tourNumber}} le {{tourDate}} : {{stopCount}} visites dans {{zoneName}}.
tour.assigned.seriesPUSH · IN_APPTournées planifiées{{count}} tournées du {{fromDate}} au {{toDate}} ajoutées à votre planning.
tour.updatedPUSH · IN_APPTournée modifiéeVotre tournée du {{tourDate}} a changé : {{changeSummary}}. Synchronisez avant de partir.
tour.updated.cancelledPUSH · IN_APPTournée annuléeVotre tournée du {{tourDate}} est annulée : {{reason}}.
tour.started.staffIN_APPTournée démarrée{{delegateName}} a démarré {{tourNumber}} à {{time}} ({{stopCount}} visites).
tour.exceptions.digestIN_APPTournées à vérifierHier : {{notStartedCount}} non démarrées, {{notClosedCount}} non clôturées.
tour.closed.staffIN_APPTournée clôturée{{delegateName}} : {{doneCount}}/{{stopCount}} visites, {{signupCount}} inscriptions, {{orderCount}} commandes.
tour.reopenedPUSH · IN_APPTournée réouverteVotre tournée du {{tourDate}} est réouverte par {{actorName}} : {{reason}}.
visit.not_done.staffIN_APPVisite prioritaire non réalisée{{delegateName}} n'a pas visité {{storeName}} : {{reason}}.
task.due.digestPUSH · IN_APPVos relances du jour{{count}} relances aujourd'hui, dont {{overdueCount}} en retard. Première : {{storeName}}.
task.assignedPUSH · IN_APPNouvelle relance{{assignerName}} vous confie : {{taskTypeLabel}}, {{storeName}}, pour le {{dueDate}}.
invitation.step_completed.account_createdPUSH · IN_APPCompte créé{{storeName}} a créé son compte grâce à votre invitation.
invitation.step_completed.first_orderPUSH · IN_APPPremière commande !{{storeName}} a passé sa première commande ({{totalTtc}}). Bravo !
invitation.step_completed.otherIN_APPInvitation : {{stepLabel}}{{storeName}} : {{stepLabel}}.
ticket.created.staffIN_APPNouvelle demande client{{ticketNumber}} · {{ticketTypeLabel}} · {{storeName}} : {{subject}}
ticket.repliedPUSH · IN_APPRéponse du service clientNouvelle réponse sur votre demande {{ticketNumber}} : {{excerpt}}
ticket.replied.staffIN_APPLe client a répondu{{storeName}} sur {{ticketNumber}} : {{excerpt}}
ticket.status_changed.waiting_resellerPUSH · IN_APPVotre réponse est attendueNous avons besoin de précisions pour traiter {{ticketNumber}}. Répondez dans l'application.
ticket.status_changed.closedIN_APPDemande ferméeVotre demande {{ticketNumber}} est fermée. Ouvrez-en une nouvelle si besoin.
announcement.publishedPUSH · IN_APP{{announcementTitle}} (BO-57 limits titles to 40 characters){{announcementExcerpt}} (first 120 characters)
announcement.publishedSMS—{{smsText}} (the announcement's own SMS text, ≤ 160)
announcement.publishedEMAIL{{announcementTitle}}{{announcementBody}}
otp.code.signupSMS—HOCO : votre code d'inscription est {{code}}. Il expire dans 5 minutes. Ne le partagez avec personne.
otp.code.password_resetSMS—HOCO : code de réinitialisation {{code}}, valable 5 minutes. HOCO ne vous le demandera jamais.
otp.code.phone_changeSMS—HOCO : code {{code}} pour confirmer votre nouveau numéro. Valable 5 minutes.
security.password_changedPUSH · IN_APPMot de passe modifiéVotre mot de passe a été modifié le {{date}} à {{time}}. Pas vous ? Contactez-nous.
security.password_changedSMS—HOCO : le mot de passe de votre compte a été modifié. Si ce n'est pas vous, appelez le {{supportPhone}}.
security.new_devicePUSH · IN_APPNouvelle connexionConnexion depuis {{deviceLabel}} le {{date}} à {{time}}. Pas vous ? Changez votre mot de passe.
export.readyIN_APPExport prêt{{exportLabel}} ({{rowCount}} lignes) est prêt, téléchargeable pendant 7 jours.
export.failedIN_APPExport échoué{{exportLabel}} n'a pas pu être généré. Réessayez ou contactez l'administrateur.
import.finished.dry_runIN_APPSimulation d'import terminée{{importLabel}} : {{rowsOk}} lignes valides, {{rowsError}} à corriger avant l'import.
import.finished.appliedIN_APPImport terminé{{importLabel}} : {{rowsOk}} lignes importées, {{rowsError}} en erreur. Consultez le rapport.
digest.dailyEMAILHOCO — à 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.

CategoryDuring quiet hours
SECURITY (OTP included)Sent immediately, sound on
TRANSACTIONALPush 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
FIELDDeferred to 07:00 unless it concerns today's tour (a stop changed on a tour IN_PROGRESS is sent at once)
PROMOTIONALDeferred to the next window (09:00–20:00); never sent on Friday before 14:00
ANNOUNCEMENTDeferred 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

  1. Exactly once per channel — NotificationDelivery is unique on (outboxEventId, recipientUserId, channel); a replayed outbox row or a retried job never sends twice.
  2. Collapse per entity — pushes carry collapse_key and Android tag = order:{orderId} (or ticket:…, return:…, tour:…): a newer status replaces the older one in the notification tray; the inbox keeps every row.
  3. Merge window — same recipient, same entity, events within 60 seconds → one notification with the latest state (order.modified followed by order.validated becomes « Commande confirmée » with the modification summary in the body).
  4. One assisted-order notification — assisted_order.confirmed and the following order.submitted for the same order are merged for the reseller.
  5. Digests — task.due (one per assignee per morning), stock.low (immediate in-app, email in the 08:00 digest), staff email notifications (one digest.daily), invitation steps reached within 10 minutes (one notification with the furthest step).
  6. Hysteresis — stock.low fires once per variant and warehouse until available stock goes back above the threshold.
  7. Promotional caps — at most one PROMOTIONAL push per reseller per day (setting notifications.promotionalDailyCap, default 1) and three per rolling 7 days (notifications.promotionalWeeklyCap, default 3, D-39); several offers the same day become daily_offer.published.grouped. Over the cap the inbox row is still written; the push slot goes to the highest-priority candidate (anti-fatigue rules).
  8. 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

ChannelRetriesFailure handlingFallback
IN_APPPart of the notification job (BullMQ, 5 attempts, exponential backoff from 30 s)Job moves to the failed set; alert above 20 failures per hourNone needed — the row is the source of truth
PUSHOn FCM 429 and 5xx: 5 attempts (30 s, 2 min, 10 min, 30 min, 2 h) within the TTLUNREGISTERED or invalid token → the Device token is deleted, no retryNone. The inbox row exists; the app refreshes the badge on resume. Push failure never triggers an SMS
SMSTransport errors: 3 attempts within 2 minutes (OTP), 3 within 1 hour (others)Delivery report FAILED or EXPIRED recorded on the deliveryOTP: the user taps « Renvoyer le code » after 60 s; the secondary SMS route takes over if the provider's circuit breaker is open (integrations)
EMAIL5 attempts over 1 hourHard bounce → the address is flagged and skipped until editedNone
SSEClient reconnects with Last-Event-IDServer keeps 5 minutes of events per stream for resumeThe 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).

CategoryResellerDelegateStaff
TRANSACTIONALLocked onLocked onLocked on (inbox); emails come as the daily digest
SECURITYLocked onLocked onLocked on
FIELD—Locked on (Assumption — it carries work instructions)—
PROMOTIONALPush on by default, can be turned offIn-app only—
ANNOUNCEMENTPush on by default, can be turned offPush 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

ChannelPricing modelVolume estimate (generalisation, Assumption)Cost driver to watch
IN_APP, SSEOur infrastructure onlyAll notificationsInbox rows kept 12 months, delivery rows 90 days (retention)
PUSH (FCM)No per-message chargeA few thousand per dayNone in practice; per-project rate quotas are far above HOCO's volume
SMSPer 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 monthSegments (accents outside GSM-7, Arabic), resend abuse (rate limits of AUTH-07)
EMAILPer message or monthly tier of the transactional providerUnder 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 changesTo size in V2Template 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:

  1. data values are strings (FCM requirement). The app routes on route; when the route is unknown to its version, it opens the inbox (RA-26, DA-23).
  2. Opening a notification marks it read (notifications.markRead) with notificationId.
  3. 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 are https://app.hoco.dz + route. Assumption — route names are proposals; the apps' routers own them and the payload change is a template edit.
  4. 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.
  5. Data-only messages (tour.updated on a tour IN_PROGRESS, tour.reopened) carry {"type": "sync", "scope": "tours"} and trigger sync.delta; they always accompany a visible notification because Android throttles silent high-priority messages.
  6. If iOS is decided (D-10), the same message adds apns.headers.apns-collapse-id equal to the Android tag.

Adding a new event

  1. Name it <entity>.<past_participle> in snake case (return.received), unique, never reused; add it to the emitting module in the service catalogue.
  2. Emit it as an OutboxEvent in 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.
  3. 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).
  4. Write the rule in the seed (NotificationRule: channels, category, collapse key, merge window, TTL, quiet-hours policy) and expose it in BO-58.
  5. Write the copy for each channel in fr (and empty ar, en keys that fall back), respecting the copy rules; run the length and GSM-7 check.
  6. Wire the deep link in RA, RW, DA or BO, with the inbox fallback; add the SSE event to events.stream or admin.events.stream if open screens must update live.
  7. Update this page (matrix row and copy row) in the same pull request, and the copy deck of DQA-10.
  8. 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.
  9. Re-estimate costs if SMS, email or WhatsApp volume changes, and tell HOCO.

On this page