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

Engineering reference: the back-in-stock notifier architecture

This is the back-in-stock notifier with the friendly labels removed: the real resource names, the runtime, the table key schemas, the three public Function URLs, the reservation-expiry schedule, and the IAM scope. If you want to build it rather than understand it, start here.

Key takeaways

  • Six Lambda functions, all Python 3.14 on arm64, with the notifier fed through one SQS queue and a dead-letter queue.
  • Three public surfaces: Function URLs on bisn-capture, bisn-detect, and bisn-reserve — no API Gateway.
  • Five DynamoDB tables, all on-demand: requests (the queue), restocks (the cap), reservations (the holds), opt-out, and audit.
  • Exactly-once is enforced by three conditional writes: the dedup key, the restock id, and the atomic units decrement.
  • One Bedrock model, Claude Haiku 4.5 via Global cross-Region inference, called only by the notifier. 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 three Lambda Function URLs, outbound messaging goes through SNS and SES, and the one asynchronous hop — a detected restock to the notifier — is buffered on a single SQS queue.

Engineering architecture: three Function URLs, an SQS-buffered notifier, six Lambdas, five DynamoDB tables, Bedrock, SNS and SES, and the scheduled jobs An external box at the top, the store inventory and shopper, posts to three Lambda Function URLs inside a dotted AWS account container labelled eu-west-2: notify-me taps to bisn-capture, stock webhooks to bisn-detect, and reserve-link clicks to bisn-reserve, all Python 3.14 arm64. bisn-capture validates a request, checks bisn-optout, and writes it to the DynamoDB requests table bisn-requests keyed by SKU and join timestamp with a per-contact dedup index. bisn-detect verifies the webhook signature with a secret from Secrets Manager, records the restock in bisn-restocks with a minted restock id and the unit count, and enqueues one batch on the SQS queue bisn-jobs with dead-letter queue bisn-jobs-dlq. From the queue, bisn-notifier reads bisn-requests oldest-first, caps the batch to the units in bisn-restocks, makes the single Bedrock Claude Haiku 4.5 call, mints reserve tokens, and sends via SNS for SMS or SES for email out to the shopper, writing bisn-audit. bisn-reserve validates a signed token and holds a unit with a conditional decrement on bisn-restocks, writing a row to the reservations table bisn-reservations with an expiry timestamp, then redirects to the store checkout. On the left, EventBridge Scheduler drives two scheduled Lambdas: bisn-catalog-sync, which mirrors the product catalogue and settings into a catalogue store, and bisn-sweep, which expires unconverted holds in bisn-reservations, returns the units to bisn-restocks, expires stale bisn-requests, and enqueues top-up batches on bisn-jobs. The five DynamoDB tables sit in a column on the right: bisn-requests keyed by SKU and join timestamp, bisn-restocks keyed by SKU and restock timestamp, bisn-reservations keyed by reserve token with a TTL, bisn-optout keyed by contact, and bisn-audit keyed by SKU and timestamp. Secrets Manager holds two secrets. Bedrock is called only by bisn-notifier. Store inventory & shopper notify-me, stock webhook, reserve click; checkout AWS account · eu-west-2 bisn-capture Function URL · notify-me taps in bisn-detect Function URL · stock webhook bisn-reserve Function URL · hold + redirect bisn-jobs SQS + bisn-jobs-dlq enqueue batch EventBridge Scheduler 2 rules bisn-catalog-sync catalogue + settings mirror bisn-sweep expire holds, top up bisn-notifier cap, Bedrock, SNS/SES send batch top-up Secrets Manager 2 secrets sign / verify Bedrock Haiku 4.5, Global SNS (SMS) notice out SMS out SES email notice bisn-requests PK sku, SK joined_ts bisn-restocks PK sku, SK restock_ts bisn-reservations PK reserve_token, TTL bisn-optout PK contact bisn-audit PK sku, SK ts All Lambdas: Python 3.14, arm64 · CloudWatch Logs 7-day retention · AWS Budgets cost alarm · three Function URLs, no API Gateway, no NAT
Fig 7. The back-in-stock notifier drawn for engineers: three Function URLs (bisn-capture, bisn-detect, bisn-reserve), an SQS-buffered notifier, five DynamoDB tables, Bedrock called only by the notifier, SNS for SMS and SES for email, and two scheduled jobs. One region, one account, no API Gateway.

The DynamoDB data model

Five tables, all on-demand, all in eu-west-2. The schema is deliberately shaped so that the two hard guarantees — queue order and never-oversell — fall out of the keys rather than out of application logic.

  • bisn-requests — the waiting list. PK sku, SK joined_ts (an ISO-8601 timestamp with a short random suffix for uniqueness). Because the sort key is the join time, a single Query on a SKU with ScanIndexForward=true returns the queue oldest-first — the ordering is the data. A global secondary index, bisn-requests-by-contact (PK sku#contact), backs the one-active-request-per-person check with a conditional write. Each item holds the channel, the request state (waiting / notified / reserved / converted), the last_notified_restock id, and a ttl for staleness expiry.
  • bisn-restocks — one item per detected restock. PK sku, SK restock_ts, plus a minted restock_id. The item carries units_available — the decrementable counter that is the whole never-oversell mechanism — and the restock state. Writing this item conditionally on the restock_id not already existing is what makes detection idempotent.
  • bisn-reservations — the holds. PK reserve_token (the signed, single-use token from the notice). Each row records the sku, the request_id, the restock_id, an expiry_ts, and a state (held / converted / expired). A DynamoDB TTL on expiry_ts tidies old rows, but the sweep, not TTL, drives the functional expiry so timing is precise.
  • bisn-optout — PK contact (E.164 number or email). The suppression list, checked at capture and again at send.
  • bisn-audit — PK sku, SK ts, append-only. One row per notice sent and per restock detected, holding the facts each was built from — the record you reach for when a customer asks “why was I (not) told?”.

Lambda functions

Six functions, all Python 3.14 on arm64, all with CloudWatch Logs at 7-day retention. Each does one job; only the notifier runs off the queue, so the public webhooks always answer fast.

  • bisn-capture — a Function URL. Validates the notify-me tap, normalises the contact, checks the SKU against the catalogue and bisn-optout, and writes the request to bisn-requests via a conditional put on bisn-requests-by-contact so a repeat tap refreshes rather than duplicates. Nothing slow happens here.
  • bisn-detect — a Function URL. Verifies the store signature, compares the new level to the last known one, and on a genuine below-to-above crossing mints a restock_id, writes bisn-restocks conditionally, records units_available, and enqueues one batch on bisn-jobs.
  • bisn-notifier — SQS-triggered on batch jobs. Reads bisn-requests oldest-first, walks the front skipping opt-outs and the already-notified, caps at units_available, makes the single Bedrock call, mints per-recipient reserve tokens, sends via SNS or SES, and writes bisn-audit — marking each recipient notified for the restock id with a conditional write before sending.
  • bisn-reserve — a Function URL. Validates the signed token, holds a unit with a conditional decrement of units_available on bisn-restocks, writes the bisn-reservations row, and redirects to the store checkout. A failed condition (units at zero) returns an honest “just gone”.
  • bisn-catalog-sync — scheduled. Mirrors the product catalogue and per-SKU settings (name, threshold, reserve window, voice) from the store into the catalogue store the other functions read.
  • bisn-sweep — scheduled. Queries bisn-reservations for holds past expiry_ts that never converted, increments units_available back on bisn-restocks, marks each reservation expired, expires stale bisn-requests, and enqueues top-up batches on bisn-jobs for any freed units.

Exactly-once, and never-oversell

Three conditional writes carry the whole correctness story, because SQS is at-least-once and webhooks retry. First, the dedup key: bisn-capture’s conditional put on sku#contact guarantees one place in the queue per person per item, however many times they tap. Second, the restock id: bisn-detect’s conditional write on a fresh restock_id means a re-sent webhook enqueues no second batch, and bisn-notifier’s conditional “notified for this restock” marker — written before the send — means a redelivered SQS message re-sends nothing. Third, the units decrement: bisn-reserve’s conditional units_available >= 1 before subtracting is what makes overselling impossible even under simultaneous clicks; DynamoDB serialises the writes, and the loser sees zero. The sweep’s expired flag closes the loop, so a returned unit is returned exactly once.

The queue between bisn-detect and bisn-notifier is bisn-jobs, with bisn-jobs-dlq as its dead-letter queue after five failed attempts. Because the notifier is idempotent per recipient per restock, a redelivery after a partial failure is safe — it resumes rather than re-sends. A batch that keeps failing (a malformed job, a downstream outage) lands in the DLQ with the restock id intact, alarms, and can be replayed once the cause is fixed; no notices are silently lost, and none are duplicated on replay.

IAM scope, observability, and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. bisn-capture can read the catalogue and bisn-optout and conditionally write bisn-requests — it cannot call Bedrock, SNS, or SES. bisn-detect can read the signing secret, write bisn-restocks, and send to bisn-jobs — nothing more. bisn-notifier is the only role with bedrock:InvokeModel, scoped to the one Haiku profile; it can publish to SNS and SES, read bisn-requests and bisn-optout, and write bisn-restocks markers and bisn-audit, but cannot delete from any table. bisn-reserve can conditionally update bisn-restocks and write bisn-reservations, and holds the token-signing secret — it has no messaging permissions at all. The scheduled functions hold only the narrow catalogue and table permissions they need and no inbound surface.

Observability is CloudWatch throughout: structured logs at 7-day retention, metrics on batch size versus units available (a notifier that ever sends more notices than units is a bug worth paging on), on DLQ depth, and on reserve-conversion rate. An AWS Budgets alarm watches monthly spend — with SMS the line most likely to move, it’s the cheapest early warning that volume, or a loop, is running hot. Everything runs in eu-west-2 from one infrastructure-as-code definition; the only cross-Region path is Bedrock’s Global inference profile, which routes the single model call for capacity and holds no data. The model id is anthropic.claude-haiku-4-5 via that Global profile, invoked only by bisn-notifier.

That’s the whole system: a notify-me tap becomes an ordered place in a queue, a signed inventory webhook becomes a counted restock, and the two meet in a notifier that tells people oldest-first, never past the number of units in hand, each with a link that holds one unit for a short while and passes it on if they don’t. Five tables, six small functions, three conditional writes doing the real work, and one region — a fair queue and a firm never-oversell promise for a couple of dollars a month.

Design rules that shaped the build

  • One job per function. Six small Lambdas beat one that does everything; only the notifier runs off the queue.
  • Order and cap live in the schema. The join-time sort key is the queue; the units counter is the cap — not application guesswork.
  • Three conditional writes hold the line. The dedup key, the restock id, and the units decrement give exactly-once and never-oversell.
  • Least privilege, per role. Only the notifier can call Bedrock, SNS, and SES; only reserve can decrement the units count.
  • Fail into the DLQ, not into silence. Idempotent retries resume safely; a stuck batch alarms and replays without duplicating notices.
  • One region, one model. eu-west-2 throughout; Bedrock Haiku 4.5 via Global inference, called once per restock.
All posts