Part 7 of 7 · Upsell recommender series ~10 min read

Engineering reference: the upsell recommender architecture

This is the upsell recommender with the friendly labels removed: the real resource names, the runtime, the table key schemas, the two public Function URLs, the scheduled send and sweep, and the IAM scope that keeps the one model call in its box. If you want to build it rather than understand it, start here.

Key takeaways

  • Six Lambda functions, all Python 3.14 on arm64, wired through one SQS queue with a dead-letter queue.
  • Two public surfaces: a Function URL on upsr-order for the order webhook, and one on upsr-addlink for the one-tap redirect — no API Gateway.
  • Five DynamoDB tables, all on-demand: orders, offers, a catalogue mirror, customers, and an opt-out list.
  • One EventBridge Scheduler with three rules: the delayed send, the take-up sweep, and the catalogue sync.
  • One Bedrock model, Claude Haiku 4.5 via Global cross-Region inference, called only by upsr-picker. Single region, eu-west-2.

The architecture, for engineers

This is the same system as Part 1 with the friendly labels removed and the real resources named. Everything is in one region, eu-west-2 (London), in one account. There is no API Gateway, no NAT Gateway, and nothing always-on; the only inbound surfaces are two Lambda Function URLs, the outbound nudge goes through SNS, and work is buffered on a single SQS queue.

Engineering architecture: the order webhook and add-link Function URLs, SQS, six Lambdas, five DynamoDB tables, Bedrock, SNS and SES, and the scheduled jobs An external box at the top, the store and e-commerce provider, posts order events to a Lambda Function URL, upsr-order, inside a dotted AWS account container labelled eu-west-2, and hosts the add-to-cart the one-tap link redirects into. upsr-order verifies the signature with a secret from Secrets Manager, de-duplicates and records the order in the DynamoDB orders table upsr-orders with a conditional write on the order id, checks the customers table upsr-customers for opt-out and the frequency cap, gathers the basket, and enqueues a suggest job on the SQS queue upsr-jobs with dead-letter queue upsr-jobs-dlq. From the queue, upsr-picker runs, all functions Python 3.14 arm64: it makes the single Bedrock Claude Haiku 4.5 call to propose a ranked shortlist, filters that shortlist against the DynamoDB catalogue table upsr-catalogue for stock, complementary tags, price and ownership, and writes the chosen add-on with a send-at time to the DynamoDB offers table upsr-offers, or records no fit. On the left, EventBridge Scheduler drives three scheduled Lambdas: upsr-catalog-sync, which pulls the product list into upsr-catalogue; upsr-sender, which queries upsr-offers for due, unsent offers, re-checks opt-out and the cap, builds a signed one-tap link, sends one nudge via SNS to the customer, stamps the frequency cap on upsr-customers, and marks the offer sent; and upsr-sweep, which closes untapped offers past their attribution window and emails a take-up report via SES. The second Function URL, upsr-addlink, receives the customer’s tap, records it against the offer in upsr-offers, and issues an HTTP 302 into the store’s add-to-cart. The five DynamoDB tables sit in a column on the right: upsr-orders keyed by order id, upsr-offers keyed by offer id with a state-and-send-at index and a window TTL, upsr-catalogue keyed by SKU, upsr-customers keyed by customer id, and upsr-optout keyed by customer id. Secrets Manager holds two secrets. Bedrock is called only by upsr-picker. Store / e-commerce provider order webhook; add-to-cart target AWS account · eu-west-2 upsr-order Function URL · verify, dedup, gate order in upsr-jobs SQS + upsr-jobs-dlq EventBridge Scheduler 3 rules upsr-catalog-sync product list → upsr-catalogue upsr-sender release due offers, SNS send upsr-sweep close windows, take-up report upsr-picker Bedrock propose + rules dispose upsr-addlink Function URL · record tap, 302 tap in Secrets Manager 2 secrets Bedrock Haiku 4.5, Global SNS (SMS) nudge out nudge SES take-up report Add-to-cart 302 target 302 upsr-orders PK order_id upsr-offers PK offer_id · GSI state,send_at upsr-catalogue PK sku upsr-customers PK customer_id (cap) upsr-optout PK customer_id All Lambdas: Python 3.14, arm64 · CloudWatch Logs 7-day retention · AWS Budgets cost alarm · two Function URLs, no API Gateway, no NAT
Fig 7. The upsell recommender drawn for engineers: two Function URLs (upsr-order and upsr-addlink), an SQS-buffered set of six Lambdas, five DynamoDB tables, Bedrock called only by the picker, SNS for the nudge and SES for the take-up report, and three scheduled jobs. One region, one account, no API Gateway.

Lambda functions

Six functions, all Python 3.14 on arm64, all with CloudWatch Logs at 7-day retention. Each does one job and hands off; the SQS queue (upsr-jobs, with upsr-jobs-dlq as its dead-letter queue after five attempts) decouples the public webhook from the slower model call.

  • upsr-order — a public surface. Backs the order Function URL: verifies the store’s signature, de-duplicates against upsr-orders with a conditional write on the order id, checks upsr-optout and the frequency cap on upsr-customers, gathers the basket and history, and enqueues a suggest job. Nothing slow happens here — no catalogue join, no model call.
  • upsr-picker — SQS-triggered on suggest jobs. Makes the single Bedrock call to propose a ranked shortlist, applies the deterministic catalogue filters (in stock, complementary, price-appropriate, not already owned) against upsr-catalogue, and writes the top survivor with a send_at to upsr-offers — or writes a no-fit record and stops. The only function with bedrock:InvokeModel.
  • upsr-sender — scheduled. Queries upsr-offers by the state-and-send-at index for offers whose delay has elapsed and are still chosen, re-checks opt-out and the cap, builds the signed one-tap link, sends one nudge via SNS, stamps the frequency cap on upsr-customers, and flips the offer to sent with a conditional update so it is never sent twice.
  • upsr-addlink — a public surface. Backs the add-link Function URL: validates the signed token, records the tap against the offer in upsr-offers (marking it taken if inside the attribution window, or out-of-window if not), and issues an HTTP 302 to the store’s add-to-cart URL for the chosen SKU.
  • upsr-sweep — scheduled. Queries upsr-offers for sent offers past their attribution window with no tap, marks each ignored, and emails the shop owner a rolled-up take-up report via SES.
  • upsr-catalog-sync — scheduled. Pulls the product list — stock, prices, categories and complementary tags — and upserts rows into upsr-catalogue, so the picker’s filters always read current stock.

Idempotency and exactly-once

Three points in the flow must not double-act, and each is pinned by a conditional write rather than by hope. First, one offer per order: upsr-order writes upsr-orders with a condition that the order id doesn’t already exist, so a store that fires the webhook three times for one checkout creates exactly one job. Second, one nudge per offer: upsr-sender flips the offer from chosen to sent with a condition on the current state, so two overlapping scheduler runs can never both send — the loser’s conditional update simply fails. Third, one terminal state per offer: both upsr-addlink and upsr-sweep only transition an offer that is still sent, so a tap that races the sweep resolves to a single outcome, and a re-run of the sweep skips anything already closed. The offers table’s state field is the single source of truth for where each offer is, and every transition is a guarded write.

Data stores, schedules, and messaging

  • DynamoDB (all on-demand). upsr-orders — PK order_id; one item per order with its dedup marker, basket snapshot, and gate outcome. upsr-offers — PK offer_id, holding the order, customer, chosen SKU, line, send_at, and state (chosen / sent / taken / out-of-window / ignored / no-fit); a GSI on state + send_at drives the sender and sweep queries, and the attribution window is a TTL attribute. upsr-catalogue — PK sku, the product mirror with stock, price, category and complementary tags. upsr-customers — PK customer_id, the history plus the frequency-cap fields (last_nudged_at, nudge_count). upsr-optout — PK customer_id, the suppression list checked before every send.
  • Function URLs. Two — upsr-order for the inbound webhook and upsr-addlink for the one-tap redirect, both AuthType NONE at the edge because authenticity is enforced in-function: the webhook by the store’s shared-secret signature, the add link by the signed token it carries. No API Gateway.
  • SNS and SES. SNS sends the outbound nudge (or SES does, if you send the offer by email); SES sends the internal take-up report to the shop owner from a verified domain with DKIM.
  • EventBridge Scheduler. Three rules — upsr-sender at rate(5 minutes) to release due offers, upsr-sweep at rate(1 hour) to close windows, and upsr-catalog-sync at rate(15 minutes) to refresh stock and prices.
  • Secrets Manager. Two secrets — the webhook signing secret and the store API key — fetched at call time, never in env vars or the catalogue sheet. The add-link token signing key lives here too, alongside the webhook secret.
  • Bedrock. Model id anthropic.claude-haiku-4-5 via the Global cross-Region inference profile, invoked only by upsr-picker.

IAM scope, observability, and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. upsr-order can read upsr-customers and upsr-optout, conditionally write upsr-orders, read the signing secret, and send to upsr-jobs — it cannot call Bedrock, SNS, or the catalogue. upsr-picker is the only role with bedrock:InvokeModel, scoped to the one Haiku profile; it reads upsr-catalogue and writes upsr-offers, and cannot send anything. upsr-sender can read upsr-offers, upsr-optout and upsr-customers, publish to SNS, and update the offer and cap — but cannot call Bedrock. upsr-addlink can validate the token, update upsr-offers, and redirect, nothing more. The scheduled functions hold the narrow catalogue, offers and SES permissions they need and, apart from upsr-addlink, no inbound surface at all. Everything runs in eu-west-2; the only cross-Region path is Bedrock’s Global inference profile, which routes the model call for capacity and is not a data store. CloudWatch Logs at 7-day retention carry every function’s trace, and an AWS Budgets alarm watches the monthly spend — with Bedrock and SMS the lines most likely to move, it’s the cheapest early warning that volume (or a runaway loop) is running hot.

That’s the whole system: an order comes in, a model proposes and the catalogue rules dispose, one tasteful nudge goes out with a one-tap link, and every offer — taken, ignored, or never sent — ends up counted. No always-on compute, one small model call per order, and a hard filter that means a bad suggestion can never reach a customer. It sells the add-on that actually fits, and stays quiet when none does.

Design rules that shaped the build

  • One job per function. Six small Lambdas beat one that does everything; the queue decouples the model call from the webhook.
  • Two public surfaces, both self-authenticating. upsr-order by signature, upsr-addlink by signed token — no API Gateway.
  • Least privilege, per role. Only the picker can call Bedrock; only the order and sender functions touch the opt-out list.
  • State in DynamoDB, transitions guarded. The offers table’s state field drives the send and sweep, and every change is a conditional write.
  • One region, one model. eu-west-2 throughout; Bedrock Haiku 4.5 via Global inference, called once per order.
  • A budget alarm is a smoke detector. With Bedrock and SMS the variable lines, a Budgets alert is the cheapest way to catch a runaway.
All posts