Part 7 of 7 · Booking deposit collector series ~10 min read

Engineering reference: the booking deposit collector architecture

This is the booking deposit collector with the friendly labels removed: the real resource names, the two public Function URLs, the table key schemas, the two conditional writes that keep money and the calendar honest, 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, wired through one SQS queue with a dead-letter queue.
  • Two public surfaces: bdc-intake for bookings and bdc-webhook for the payment provider — each its own Lambda Function URL, no API Gateway.
  • Five DynamoDB tables, all on-demand: bookings, slots, a payment-event ledger, an opt-out list, and an append-only audit log.
  • Exactly-once rests on two conditional writes: attribute_not_exists on the slot to hold it, and a state condition to confirm, release, and settle a booking once each.
  • One Bedrock model, Claude Haiku 4.5 via Global cross-Region inference, called only by the composer. 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 — one for the booking source, one for the payment webhook — outbound messaging goes through SNS and SES, and work is buffered on a single SQS queue.

Engineering architecture: two Function URLs, an SQS-buffered set of six Lambdas, five DynamoDB tables, Bedrock, SNS and SES, and two scheduled sweeps Three external boxes at the top: a booking source on the left, the customer and team in the centre, and the payment provider on the right, inside a dotted AWS account container labelled eu-west-2. The booking source posts to bdc-intake, a Lambda Function URL that holds the slot with a conditional write to the DynamoDB slots table bdc-slots, writes the booking PENDING with a deadline to bdc-bookings, and enqueues a compose job on the SQS queue bdc-jobs with dead-letter queue bdc-jobs-dlq. The payment provider posts to bdc-webhook, a second Lambda Function URL that verifies the signature with a secret from Secrets Manager, records the event id in the payment ledger bdc-payments with a conditional write, and flips the booking from PENDING to CONFIRMED in bdc-bookings with a conditional write. From the queue, bdc-composer, Python 3.14 arm64, makes the single Bedrock Claude Haiku 4.5 call, injects the link, and sends via SNS to the customer, writing bdc-audit. EventBridge Scheduler drives two scheduled Lambdas: bdc-release-sweep, which reminds once and releases unpaid holds past their deadline, freeing bdc-slots and marking bookings RELEASED; and bdc-settle-sweep, which after the appointment applies or forfeits the deposit and hands disputes to bdc-escalator, which emails the team via SES. The five DynamoDB tables sit in a column on the right: bdc-bookings keyed by booking id with a state-and-deadline index, bdc-slots keyed by the slot, bdc-payments keyed by event id, bdc-optout keyed by phone, and bdc-audit keyed by booking id and timestamp. Secrets Manager holds two secrets. Bedrock is called only by bdc-composer. Booking source form / phone / walk-in Customer / your team SMS in, email escalations Payment provider hosted link + webhook AWS account · eu-west-2 bdc-intake Function URL · hold + PENDING booking bdc-webhook Function URL · verify + confirm webhook bdc-jobs SQS + bdc-jobs-dlq EventBridge Scheduler 2 rules bdc-release-sweep remind once, release bdc-settle-sweep apply / forfeit bdc-composer Bedrock compose + SNS send bdc-escalator handover / dispute via SES Secrets Manager 2 secrets (sign key, SMS key) Bedrock Haiku 4.5, Global SNS (SMS) messages out SMS out SES handover email bdc-bookings PK booking_id · GSI state+deadline bdc-slots PK slot_key (the hold) bdc-payments PK event_id · TTL bdc-optout PK phone (STOP) bdc-audit PK booking_id, SK ts 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 booking deposit collector drawn for engineers: two Function URLs (bdc-intake, bdc-webhook), an SQS-buffered set of six Lambdas, five DynamoDB tables, Bedrock called only by the composer, SNS for SMS and SES for handover, and two scheduled sweeps. 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 (bdc-jobs, with bdc-jobs-dlq as its dead-letter queue after five attempts) decouples the two public webhooks from the slower model and SMS calls.

  • bdc-intake — the booking surface. Backs the first Lambda Function URL. Holds the slot in bdc-slots with a conditional write, writes the booking PENDING with its deposit deadline to bdc-bookings, and enqueues a compose job. It also takes the on-the-day attendance mark from front-of-house and writes the attended flag. Nothing slow happens here.
  • bdc-webhook — the money surface. Backs the second Function URL. Verifies the provider’s signature against the Secrets Manager signing key, records the event id in bdc-payments with a conditional write, and flips the booking PENDING → CONFIRMED with a conditional write — then enqueues a confirmation message. It cannot create bookings or hold slots.
  • bdc-composer — SQS-triggered on message jobs. Makes the single Bedrock call, validates the draft, injects the payment or booking link, checks the opt-out list, sends via SNS, and writes bdc-audit. The only function that can call Bedrock or publish SMS.
  • bdc-release-sweep — scheduled. Queries bdc-bookings by the state-and-deadline index for PENDING holds near or past their deadline; sends one reminder (marking the booking reminded) and, past the deadline, releases the hold with a conditional write on state == PENDING, freeing the bdc-slots item and marking the booking RELEASED.
  • bdc-settle-sweep — scheduled. After the appointment window, reads CONFIRMED bookings and, by the deterministic policy, marks each APPLIED or FORFEITED once with a conditional write on state == CONFIRMED, handing disputes and mismatches to the escalator.
  • bdc-escalator — builds the handover (booking + payment + outcome + reason), de-duplicates against open cases, and emails the team via SES for anything a person must own: a forfeit dispute, a payment mismatch, an unknown reference.

The data model

Five DynamoDB tables, all on-demand, and the schema is where the exactly-once guarantees live.

  • bdc-bookings — PK booking_id. One item per booking with its state (PENDING / CONFIRMED / RELEASED / APPLIED / FORFEITED), the slot_key it holds, the deposit amount, the deposit_deadline, the reminded and attended flags, and the payment reference. A global secondary index on state + deposit_deadline is what lets both sweeps find exactly the bookings they care about — PENDING-past-deadline for release, CONFIRMED-past-window for settlement — without scanning the table.
  • bdc-slots — PK slot_key (for example resource#2026-07-11T20:00). One item per held slot; this table is the double-booking guard. The hold is a PutItem with attribute_not_exists(slot_key), so only the first booking for a slot can write it. Releasing a hold deletes the item, returning the slot to sale.
  • bdc-payments — PK event_id. The idempotency ledger for the payment webhook: each incoming event is recorded with attribute_not_exists(event_id), so a re-delivery finds it present and stops. A TTL expires old records, since a webhook is never re-sent after a day or two.
  • bdc-optout — PK phone (E.164). The STOP suppression list, checked before every message the composer or a sweep sends.
  • bdc-audit — PK booking_id, SK ts, append-only. Every message sent and every state transition, with the facts it was built from — the record that makes a forfeit or a confirmation defensible after the fact.

Exactly-once, and never twice

Two conditional writes carry the whole system’s correctness, and everything else is downstream of them. The first is the slot hold: attribute_not_exists(slot_key) on bdc-slots means a slot can be held by exactly one booking, however many arrive at once — the database serialises the race, so there is no double-booking. The second is the state transition on bdc-bookings: every move is conditional on the current state, so bdc-webhook confirms only a booking that is still PENDING, bdc-release-sweep releases only one still PENDING, and bdc-settle-sweep settles only one still CONFIRMED. A duplicate payment webhook, an overlapping sweep, a retried SQS message — each finds the booking already past that state and does nothing. The payment ledger’s attribute_not_exists(event_id) is a belt-and-braces first line, but the state condition is the real backstop, because it makes every transition in the system idempotent by construction rather than by luck.

The SQS queue between the webhooks and the composer means a slow Bedrock or SNS call never blocks a webhook, and a transient failure is retried up to five times before the message lands in bdc-jobs-dlq for inspection. Because sends are keyed and audited, a redelivered message doesn’t double-text: the composer records what it has sent, and the state conditions upstream mean there was only ever one job to begin with.

IAM scope, observability, and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. bdc-intake can conditionally write bdc-slots, write bdc-bookings, read bdc-optout, and send to bdc-jobs — it cannot call Bedrock, SNS, or confirm a payment. bdc-webhook can read the signing secret, conditionally write bdc-payments, conditionally update bdc-bookings, and send to bdc-jobs — and nothing else; it is the only role that can move a booking to CONFIRMED, and it cannot create one. bdc-composer is the only role with bedrock:InvokeModel, scoped to the one Haiku profile, plus SNS publish and read access to the SMS secret; it cannot delete from any table. The two sweeps hold the narrow query-and-conditional-update permissions they need on bdc-bookings and bdc-slots and no inbound surface at all. bdc-escalator can send via SES and read its inputs only.

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 hold each function’s logs at 7-day retention, and an AWS Budgets alarm watches the monthly spend — with SMS the line most likely to move, it’s the cheapest early warning that volume, or a loop, is running hot. The whole stack is one region, one account, and defined as infrastructure-as-code: the two Function URLs, the six functions, the five tables and their index, the queue and DLQ, the two schedules, the SNS and SES configuration, the secrets, and the Budgets alarm all come up from a single template, so the system is reproducible and reviewable rather than clicked together by hand.

That’s the whole thing: a booking becomes a held slot and a deposit request, a signed webhook confirms it exactly once, an unpaid hold is reminded once and released, and on the day the deposit is applied or forfeited by policy — all on a few dollars a month of serverless AWS, with a model nowhere near the money and a human owning every exception.

All posts