Part 2 of 7 · Back-in-stock notifier series ~8 min read

How a back-in-stock request gets captured

Before anything can be sent, the system has to remember who asked, for what, and when — cleanly, once each, in order. This post is about that first step: how a “notify me” tap on a sold-out product becomes a single, de-duplicated place in a per-item queue, stamped with the moment it joined so the line stays fair when the stock finally comes back.

Key takeaways

  • The trigger is a “notify me” tap that posts the shopper’s contact and the SKU to one Lambda Function URL — no API Gateway.
  • The request is validated and normalised before anything is stored, so a malformed phone or a missing SKU never enters the queue.
  • One active request per person per item: a second tap on the same product updates the existing place, it doesn’t create a new one.
  • The opt-out list is checked at capture, so anyone who has unsubscribed is never quietly added back to a waiting list.
  • Every accepted request is stamped with the moment it joined, and that timestamp is the queue — oldest first when the stock returns.

From a tap to a place in the queue

Everything starts with a small, deliberate action the shop rarely captures well: a customer standing on a sold-out product page who still wants the thing. Most storefronts show “out of stock” and leave it there. This system puts a “notify me” control on that page, and when it’s tapped, the browser posts a tiny payload — a name, a phone number or email, the SKU, and the channel the shopper picked — to 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 form post needs and the cheapest way to receive one.

The capture function’s job is not to send anything — nothing goes out for days or weeks, until the item returns. Its job is to record the request cleanly and exactly once, and to stamp it with the moment it joined so that when the stock does come back, the queue is fair and unambiguous. Most of this post is about the checks between “a tap arrived” and “a place in the queue exists”, because a waiting list is only worth anything if it’s honest.

Prove it’s well-formed, then prove it’s new

The first check is validity. A Function URL is public, so the function treats every field as untrusted: the phone number is parsed and normalised to E.164 (the +44… international form) or the email is validated, the SKU is checked against the store catalogue so a typo or a stale product id can’t create a ghost queue, and the channel must be one the shop supports. Anything that fails is rejected at the door with a clear error and nothing is written. A light rate limit per source keeps a script from stuffing the list. Only a clean, real request gets past this line.

The second check is duplication, and it’s what keeps the queue honest. People tap “notify me” twice, or come back a week later and tap it again, and none of that should buy them a second place in the line or a second text when the item returns. So the system keeps one active request per person per item: it writes the request keyed on the SKU and the contact, using a conditional write to DynamoDB, so a repeat tap finds the existing request and simply refreshes it rather than adding a duplicate. Crucially, a repeat tap does not reset the join time — the original position is preserved, because moving someone to the back for asking twice would be its own small unfairness.

Opt-out is checked before you join

One more gate runs before a request is accepted: the opt-out list. Anyone who has ever replied STOP to a text or unsubscribed from an email is recorded as suppressed, and the capture function checks that list before adding them to any queue. If a suppressed contact taps “notify me”, the tap is acknowledged politely but no waiting-list entry is created and nothing will ever be sent — because the cleanest message to handle is the one you correctly decide never to send. This is the same suppression list the notifier consults again at send time, so opt-out is enforced at both ends: you can’t be added if you’ve opted out, and you can’t be messaged even if you slipped through.

Only once a request is well-formed, matched to a real SKU, de-duplicated, and cleared against opt-out does the function commit it to the waiting list.

The capture gate: notify-me in, validate, dedup one per person, opt-out check, then append to the per-SKU queue in join order On the left, an external box “Shopper — notify-me tap” sends an arrow into a Lambda Function URL box labelled the capture function, inside a dotted AWS account container. From the capture function a vertical sequence of decision steps runs downward. First “Validate & normalise” — parse the phone or email, check the SKU against a DynamoDB catalogue box; a bad request exits to a small “Reject 400” box. Next “New request? (one per person per item)”, backed by a DynamoDB requests table with a conditional write keyed on SKU and contact; a repeat tap exits to a “Refresh, keep position” box that does not move the join time. Next “Opt-out?”, reading a DynamoDB opt-out table; a suppressed contact exits to an “Acknowledge, add nothing” box. Finally an arrow into a box “Append to per-SKU queue” that writes the request stamped with its join timestamp into the requests table ordered oldest-first. A note reads: nothing is sent now; the join timestamp is the queue, and one person holds one honest place per item. Shopper notify-me tap AWS account Capture function Function URL Validate & normalise phone/email, SKU DynamoDB catalogue SKU exists? Reject 400 bad input New request? one per person / item DynamoDB requests conditional write Refresh, keep position repeat Opt-out? suppression list DynamoDB opt-out PK contact Ack, add nothing stop Append to queue stamp join time DynamoDB requests PK sku, SK joined_ts Nothing is sent now — the join timestamp is the queue, and one person holds one honest place per item.
Fig 2. The capture gate. A notify-me tap is validated and matched to a real SKU, de-duplicated so one person holds one place per item, and cleared against the opt-out list — then appended to the per-SKU queue stamped with the moment it joined, which is the order it’ll be told in.

The timestamp is the queue

There is no separate “queue” data structure to keep in sync — the join timestamp is the queue. Each accepted request is written to the requests table partitioned by SKU and sorted by the moment it joined, so reading the waiting list for the walnut dining chair is a single query that comes back oldest-first, for free. When Part 4 needs to know who’s next, it doesn’t compute an order; it just reads in that natural order and stops when it has enough. Storing the ordering as the sort key rather than deriving it later is what makes “first come, first told’ a property of the data instead of a promise the code has to keep.

Each request also carries a small amount of state that later parts lean on: which restock (if any) it was last notified for, so nobody is told twice for the same event; the channel the shopper chose; and a time-to-live so a request that’s sat unwanted for months eventually falls off the list rather than lingering forever. The one case that doesn’t flow straight through is a SKU the catalogue doesn’t recognise — a discontinued line, a bad id from an old cached page. Rather than create a queue for a product that may never return, the function records the attempt and, if it looks like real demand, drops a note to a person to check, instead of silently swallowing it. Everything else becomes a clean place in the line, ready for a restock to call.

Design rules that shaped the capture step

  • One public surface for capture. A single Lambda Function URL receives the notify-me tap; there is no API Gateway.
  • Validate before you store. A bad phone, email, or SKU is rejected at the door — ghost entries never reach the queue.
  • One person, one place. A conditional write keeps a single active request per person per item; repeat taps refresh, they don’t duplicate.
  • Asking twice never costs your spot. A repeat tap keeps the original join time, so nobody is pushed back for being keen.
  • Opt-out is checked here too. A suppressed contact is acknowledged but never added, so the list only holds people who can be told.
  • The timestamp is the order. Requests are stored sorted by join time, so the queue is a property of the data, not a calculation.
All posts