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
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-sendsDynamoDB 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
closedin 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