Part 7 of 7 · Event RSVP manager series ~8 min read

Engineering reference: the event RSVP manager architecture

Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the conditional-write capacity logic, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the signed-link flow. Read alongside the previous six posts; this one’s the build sheet.

Region and account shape

Default region: ap-southeast-1 (Singapore). SES inbound, Bedrock Global cross-Region inference, and EventBridge Scheduler are all in good shape there. A second region for multi-region resilience isn’t worth the extra setup work at SMB volume — the failure mode for a small event is a guest missing a reminder, not a regional outage. One AWS account dedicated to the RSVP manager (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.

Topology

AWS topology of the event RSVP manager A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three sign-up lanes — a register Function URL Lambda invoked by the static register form that runs the duplicate check and the seat request, an SES inbound rule set with action S3 PUT to s3://ev-raw-mime/ plus the parser Lambda intake-ses-parser that reads the sender and runs the same seat request, and a host-import Lambda invoked on demand that walks a pasted list and runs a seat request per person. Middle region: the guest-list keeper. The keeper Lambda is invoked by every lane and by the confirm, cancel, and claim Function URLs; it reads and writes the DynamoDB ev-guests and ev-event tables, claims or releases a seat with a single conditional UpdateItem on the confirmed count against the cap, and emits events to the EventBridge default bus per state change: ev.confirmed, ev.cancelled, or ev.seat_freed. Bottom region: dispatch and timed jobs. The messenger Lambda is triggered by an EventBridge rule on those events; it renders the confirmation, reminder, or waitlist-offer email, checks quiet hours, and sends via SES outbound. On a confirmation it books one-off EventBridge Scheduler reminder rules; on a seat-freed event it books a one-off claim-timeout rule. Function URL Lambdas handle confirm, cancel, and claim link clicks, verifying a signed token and calling back into the keeper. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $15 monthly threshold, posting to SNS topic ev-cost-alarm. A note at the bottom: the cap is defended by one conditional write — and every interaction is logged to ev-audit. Ingress Lambda · register Function URL duplicate check → seat request to keeper from register form SES inbound rule set ev-inbound-rules action: S3 PUT s3://ev-raw-mime/ trigger: intake-ses-parser Lambda · host-import on-demand invoke walks pasted list seat request per person → pending or pre-confirmed Seat request one door per lane · one keeper Guest-list keeper Lambda · keeper conditional UpdateItem count < cap to claim count − 1 to release + confirm/cancel/claim DynamoDB tables ev-guests (state) ev-event (cap, count) ev-audit (history) on-demand EventBridge default bus ev.confirmed ev.cancelled ev.seat_freed (per state change) Dispatch & timed jobs Lambda · messenger renders email, checks quiet hours; SES outbound; books Scheduler jobs Guest email confirm + cancel link + claim link (offers) signed-token links → Function URLs Lambda · link-handler verifies signed token, calls keeper for confirm, cancel, or claim; writes ev-audit The cap is defended by one conditional write — and every interaction is logged to ev-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into one seat request), the guest-list keeper (conditional writes defend the cap and emit events), dispatch and timed jobs (the email ships and the signed links resolve). Every Lambda is event- or invoke-driven; nothing is synchronous-chained.

Lambda functions

All Lambdas use the arm64 architecture, the smallest memory size that meets latency targets (typically 256 MB), Python 3.14 runtime, and CloudWatch Logs at 7-day retention. Each function has its own least-privilege IAM role. None run inside a VPC.

  • register — Lambda Function URL (AuthType: NONE), invoked by the static register form. Validates input, runs a duplicate check by email against ev-guests, and calls into the keeper for a seat request. Returns the resulting state (confirmed or waitlist position) as JSON for the form to render. Memory: 256 MB. Timeout: 15 s.
  • intake-ses-parser — S3 PUT trigger on s3://ev-raw-mime/. Parses the MIME, reads the sender display name and address from the headers, and scans the body for a clear cancel intent (“cancel”, “can’t make it”). On a sign-up it runs a seat request via the keeper; on a cancel intent it routes to the keeper’s release path. Sends a short SES reply with the result. Memory: 256 MB. Timeout: 30 s.
  • host-import — on-demand invoke from a small host admin action. Walks a pasted list of (name, email) rows and runs a seat request per row, honoring a pre_confirmed flag. People past the cap land on the waitlist in list order. Memory: 256 MB. Timeout: 60 s.
  • keeper — the core state machine. Invoked by the three ingress lanes and by link-handler. Claims a seat with a single conditional UpdateItem on the ev-event item: SET confirmed_count = confirmed_count + 1 with a condition expression confirmed_count < cap. Releases a seat with the mirror update guarded by attribute_exists and a guest-state condition so a double-click can’t double-decrement. Moves a waitlisted guest to confirmed on a claim with the same capacity condition. Emits ev.confirmed, ev.cancelled, or ev.seat_freed to the default bus. Writes every change to ev-audit. No Bedrock calls. Memory: 256 MB. Timeout: 15 s.
  • messenger — EventBridge rule on the three state events. Renders the right template (confirmation, reminder, waitlist offer, cancellation ack), checks the quiet-hours window from ev-event.settings, and ships via SES SendRawEmail. On ev.confirmed it books the guest’s one-off reminder rules through EventBridge Scheduler; on ev.seat_freed it books a one-off claim-timeout rule at the end of the claim window. A quiet-hours deferral re-books the message at the next morning’s business minute. Memory: 256 MB. Timeout: 30 s.
  • link-handler — Lambda Function URL (AuthType: NONE), serving the confirm, cancel, and claim links. Verifies the HMAC-signed token on the query string (signing key in Secrets Manager under ev/links/signing-key), checks it hasn’t expired, and calls the matching keeper path. Renders a small HTML confirmation page back to the guest. Memory: 256 MB. Timeout: 15 s.
  • reminder-fire — EventBridge Scheduler target for each booked reminder one-off. Re-checks that the guest is still confirmed (a cancelled guest’s jobs are deleted, but this guards a race), then invokes messenger to send that reminder. Self-cleans via --action-after-completion DELETE. Memory: 256 MB. Timeout: 15 s.
  • offer-timeout — EventBridge Scheduler target for each waitlist claim window. On fire, checks whether the offer was already claimed; if not, marks it expired and re-emits ev.seat_freed so the offer rolls to the next waitlister. Self-cleans. Memory: 256 MB. Timeout: 15 s.
  • host-summary — EventBridge Scheduler target, daily in the run-up to the event and once after it. The daily run sends the host a short headcount email (no Bedrock). The post-event run calls Bedrock Haiku 4.5 to draft a thank-you note for the confirmed list. Also backs the host Q&A helper (an on-demand invoke that answers plain-English questions about the guest list via Haiku 4.5). Memory: 512 MB.

Storage

  • DynamoDB · ev-guests — one row per guest. PK (event_id, guest_id); GSI on (event_id, email) for the duplicate check and on (event_id, waitlist_pos) for the front-of-line lookup. Attributes: name, email, state (pending/confirmed/waitlisted/cancelled), reminder_jobs (list), offer_expires. On-demand.
  • DynamoDB · ev-event — one item per event holding the contended counter. PK event_id; attributes: cap, confirmed_count, waitlist_len, and a settings map (timezone, reminder offsets, quiet-hours window, claim-window length). The conditional write that defends the cap targets this item. On-demand.
  • DynamoDB · ev-audit — one row per state change of any kind. PK (event_id, ts); attributes: guest_id, action (confirm/cancel/offer/claim/expire/import), by, before, after. On-demand. No TTL — this is the long-term record so the guest list is reconstructable.
  • S3 · ev-raw-mime — raw inbound MIME from the email reply lane. Versioning enabled. Lifecycle to Glacier at 30 days; expiry at 1 year.
  • S3 · ev-assets — the static register page and email template fragments. Versioning enabled.

The capacity cap, exactly

The cap is enforced by one DynamoDB UpdateItem against the single ev-event item. To claim a seat: UpdateExpression: "SET confirmed_count = confirmed_count + :one" with ConditionExpression: "confirmed_count < cap". If the condition fails, the SDK raises ConditionalCheckFailedException; the keeper catches it and routes the guest to the waitlist instead of erroring. Because UpdateItem is atomic, a burst of concurrent claims on the last seat resolves to exactly one success and the rest fall through to the waitlist — no read-then-write gap, no optimistic-lock retry loop. Releasing a seat is the mirror: confirmed_count = confirmed_count - :one guarded by a guest-state condition so a double-click can’t decrement twice. This single-item counter is the reason the system can promise it never oversells.

Bedrock

  • Foundation model. anthropic.claude-haiku-4-5-20251001-v1:0 via the Global cross-Region inference profile global.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites: the post-event thank-you draft and the host Q&A helper, both in host-summary. Heavier reasoning isn’t needed here, so Sonnet 4.6 is not wired in.
  • Embeddings. Not used. The guest list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors.
  • Quotas. Default account quotas are more than enough. No hot path calls Bedrock; the model fires at most twice per event plus the occasional host question.

EventBridge Scheduler config

  • Reminder one-offsat(YYYY-MM-DDTHH:MM:SS) in the event timezone, target reminder-fire, one per scheduled reminder per confirmed guest. --action-after-completion DELETE.
  • Offer-timeout one-offsat(...) at the end of each waitlist claim window, target offer-timeout. Self-deleting.
  • Quiet-hours deferrals — created on the fly by messenger when a send would land in the quiet window; at(...) at the next business minute, target messenger. Self-deleting.
  • ev-host-dailycron(0 9 * * ? *) in the event timezone during the run-up window. Target: host-summary.

SES inbound and outbound

  • Set the MX record on a dedicated subdomain (e.g. rsvp.your-event.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set ev-inbound-rules: one rule with recipient rsvp@your-event.com → spam scan → S3 PUT to s3://ev-raw-mime/<message-id> → stop. The S3 PUT triggers intake-ses-parser.
  • SES outbound for confirmations, reminders, and offers: verify a sender identity at events@your-event.com with DKIM and SPF on the parent domain. Out of sandbox by request. A configuration set with open/click tracking off keeps the emails plain and the bounce/complaint topics wired to SNS.

IAM (least privilege per Lambda)

Each Lambda has its own role with policies scoped to exact ARNs. Sketch:

  • keeper role: dynamodb:UpdateItem + GetItem on ev-event and ev-guests; dynamodb:Query on the GSIs; dynamodb:PutItem on ev-audit; events:PutEvents on the default bus. No bedrock:*, no ses:*.
  • messenger role: ses:SendRawEmail from the verified identity; scheduler:CreateSchedule + DeleteSchedule for reminder and timeout one-offs; dynamodb:GetItem on ev-event + ev-guests for templating; secretsmanager:GetSecretValue on the link signing key (to mint signed links).
  • link-handler role: secretsmanager:GetSecretValue on ev/links/signing-key; lambda:InvokeFunction on keeper (or direct DDB if collapsed); dynamodb:PutItem on ev-audit.
  • intake-ses-parser role: s3:GetObject on ev-raw-mime; lambda:InvokeFunction on keeper; ses:SendRawEmail for the reply.
  • host-summary role: dynamodb:Query on ev-guests + ev-audit; bedrock:InvokeModel on the Haiku ARN; ses:SendRawEmail from the verified identity.

Every confirm, cancel, and claim link is a Function URL with a query string carrying event_id, guest_id, an action, an expiry timestamp, and an HMAC-SHA256 signature over those fields keyed by ev/links/signing-key. link-handler recomputes the HMAC and rejects any mismatch or past-expiry link before touching state. This gives passwordless, per-seat authorization: a guest can act only on their own seat, and only until the event passes. Claim links additionally carry an offer_id so a stale offer link (rolled on to the next person) is recognized and rejected with a friendly “this offer has expired” page.

Observability and cost gates

  • CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on "error" + "ConditionalCheckFailed" + "timeout" to a metric for alerting (note: a ConditionalCheckFailed on a claim is normal — a full event — so alert only on unexpected rates).
  • Alarms: keeper failures > 0 excluding capacity-condition failures; messenger send failure rate > 1% in 24h; link-handler signature-verification failures > 5/hour (might mean a tampered link campaign or a rotated key).
  • X-Ray: off by default. Not worth the cost at SMB volume.
  • AWS Budgets: $15/month threshold, alarm at 80% and 100%, posts to SNS topic ev-cost-alarm subscribed to the host admin’s email.

Config and secrets

The link signing key lives in Secrets Manager under ev/links/signing-key; the SES sender identity lives in IAM and the verified-domain config. Per-event settings — cap, timezone, reminder offsets, quiet-hours window, claim-window length, and the time-critical-offer override — live in the settings map on the ev-event item so the host can change them without a deploy. Cross-event defaults live in Parameter Store under /ev/config/. Lambdas fetch config on cold start and cache it for the lifetime of the execution environment.

Deploy

Deploy with GitHub Actions + OIDC + AWS SAM — no long-lived keys. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), keep the cap and counter on a single ev-event item so the conditional write stays atomic (don’t shard the counter unless you actually outgrow a single partition), and pin the EventBridge Scheduler timezone to the event timezone so reminders don’t silently start firing in UTC after a CI change. Total deployable surface: around nine Lambdas, three DDB tables (plus two GSIs), two S3 buckets, one EventBridge rule on the default bus (plus the on-the-fly Scheduler one-offs), one SES rule set, and one Budgets alarm.

That’s the full system. Six narrative posts and this engineering reference. If you want to talk about adapting it for your event, see Work with me.

All posts