How an order triggers a suggestion
Before the system suggests a single thing, it has to earn the right to: prove the order is real and new, check the customer hasn’t opted out or been nudged too recently, and gather what they actually bought. This post is about that gate — how a raw order webhook becomes a safe, context-rich job that a suggestion can be built from, or is quietly dropped before it ever costs a model call.
Key takeaways
- The trigger is an order webhook from your store, posted to one Lambda Function URL — no API Gateway.
- The webhook is verified by its signature before anything else runs, so a stranger can’t make the system suggest things to people.
- A conditional write keyed on the order id means one order can only ever create one offer, however many times the webhook fires.
- Opt-out and a per-customer frequency cap are checked before any model call — the cheapest offer is the one you correctly decide not to build.
- Only once an order is real, new, allowed and gathered does it become a suggest job; everything slow happens later.
From an order to a job
Everything starts with an event the shop already produces but rarely acts on in the moment: an order was placed. E-commerce platforms expose this as a webhook — when a customer checks out, the store sends a small HTTP POST with the order id, the customer id, and the line items. That POST lands on a single Lambda Function URL. There’s no API Gateway in front of it; a Function URL is a plain HTTPS endpoint on the function itself, which is all a webhook needs and the cheapest way to receive one.
The order function’s job is not to suggest anything. Its job is to decide whether a suggestion is worth attempting at all, and to set it up so it can only ever happen once. Most of this post is about the checks that sit between “an order arrived” and “a suggest job is queued”, because that gap is where an upsell system either stays welcome or tips over into nagging.
Prove it’s real, then prove it’s new
The first check is authenticity. A Function URL is public, so the first thing the function does is verify the store’s signature — a hash of the request body signed with a shared secret held in Secrets Manager. If the signature doesn’t match, the request is dropped with a 401 and nothing else happens. Without this, anyone who found the URL could fabricate orders and make your system message strangers; with it, only your store can trigger a pick.
The second check is duplication, and it’s what enforces the one-order-one-offer promise. Stores retry webhooks they think failed, and some fire more than once for a single checkout — an order created event, then a paid event, then a fulfilled event, all for the same purchase. The system must build at most one offer per order. So the function writes a record keyed on the order id using a conditional write to DynamoDB: the first event for that order wins and creates the job; any later events or retries see the existing record and stop. One order, one offer, however many times the store shouts about it.
Should we suggest at all?
Two more gates decide whether a suggestion is even allowed, regardless of what was bought. Opt-out is the suppression list: anyone who has ever asked not to be marketed to is recorded, and the system will never nudge that customer again. The frequency cap is the anti-nagging rule: no customer is nudged more than once inside a set window — thirty days by default — so a regular who orders every week still only hears a suggestion occasionally. The function reads the customer’s last-nudged timestamp and count, and if a nudge would fall inside the window, it stops here. Both checks run before a single token is spent on the model, because the cheapest suggestion to handle is the one you correctly decide not to make.
Only once an order is verified, de-duplicated, not opted out, and inside the frequency cap does the function gather the context a pick will need — and enqueue the job.
Gathering the context
Gathering is where the order stops being a bare event and becomes something a good suggestion can be built from. The function reads the line items from the webhook — what was actually bought, in what quantities, at what value — and joins them to the customer’s order history mirrored from the store: what they’ve bought before, so the pick can avoid suggesting something already owned, and how much they typically spend, so the price rules have a sense of proportion. It also notes the order value, because a £45 add-on makes sense against a £520 bike and looks absurd against a £6 pack of inner tubes.
None of this is a judgement yet; it’s just assembling the facts. The one thing the gate deliberately does not do is call the catalogue or the model — those belong to the pick, which is slower and costs money, and should only run for orders that have already earned it. So the output of this step is a small, clean job on the SQS queue: the order id, the customer id, the basket, the relevant history, and the order value, ready for Part 3 to turn into a single suggestion. Everything expensive happens on the far side of that queue, which keeps the public webhook fast and cheap no matter how busy the shop gets.
Design rules that shaped the gate
- One public surface. A single Lambda Function URL receives the order webhook; there is no API Gateway and no other way in.
- Verify before you trust. A bad signature is dropped with a 401 before any work — the URL being public is fine because the secret isn’t.
- One order, one offer. A conditional write on the order id collapses store retries and duplicate events into a single job.
- Decide not to suggest, cheaply. Opt-out and the frequency cap are checked before the model runs — a suppressed order costs nothing.
- Gather, don’t judge. The gate assembles the basket and history; it never calls the catalogue or the model itself.
- Keep the webhook fast. Everything slow — the catalogue join, the model, the send — happens behind the SQS queue.