Part 3 of 7 · Cart recovery series ~5 min read

How a cart reminder gets timed

Each cart has its own timer. When a cart is left behind, the intake schedules a wake-up for when the first reminder might be due. At that moment an EventBridge Scheduler one-off fires the waiter Lambda for just that cart. The Lambda reads the row, works out how long it’s been since the cart was abandoned, and decides whether to do nothing or to send — and if so, which reminder. The whole decision is plain Python. No model. Every wait lives in the rules doc, where the owner can edit it without a deploy.

Key takeaways

  • One EventBridge Scheduler wake-up per cart, set to when the next reminder might be due.
  • Per-size waits live in the rules doc — a small cart waits 4h then 24h, a big cart waits 1h then 20h.
  • Four moves per cart, every wake-up: still shopping, first reminder, second reminder, give up.
  • DynamoDB tracks last-sent and bought status so reminders aren’t duplicate spam.
  • The waiter itself never calls a model. The decision is entirely deterministic.

The decision flow, per cart

Decision flow per cart on every wake-up A vertical decision flow diagram. At the top, an input box "Cart from the list" with the cart's email, items, total, abandoned-at time, and DynamoDB state. Below that, a step "Compute time_since_abandon" — now in the configured timezone minus the abandoned-at time. Below that, a check "Already bought or unsubscribed?" — if yes, route to "Still shopping" (do nothing this wake-up). If no, continue. The next step "Look up wait for cart size" — pulls the wait from the rules doc; for example a big cart returns waits of 1 hour then 20 hours. The next step "Which wait crossed?" — compares time_since_abandon to the first and second wait. If neither has been reached, route to "Still shopping" and reschedule. If one has, look at the DynamoDB state to see if a reminder already went out for it. If no reminder yet, route to "First reminder" (or "Second reminder" if the first already went out). If both reminders have already gone out, route to "Give up" — stop sending and leave the cart alone. Each terminal box — Still shopping, First reminder, Second reminder, Give up — emits an event to EventBridge with the move and the cart context. A note at the bottom: the rules doc holds every wait; the waiter's code only enforces them — change a wait in the doc and the next wake-up uses the new value. Cart from the list email · items · total · abandoned Step 1 Compute time_since_abandon now (TZ) − abandoned_at Step 2 Bought or unsubscribed? read DDB cr-state table Step 3 Look up wait for size e.g. big → [1h, 20h] Step 4 Which wait crossed? none → still shopping both done → give up Step 5 Already sent this one? read DDB cr-sends table Still shopping do nothing First reminder first wait crossed Second reminder second wait crossed Give up both sent, no buy if yes none both first second The rules doc holds every wait — change one and the next wake-up uses the new value.
Fig 3. The waiter’s decision tree, per cart, per wake-up. Five steps decide which of four moves applies. The rules doc holds every wait; the waiter only enforces them.

Waits: 4h/24h isn’t magic, it’s in the doc

The rules doc has one short line per cart size. Each names the two waits in plain prose: “Small carts (under $40): remind after 4 hours, then after 24. Medium carts: 2 hours, then 22. Big carts (over $150): 1 hour, then 20.” The numbers are how long to wait after the cart was abandoned before each reminder fires. The first number is the first reminder. The second number is the last reminder — after it, the system gives up. There is no third.

The sizes exist for a reason. A small cart can wait — the shopper is in no hurry and a fast email feels pushy for a $20 basket. A big cart is worth catching while the intent is still warm, so the first nudge comes sooner. None of the waits are aggressive; even the fastest gives the shopper a full hour to come back on their own first. The point is a gentle reminder, not a pounce.

Per-cart overrides exist too. The cart row has an optional wait_override field. Set two values there and the waiter uses your numbers instead of the size default for that one cart. This is the right escape hatch for a launch day or a flash sale where the rhythm is different.

Four moves, always

Every cart, every wake-up, lands in exactly one of four buckets. The names are simple on purpose.

  • Still shopping. Not enough time has passed yet, the cart changed again, or the cart was already bought or unsubscribed. Do nothing, and reschedule the wake-up for the next wait. Most carts that come back do so here, on their own.
  • First reminder. The first wait passed with no checkout and no reminder yet. Send a friendly nudge with the items and a return link. Write a row to the cr-sends DynamoDB table marking that the first reminder fired, and schedule the wake-up for the second wait.
  • Second reminder. The second wait passed with no checkout and the first reminder already went out. Send one slightly warmer follow-up — the last one. Write the send to cr-sends. No further wake-up is scheduled.
  • Give up. Both reminders went out and the cart still wasn’t bought. Stop. Mark the cart closed in DynamoDB and leave the shopper alone. Two emails is the whole budget; a third would cross from helpful into nagging, so the system never sends it.

State that makes the decision deterministic

The waiter reads two DynamoDB tables every wake-up. cr-sends records every reminder that’s gone out: (cart_id, step, sent_at, channel). cr-state records the cart’s status: (cart_id, status, bought_at, unsubscribed). With those two tables, the move-decision logic is a few dozen lines of Python and zero magic. A given cart with a given abandoned-at time, a given size, and a given send history always produces the same move. If a wake-up fires twice by accident, the state in cr-sends shows the reminder already went out, so no duplicate is sent.

A cart that changes resets its abandoned-at time and reschedules — the shopper is active again, so the clock restarts. A checkout flips cr-state to bought and the next wake-up short-circuits to still shopping. Part 5 covers the stop-on-checkout flow in detail.

Why the timing decision uses no model

The waiter could call a model to guess the perfect moment to send, or to decide whether a given shopper is “worth” a reminder. It doesn’t. Two reasons. First, the timing should be the one part of the system that is utterly predictable — if the doc says remind after 4 hours and there’s no checkout, the reminder fires. A model in that loop introduces variance the owner can’t reason about, and timing bugs in email are the kind that get you marked as spam. Second, model calls cost money, and most carts come back on their own, so the call would be wasted most of the time.

Bedrock fires elsewhere — to polish one line of the reminder copy in Part 4, and on the monthly summary in Part 6. Not on the timing decision. The waiter itself is plain Python that reads a clock and writes events.

Next post: how a reminder finds the right shopper, how quiet hours and the do-not-disturb list are honored, and what the email actually carries.

All posts