Chapter 4
How to build the system: normalize events from your TMS, ELD, WMS, and EDI feeds, route by role, apply preferences and driving state, tier by severity, escalate with acknowledgment, and brand per tenant.

Last updated: July 2026
This chapter has the code. If you're a product or operations reader, skim it and pick back up at chapter 5. The examples use Courier, but the patterns apply whatever you build on.
The unglamorous foundation. Your events come from everywhere: telematics and ELD hardware, your TMS, warehouse systems, carrier APIs, and EDI feeds. Each describes the same real-world moment in a different shape, at a different cadence, with different reliability.
EDI is the classic example, because freight still runs on it. A load's life shows up as a sequence of transactions: a 204 is the load tender, a 990 is the carrier's accept-or-decline response, and a series of 214 shipment status messages report milestones as the load moves, with status codes like X3 for dispatched, AF for departed the pickup, X1 for arrived at delivery, and D1 for delivered. Meanwhile your telematics provider is reporting a geofence "arrival" for the same stop, and a dispatcher might set a status manually in the TMS. Three sources, one real event, three shapes.
If you notify off each source directly, you'll send duplicate and contradictory alerts. So the first thing you build isn't a notification, it's a normalization layer: a single canonical internal event that everything upstream maps into.
{"event": "shipment.arrived","shipment_id": "SHP-40128","load_id": "LD-88213","occurred_at": "2026-07-24T14:03:00Z","source": "telematics.geofence","confidence": "high","location": { "stop_id": "STOP-2", "type": "consignee", "appointment": "2026-07-24T14:00:00Z" },"actors": {"driver_id": "DRV-771","carrier_id": "CAR-19","shipper_id": "SHP-CUST-4","tenant_id": "TENANT-wine-co"}}
A mapping table turns raw sources into that shape:
// Raw source signals collapse into one canonical event typeconst EVENT_MAP = {"edi214.X1": "shipment.arrived","edi214.AF": "shipment.picked_up","edi214.D1": "shipment.delivered","telematics.geofence.enter.consignee": "shipment.arrived","tms.status.arrived": "shipment.arrived",};function normalize(raw) {const event = EVENT_MAP[raw.signal];if (!event) return null;return { event, ...toCanonical(raw) };}
Everything downstream (routing, severity, digests) keys off this canonical shape, not off the raw feed. Deduplicate here too: if three sources report the same arrival within a window, collapse them into one event, preferring the highest-confidence source, before anyone gets notified.
Once you have clean events, fan-out becomes configuration. The "who needs to know" from chapter 1 is a table, keyed on event type and role, that resolves to a channel and a severity:
{"shipment.arrived": {"driver": { "channel": "inbox", "severity": "info", "gated_on": "duty_status" },"dispatcher": { "channel": "inbox", "severity": "info" },"shipper": { "channel": "digest", "severity": "info" },"warehouse": { "channel": "email", "severity": "info" }},"shipment.dwell_exceeded": {"dispatcher": { "channel": "push", "severity": "warning" },"ops_lead": { "channel": "push", "severity": "warning", "escalate_after": "10m" }},"reefer.excursion": {"driver": { "channel": "voice", "severity": "critical" },"dispatcher": { "channel": "push", "severity": "critical", "escalate_after": "5m" }}}
The send itself is then one call per mapped recipient. With Courier, you send against a template and let routing decide the channel, so adding an audience to an event or changing a channel is a data change, not a redeploy:
// One canonical event fans out to every mapped recipientasync function fanOut(event, recipients) {for (const [role, rule] of Object.entries(routingTable[event.event] || {})) {const recipient = recipients[role];if (!recipient) continue;await courier.send.message({message: {to: { user_id: recipient.user_id },context: { tenant_id: event.actors.tenant_id }, // loads that tenant's brand + prefstemplate: `${event.event}.${role}`,data: event,routing: { method: "single", channels: [rule.channel] },metadata: { tags: [event.event, role, rule.severity] },},});}}
Passing context.tenant_id on the send is what loads that tenant's brand and default preferences, so the next steps (branding, preferences, scoping) work without extra plumbing.
The routing table says where a notification should go by default. Preferences and constraints decide whether it actually does. This is the layer that turns a generic system into one that respects a driver's clock and a dispatcher's tolerance.
Two things happen here:
// Gate a non-urgent driver notification on live duty statusasync function sendToDriver(event, driver, rule) {const critical = rule.severity === "critical";if (!critical && driver.duty_status === "driving") {return queueForNextStop(event, driver); // held, released at the next break}return courier.send.message({message: {to: { user_id: driver.user_id },context: { tenant_id: event.actors.tenant_id },template: `${event.event}.driver`,data: event,},});}
Modeling duty status as a preference-style gate rather than hard-coding it per message means the rule lives in one place and every driver notification inherits it.
The severity from the routing table drives delivery behavior:
Digests are where much of the fan-out noise goes to be useful instead of annoying. Different audiences want different cadences: a shipper wants a daily or shift roll-up of their shipments, a driver wants a next-stop summary of what accumulated while they were driving, and an ops lead wants immediate delivery for critical only and a digest for everything else.
Rather than build the batching, scheduling, and per-user state yourself, route routine events into a Journey with an add-to-digest node. The node accumulates events keyed by a subscription topic (say, one topic per shipper) and releases a single rolled-up notification on that topic's schedule.
// Routine, info-tier events enter a digest Journey; its add-to-digest node// batches them by topic and releases on scheduleawait courier.journeys.invoke("shipment-updates-digest", {user_id: shipper.user_id,data: event,});
The shipper gets one clean summary on their cadence instead of 200 pings, and the true exceptions still cut through immediately because they're sent directly, outside the digest.
Some notifications can't just be sent, they have to be acknowledged, and if they're not, they have to climb the chain. Cold chain is the textbook case, and it's not hypothetical: ATRI's 2024 research found refrigerated drivers were detained on 56.2% of their stops, the highest rate of any segment, so temperature-sensitive loads sit exposed more than any other. When a reefer throws a temperature excursion, minutes matter.
The pattern is send, wait, check for acknowledgment, then either stop or escalate:
This is a job for a Journey, Courier's workflow builder, not a pile of cron jobs. You build the escalation once as a Journey: a send node to the driver, a delay, a send to the dispatcher, another delay, a send to the ops manager and the shipper's quality team, with each send node addressing its recipient from the run's data. Then you start a run per excursion and cancel it the moment someone acknowledges.
// The ladder (send -> delay -> send -> delay -> send) is defined once as a Journey.// Start a run per excursion; invoke returns a runId.const { runId } = await courier.journeys.invoke("reefer-excursion-escalation", {user_id: driverId,data: {shipment_id: shipmentId,dispatcher_id: dispatcherId,ops_lead_id: opsLeadId,},});await saveRun(shipmentId, runId); // persist so an ack can cancel this run// When someone acknowledges (taps the alert, replies, resolves in the TMS),// cancel the run so the remaining steps never fireawait courier.journeys.cancel({ run_id: await getRun(shipmentId) });
If you set a cancelation token on the Journey's trigger (for example, derived from shipment_id), you can skip storing the run id and cancel every run for that shipment in one call: courier.journeys.cancel({ cancelation_token: shipmentId }). See the Journeys docs for building the send, delay, and condition nodes.
The same pattern covers any must-acknowledge event: an unassigned load aging toward a missed pickup, a dwell alert climbing toward detention, an accident. The value is that it both guarantees the alert doesn't die in someone's silenced phone and stops over-notifying the instant it's handled.
If you're a platform, you send on behalf of many customers, each of whom wants their own branding and sender identity, their own thresholds and quiet hours, and absolute certainty that they can't see anyone else's data. Building that isolation by hand, per customer, is a quarter you'll never get back.
This is what tenants are for. Each shipper, broker, or brand is a tenant with its own brand (logo, colors, sender identity) and its own settings, and notifications automatically render and route per tenant. Because each send in 4.2 already carries context.tenant_id, the branding and scoping come along for free.
// A tenant carries its own brand and settings; sends reference it, not hard-coded configawait courier.tenants.update("TENANT-wine-co", {name: "Wine Co Logistics",brand_id: "BRAND-wine-co",parent_tenant_id: "TENANT-wine-parent", // corporate parent sees across subsidiariesdefault_preferences: { /* per-notification-type defaults for this customer */ },});
The international logistics provider from chapter 1 is the clean example: brand and role scoping so each client sees only their own shipments, parent-account visibility so a corporate parent can see across its subsidiaries without those subsidiaries seeing each other, and internal routing to account managers that never exposes customer data. Their takeaway on moving off the hand-rolled system: "What used to take four weeks now takes a day." That gap, weeks to a day, is mostly the difference between changing notification content and structure as configuration versus as code.
In logistics, a dropped notification means a truck sits, a dock stays empty, or a load spoils. So delivery reliability isn't a nice-to-have.
Three things to get right:
// Fallback order expressed as routing; each channel is tried in turnrouting: { method: "single", channels: ["push", "sms", "inbox"] }
The international logistics provider called out missed critical alerts as a top reason for replacing their legacy system, and delivery visibility as a reason for choosing the new one. In this domain, "we think it sent" is not good enough, because the failure mode is a physical asset stuck in the real world.
Start with a normalization layer that maps every event source (TMS, ELD, WMS, carrier APIs, EDI) into a single canonical event. Then build a routing table that maps each event and recipient role to a channel and severity, apply preferences and driving-state gating, add digests for non-urgent traffic, escalation for critical alerts, and retries with channel fallback for reliability.
EDI 214 is the transportation carrier shipment status message, a standard format carriers use to report milestones (with codes like X1 for arrived and D1 for delivered). Many partners still require it, so you'll likely ingest 214s as one event source, normalize them alongside telematics and API events, and notify off the normalized event rather than the raw 214.
Use multi-tenancy: model each customer as a tenant with its own branding, sender identity, and settings, and scope data and visibility per tenant so customers never see each other's information. Notifications then render and route per tenant automatically.
Build it as a Journey that sends, delays, and sends to the next person if no acknowledgment arrives within the window, then cancel the run the moment someone acknowledges. Canceling by run id stops that run; canceling by a token stops every run for the same incident.
Previous chapter
The Constraints That Change the Design
The constraints that make logistics notifications different: the federal texting rule, the hours-of-service clock, A2P 10DLC consent, alert fatigue, driver language, and connectivity dead zones.
Next chapter
How to Operate and Measure Logistics Notifications
How to operate and measure logistics notifications: the metrics that matter, what detention and missed alerts cost, a maturity model, and how to decide whether to build or buy.
© 2026 Courier. All rights reserved.