Part 3 of 7 · Shipping notifier series ~5 min read

How a shipping update gets sent

Every 30 minutes during the day, an EventBridge Scheduler rule fires the notifier Lambda. The Lambda reads the order list, looks at one row at a time, compares the order’s current status to the last status it told the customer about, and decides whether to do nothing or to send an update — and if so, which one. The whole decision is plain Python. No model. No guessing. Every setting lives in the rules doc, where the owner can edit it without a deploy.

Key takeaways

  • The notifier runs on a schedule via EventBridge Scheduler, every 30 minutes during the day.
  • It compares each order’s current status to the last status it actually told the customer about.
  • Five moves per order, every check: nothing, shipped, out for delivery, delivered, delayed.
  • DynamoDB tracks the last status sent per order so the same update never goes out twice.
  • The notifier itself never calls a model. The decision is entirely deterministic.

The decision flow, per order

Decision flow per order on every scheduled check A vertical decision flow diagram. At the top, an input box "Order from list" with the row's number, customer, current status, expected date, and DynamoDB state. Below that, a step "Read current vs last-sent" — the order's current status compared against the last status the customer was told about. Below that, a check "Unsubscribed or paused?" — if yes, route to "Nothing" (do nothing this check). If no, continue. The next step "Map status to the right update" — looks up which template matches the current status; placed maps to nothing, shipped, out for delivery, and delivered each map to their own update. The next step "Already sent this status?" — checks the DynamoDB state to see whether an update for this exact status already went out. If yes, route to "Nothing." If no, decide which update: a forward step (shipped or out for delivery) routes to "Step update", a delivered status routes to "Delivered", and an order past its expected date with no delivery routes to "Delayed". Each terminal box — Nothing, Step update, Delivered, Delayed — emits an event to EventBridge with the move and the order context. A note at the bottom: the rules doc holds every setting; the notifier's code only enforces them — change a quiet-hours window or an expected-delivery window in the doc and the next check uses the new value. Order from list number · status · date · customer Step 1 Read current vs last-sent current status − last sent Step 2 Unsubscribed or paused? read DDB sn-prefs table Step 3 Map status to update e.g. shipped → "on its way" Step 4 Status moved forward? no change → nothing past date → delayed Step 5 Already sent this status? read DDB sn-sends table Nothing do nothing Step update shipped or out Delivered arrived, all done Delayed past date, no arrival if yes none late step delivered The rules doc holds every setting — change a window and the next check uses the new value.
Fig 3. The notifier’s decision tree, per order, per scheduled check. Five steps decide which of five moves applies. The rules doc holds every setting; the notifier only enforces them.

Status stages: placed, shipped, out for delivery, delivered, delayed

Every order moves through a small, fixed set of stages. Placed is the start — the customer has ordered but nothing has shipped, so there’s nothing new to say. Shipped means the parcel is with the carrier and on its way. Out for delivery means it’s on a vehicle and arriving today. Delivered means it’s at the door. Delayed is the off-path stage: the order passed its expected delivery date without arriving. Each of these (except placed) has its own friendly update in the voice doc.

The stages exist for a reason. A “shipped” note sets the customer’s expectation and gives them a tracking link. An “out for delivery” note tells them to keep an eye out today, which cuts the “is it coming?” question. A “delivered” note closes the loop and is a nice moment to say thanks. Different stages do different jobs; the updates reflect that.

Per-order overrides exist too. The order list has an optional column called mute_until. Put a date there and the notifier holds all updates for that one order until the date passes — useful for a pre-order that shipped early, or an order you’re handling by hand.

Five moves, always

Every order, every check, lands in exactly one of five moves. The names are simple on purpose.

  • Nothing. The status hasn’t changed since the last update, or the customer has unsubscribed, or the order is muted. Do nothing. Most orders, most checks, are nothing.
  • Shipped. The status just became shipped and no “on its way” email has gone out. Send the shipped update with the tracking link. Write a row to the sn-sends DynamoDB table marking that the shipped update fired.
  • Out for delivery. The status became out for delivery and no “arriving today” email has gone out. Send that update. Write the send to sn-sends.
  • Delivered. The status became delivered and no “it arrived” email has gone out. Send the delivered update with a friendly thank-you. Write the send to sn-sends.
  • Delayed. The order passed its expected delivery date without a delivered status. Warn the customer and alert the owner. Mark the order as delayed in DynamoDB. Part 5 covers this move in full.

State that makes the decision deterministic

The notifier reads two DynamoDB tables every check. sn-sends records every update that’s gone out: (order_id, status, sent_date, sent_via). sn-prefs records each customer’s preference: (order_id, unsubscribed, mute_until). With those two tables, the move-decision logic is a few dozen lines of Python and zero guesswork. A given order with a given status and a given send history always produces the same move. Re-running the check produces no extra emails (because the state in DynamoDB shows what already fired).

This is why the same order never gets two “shipped” emails. Carriers sometimes report the same status twice, or the webhook fires a retry. The notifier doesn’t care: it checks sn-sends first, sees the shipped update already went out, and lands at nothing.

Why the scheduled check uses no model

The notifier could call a model on the check to write a smarter update, or to decide whether to send at all. It doesn’t. Two reasons. First, the check should be the one part of the system that is utterly predictable — if the status moved to shipped and no shipped email went out, the email fires. A model in that loop introduces variance the team can’t reason about. Second, model calls cost money, and most orders on most checks have nothing new, so the call would be wasted.

Bedrock fires elsewhere — on the inbox forwarding lane in Part 2, and on the monthly summary mentioned in Part 6. Not on the scheduled check. The notifier itself is plain Python that reads a list and writes events.

Next post: how a message reaches the customer, how quiet hours and unsubscribes are honored, and what context every update carries.

All posts