An upsell recommender on AWS for a few dollars a month
An add-on sale is the quietest kind a shop loses: the customer buys the bike, the checkout thanks them, and nobody ever mentions the lights and the lock that go with it. This post walks through the design of a small serverless system that turns a placed order into one well-judged nudge — the single add-on that actually complements what was bought — with a one-tap link to add it, and nothing at all when nothing fits.
Key takeaways
- A placed order fires a webhook from your store; a short while later the customer gets one nudge for the add-on that actually goes with it.
- A model proposes a shortlist of add-ons; deterministic catalogue rules dispose of it — in stock, complementary, sensibly priced, not already owned.
- The offer is one tap to accept: a signed add link that drops the item straight into the store’s basket.
- It suggests exactly one add-on per order, caps how often anyone is nudged, honours opt-out, and sends nothing when nothing fits.
- Designed on AWS for about $2.60/month at roughly 150 orders a month. It only ever suggests — a human never has to.
The whole system on one page
Before any code, here’s the shape of what we’re designing. Every shop that sells more than one thing has natural pairings — a bike and its lights, a bag of beans and a grinder, a dog bed and the blanket that matches it — and almost none of them get mentioned at the moment they’d land. The checkout thanks the customer and goes quiet, and the add-on sale that was one sentence away is simply never made. The system below catches that moment: an order comes in, and a single, well-judged nudge goes back out a short while later, with a one-tap link to add the thing that goes with it.
What you set up once (the outside)
- Store and add-to-cart. Whatever platform already takes your orders — a hosted shop or e-commerce backend that can do two things: fire a webhook the moment an order is placed, and accept a link that adds a known product to a basket or starts a pre-filled checkout. The order webhook is the trigger for everything; the add-to-cart URL is where the one-tap link lands. You point the store’s webhook at one AWS URL and store its keys in Secrets Manager. This is the only moving part you don’t own, and it’s covered in Part 2.
- Catalogue and customer records. A product list — one row per item with its price, current stock, category, and a tag or two for what it complements — alongside each customer’s order history. You already keep both; this just mirrors them where the system can read them, so a pick can know that the track pump is in stock, costs a sensible fraction of the bike, and isn’t already in this customer’s past orders. A small settings doc holds the voice, the send delay, the frequency cap, and the attribution window.
- The customer. The person who placed the order. They receive, at most, one nudge for one add-on — and only if a good one exists — with a single tap to accept it. If they’ve opted out, or been nudged too recently, or nothing genuinely fits, they hear nothing at all. The system never chases, never sends a second offer for the same order, and never fills a quiet moment with a weak suggestion.
What runs on every order (the inside)
- Catch and gate. The store posts an order event to one Lambda Function URL. The function verifies the signature, dedups so one order can only ever make one offer, checks the customer against the opt-out list and the per-customer frequency cap, and gathers the basket and history. Only then does it decide a suggestion is worth attempting. This is Part 2.
- Pick add-on. One Bedrock Haiku 4.5 call proposes a ranked shortlist of add-ons with a short line for each. Then deterministic catalogue rules filter that shortlist — in stock, complementary, sensibly priced, not already owned — and take the top survivor. If the shortlist empties, no offer is made. The model proposes; Python disposes. This is Part 3.
- Send nudge. After a short, deliberate delay, one message goes out with a one-tap add link — a signed token on a second Function URL that records the tap and redirects straight into the store’s add-to-cart. One nudge, one add-on, one tap to say yes. This is Part 4.
- Track take-up. The tap is recorded inside an attribution window and tied back to its offer, the frequency cap is updated so the next nudge stays honest, and a scheduled sweep closes the window so every offer ends up counted as taken, ignored, or never sent. This is Part 5.
In plain words
It’s Tuesday evening and Tom buys a commuter bike from Ridgeback Cycles for £520. The store fires an order webhook. The system records the order, sees Tom has no lights or lock in his history, and asks the model for add-ons that go with a commuter bike; it comes back with a shortlist — a lights-and-lock bundle, a set of mudguards, a track pump. The catalogue rules take over: the mudguards for his frame are out of stock, so they’re dropped; the track pump he already bought last year, so it’s dropped; the lights-and-lock bundle is in stock, complements the bike, and at £45 is a sensible fraction of the order. It wins. An hour later Tom gets one message: “Your new bike will want lights and a lock — here’s our commuter bundle at £45, one tap to add: ridgeback.uk/a/…”. He taps, it lands in his basket, and he checks out. Nobody at the shop lifted a finger.
The next morning a different order comes in from a customer who bought the same bundle a fortnight ago and has already had a nudge this month. The model would happily propose something — a helmet, perhaps — but it never gets asked: the frequency cap gate stops the pick before the model runs, because a second nudge inside the window would read as pestering. And a third order, for a gift card, produces an empty shortlist — nothing in the catalogue genuinely complements a gift card — so the system does the most disciplined thing it can and sends nothing. One good offer when there is one; silence when there isn’t.
Design rules that shaped every decision
- One order, one offer. Each order can produce at most a single suggestion — repeats and retries collapse to one, and it never sends twice.
- The model proposes, Python disposes. Bedrock suggests a shortlist; deterministic catalogue rules make the actual choice.
- No offer beats a weak offer. If nothing is in stock, complementary, priced right and not already owned, the system stays silent.
- Never nag. A per-customer frequency cap limits how often anyone is nudged, and an opt-out is permanent.
- Saying yes is one tap. The offer carries a one-tap add link straight into the basket; there is nothing to type.
- Every offer is counted. Taken, ignored, or never sent — each one is measured, so the shop can tell what actually works.
Why this shape
Most shops handle add-ons one of three ways: they don’t suggest them at all, they bolt a “you might also like” strip onto the product page and hope, or they blast every customer a generic promo a few days later. The first leaves obvious sales on the table — the person buying a bike is never more receptive to lights than in the minute they bought it. The second is untargeted and easy to ignore. And the third trains customers to filter the shop into spam, because it fires whether or not there’s anything worth saying. The gap is a single, well-judged, well-timed suggestion — and nothing off-the-shelf sends one and only one.
The shape above fills exactly that gap and nothing more. It leans on the store you already run as the trigger, keeps the catalogue and order history you already maintain as the source of truth, and adds a small system that makes one good suggestion per order — when a good one exists. The common case — “oh, I do need lights” — becomes a single tap. The cases where nothing fits, or the customer has heard enough, resolve to silence rather than a weak offer, which is the thing that keeps the whole channel worth trusting.
The next four posts walk through each piece in turn: how an order triggers a suggestion, how the add-on gets picked, how the offer gets sent, and how a take-up gets tracked. One diagram per post. A cost breakdown and a final engineering reference at the end.
All posts