Guides/The Complete Guide to Logistics Notifications/How to Build Logistics Notifications

Chapter 4

How to Build Logistics Notifications

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.

Minimal editorial poster: an aerial view of a single semi truck on an open highway, on a cream background. Cover for the logistics notifications guide.

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.

4.1 Normalizing events from your TMS, ELD, WMS, and EDI feeds

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 type
const 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.

4.2 The routing table: mapping event plus role to channel

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 recipient
async 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 + prefs
template: `${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.

4.3 Applying preferences and driving state

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:

  • Preferences. Each recipient can subscribe or unsubscribe per notification type and per channel, and set thresholds. Courier applies subscribed preferences automatically at send time, so a dispatcher who muted "arrived" notifications doesn't get them, without you branching on it in code.
  • Driving state and quiet hours. For drivers, gate non-urgent sends on ELD duty status. The cleanest way to model this is to treat "driving" as a quiet period and hold the message, releasing it when the driver goes on-duty-not-driving.
// Gate a non-urgent driver notification on live duty status
async 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.

4.4 Severity tiers and digests

The severity from the routing table drives delivery behavior:

  • Critical: send immediately, on the most reliable channel for that recipient, and consider escalation (4.5).
  • Warning: send promptly, but respect the recipient's channel preferences and any gating like driver duty status.
  • Info: don't interrupt anyone. Roll it into a digest.

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 schedule
await 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.

4.5 Escalation with acknowledgment

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:

  1. Notify the driver. Start a timer.
  2. If no acknowledgment in 5 minutes, notify the dispatcher.
  3. If still no acknowledgment in 10 minutes, notify the ops manager and the shipper's quality team.
  4. The moment anyone acknowledges, cancel the rest of the ladder so you don't wake up three more people.

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 fire
await 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.

4.6 Multi-tenant branding for logistics platforms

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 config
await 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 subsidiaries
default_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.

4.7 Retries, fallback, and delivery guarantees

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:

  • Retries. A transient provider failure should trigger another attempt, not a silent drop. Courier retries failed sends against the provider automatically.
  • Channel fallback. If a notification can't land on the primary channel, fall back to another. Define the order per severity: for a critical driver alert, that might be push, then SMS, then leave it in the inbox for reconnect.
  • Observability. You need to know what was sent, what landed, and what didn't, both to debug and to prove delivery when a customer disputes it. Courier logs every send and its status, so a missed alert is visible rather than mysterious.
// Fallback order expressed as routing; each channel is tried in turn
routing: { 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.

Frequently asked questions

How do you build a shipment notification system?

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.

What is EDI 214 and do you still need it?

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.

How do you send notifications on behalf of your customers?

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.

How do you escalate an unacknowledged alert?

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.