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-intakefor bookings andbdc-webhookfor 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_existson 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.
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 inbdc-slotswith a conditional write, writes the booking PENDING with its deposit deadline tobdc-bookings, and enqueues a compose job. It also takes the on-the-day attendance mark from front-of-house and writes theattendedflag. 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 inbdc-paymentswith 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 writesbdc-audit. The only function that can call Bedrock or publish SMS.bdc-release-sweep— scheduled. Queriesbdc-bookingsby the state-and-deadline index for PENDING holds near or past their deadline; sends one reminder (marking the bookingreminded) and, past the deadline, releases the hold with a conditional write on state== PENDING, freeing thebdc-slotsitem 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— PKbooking_id. One item per booking with itsstate(PENDING/CONFIRMED/RELEASED/APPLIED/FORFEITED), theslot_keyit holds, the depositamount, thedeposit_deadline, theremindedandattendedflags, and the payment reference. A global secondary index onstate+deposit_deadlineis 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— PKslot_key(for exampleresource#2026-07-11T20:00). One item per held slot; this table is the double-booking guard. The hold is aPutItemwithattribute_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— PKevent_id. The idempotency ledger for the payment webhook: each incoming event is recorded withattribute_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— PKphone(E.164). The STOP suppression list, checked before every message the composer or a sweep sends.bdc-audit— PKbooking_id, SKts, 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