Part 7 of 7 · Appointment reminder series ~8 min read

Engineering reference: the appointment reminder architecture

Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the SMS reply 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, EventBridge Scheduler, and SNS SMS 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 an SMB is a reminder that goes out an hour late, not a regional outage. One AWS account dedicated to the reminder (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system. SMS origination identity (a sender ID or a long/short code, depending on the destination country’s rules) is registered in the SNS console and referenced by the dispatch Lambda.

Topology

AWS topology of the appointment reminder A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three intake lanes — a Drive sheet sync via the drive-sync Lambda triggered every few minutes by EventBridge Scheduler that mirrors the appointment CSV to s3://ar-appointments-source/, an SES inbound rule set with action S3 PUT to s3://ar-raw-mime/ plus the parser Lambda intake-ses-parser that runs Textract on attachments and Bedrock Haiku 4.5 to propose a row for Slack approval, and a calendar-sync Lambda triggered hourly by EventBridge Scheduler that polls Google Calendars for events tagged hashtag-appt and proposes rows the same way. Middle region: scheduled processing. The reminder Lambda is triggered hourly by EventBridge Scheduler; it reads s3://ar-appointments-source/appointments.csv, iterates rows, computes hours_to_appt per appointment, looks up the schedule in s3://ar-rules-source/rules.txt, reads reply and send state from DynamoDB, and emits one of three events to the EventBridge default bus per appointment that needs an action: ar.first_reminder, ar.second_reminder, or ar.gap_alert. Bottom region: dispatch and reply. The dispatch Lambda is triggered by an EventBridge rule on those three event types; it picks the channel, checks quiet hours and the holiday calendar, fetches the message template from s3://ar-rules-source/voice.txt, sends the text via SNS Publish or an email via SES outbound or a gap alert to Slack, and writes a row to DynamoDB ar-sends. Customer taps on the one-tap reply links land on a Function URL Lambda reply-handler that updates ar-replies with the action (confirm, reschedule, cancel) and, on reschedule, updates the appointment sheet via the Google Sheets API. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $30 monthly threshold, posting to SNS topic ar-cost-alarm. A note at the bottom: every reminder leaves with full context — and every interaction is logged to ar-audit. Ingress Lambda · drive-sync every few min Sheets API → s3://ar-appointments-source/ appointments.csv SES inbound rule set ar-inbound-rules action: S3 PUT s3://ar-raw-mime/ trigger: intake-ses-parser Lambda · calendar-sync hourly poll Calendar API for events tagged #appt → Slack proposal Drive appointment sheet canonical store · mirrored to S3 Scheduled processing EventBridge Scheduler rate(1 hour) in TZ_NAME target: reminder Lambda + deferred one-offs Lambda · reminder reads CSV from S3 + rules.txt + voice.txt computes hours, picks one of four moves EventBridge default bus ar.first_reminder ar.second_reminder ar.gap_alert (scheduled → no event) Dispatch & reply Lambda · dispatch picks channel, quiet hours, holidays; SNS SMS or SES outbound Reply links text/email with [Confirm][Resched][Cancel] link taps → Function URL Lambda · reply-handler writes ar-replies, ar-audit, and on reschedule updates the Sheet via Sheets API Every reminder leaves with full context — and every interaction is logged to ar-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the list), scheduled processing (the hourly reminder tick emitting events), dispatch and reply (the reminder ships and the customer’s response is recorded). Every Lambda is event- or schedule-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.

  • drive-sync — EventBridge Scheduler target, fires every few minutes (default rate(5 minutes); tighten only if your booking tool writes the sheet faster than the reminder needs). Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager under ar/drive/sa) to export the appointment sheet as CSV and write to s3://ar-appointments-source/appointments.csv only if the sheet has changed since the last sync. Same pattern syncs the rules and voice docs to s3://ar-rules-source/. Memory: 256 MB. Timeout: 30 s.
  • calendar-sync — EventBridge Scheduler target, hourly. Uses the Google Calendar API events.list to scan configured calendars for events with #appt in the description; for any new events, creates a Slack interactive proposal message. For lower-latency setups you can switch to events.watch and have Calendar push notifications to a Function URL instead of polling, at the cost of renewing the channel before it expires (Calendar push channels have a finite TTL and need a small refresh job). Memory: 256 MB. Timeout: 30 s.
  • intake-ses-parser — S3 PUT trigger on s3://ar-raw-mime/. Parses MIME; if the booking confirmation is plain text it goes straight to the model, and if it carries a PDF or image attachment it runs Textract via StartDocumentTextDetection (asynchronously to handle multi-page confirmations). On Textract completion (via SNS notification), reads the structured text and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0 via global.anthropic.claude-haiku-4-5-20251001-v1:0) to propose an appointment row. Posts the proposal to Slack via the bot token with Approve/Edit/Discard buttons. For DOCX attachments (Textract doesn’t accept them), falls back to python-docx; XLSX uses openpyxl. Both packages are stable and widely used in 2026, though their maintenance velocity is light — for a parsing path that only runs a few times a month, that’s acceptable. Memory: 512 MB. Timeout: 60 s.
  • reminder — EventBridge Scheduler target, hourly (the schedule expression runs in TZ_NAME set to the SMB’s timezone, e.g. Asia/Singapore). Reads s3://ar-appointments-source/appointments.csv and the rules and voice docs. For each row, computes hours_to_appt, reads chain state from ar-sends and ar-replies, decides on a move. Emits one event per row that needs action: ar.first_reminder, ar.second_reminder, or ar.gap_alert, with the appointment context as the event payload. Scheduled (no-action) appointments emit nothing. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.
  • dispatch — EventBridge rule on the three move events. Picks the channel, checks quiet hours and holiday calendar, formats the message from the voice template, and ships via SNS Publish (SMS, with the registered origination identity), SES SendRawEmail (email fallback), or the Slack bot token (gap alert to staff). On quiet-hours or holiday defer, creates a one-off EventBridge Scheduler rule that re-invokes dispatch at the next allowed minute. Writes a row to ar-sends after a successful send. Memory: 256 MB. Timeout: 30 s.
  • reply-handler — Lambda Function URL, public with AuthType: NONE; each reply link carries a signed, single-use token (HMAC over appt_id + action + nonce, secret in Secrets Manager) so a guessed URL can’t confirm or cancel someone else’s appointment. Triggered by customer taps on the Confirm/Reschedule/Cancel links. Writes to ar-replies and ar-audit; on reschedule, updates the Drive sheet via the Sheets API and archives the old chain in ar-sends-archive; on cancel, frees the slot and posts a gap alert to Slack. Renders a tiny HTML confirmation page. Memory: 256 MB. Timeout: 15 s.
  • summary — EventBridge Scheduler target, weekly Monday 8am. Reads the past week’s ar-sends, ar-replies, and ar-audit; calls Bedrock Haiku 4.5 to write a short staff narrative (confirms, no-shows prevented, slots freed, customers who went quiet); posts it to a configured Slack channel and emails it via SES to the owner. Memory: 512 MB.

Storage

  • DynamoDB · ar-sends — one row per dispatch. PK (appt_id, window_index); attributes: sent_at, channel (sms/email/slack), recipient, move (first_reminder/second_reminder/gap_alert). On-demand. No TTL.
  • DynamoDB · ar-replies — one row per reply. PK appt_id; sort key replied_at; attributes: action (confirm/reschedule/cancel), channel, old_time, new_time (if action = reschedule). On-demand. The reminder reads this table for the “already confirmed or cancelled?” short-circuit.
  • DynamoDB · ar-audit — one row per write action of any kind. PK (appt_id, ts); attributes: action, channel, before, after. On-demand. No TTL — this is the long-term audit trail.
  • DynamoDB · ar-sends-archive — archived chains after a reschedule. Same shape as ar-sends; PK (appt_id, chain_id, window_index). On-demand.
  • S3 · ar-appointments-source — mirrored CSV from the Drive appointment sheet. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 2 years.
  • S3 · ar-rules-source — mirrored rules and voice docs as plain text. Versioning enabled.
  • S3 · ar-raw-mime — raw inbound MIME from forwarded booking emails. Lifecycle to Glacier at 30 days; expiry at 1 year.

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: intake-ses-parser for the inbound booking parsing, and summary for the weekly staff narrative. The hourly tick never touches Bedrock; if you ever wanted a model to draft a warmer same-day message, Haiku 4.5 is the right tier and Sonnet 4.6 would be overkill.
  • Embeddings. Not used. The list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors.
  • Quotas. Default account quotas are more than enough at SMB volume. The reminder itself doesn’t call Bedrock; the parsing lane fires a few times a month at most.

EventBridge Scheduler config

  • ar-hourly-tickrate(1 hour) in the SMB’s timezone. Target: reminder Lambda.
  • ar-drive-syncrate(5 minutes). Target: drive-sync Lambda.
  • ar-calendar-syncrate(1 hour). Target: calendar-sync Lambda.
  • ar-weekly-summarycron(0 8 ? * MON *) in TZ. Target: summary Lambda.
  • One-off rules — created on the fly by dispatch when a quiet-hours or holiday defer is needed. Use at(YYYY-MM-DDTHH:MM:SS) expressions with --action-after-completion DELETE so the rule self-cleans.

SNS SMS, SES inbound and outbound

  • SNS SMS. Register an origination identity for each destination country in the SNS console (sender ID where allowed, otherwise a long or short code; some countries require pre-registration). Set the SMS type to Transactional for delivery priority, and set a monthly SMS spend limit in SNS as a hard backstop under the Budgets alarm. The dispatch Lambda calls sns:Publish with the customer’s E.164 phone number.
  • Set the MX record on a dedicated subdomain (e.g. bookings.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set ar-inbound-rules: one rule with recipient bookings@your-company.com → spam scan → S3 PUT to s3://ar-raw-mime/<message-id> → stop. The S3 PUT triggers intake-ses-parser.
  • SES outbound for the email-fallback reminders: verify a sender identity at appointments@your-company.com with DKIM and SPF on the parent domain. Out of sandbox by request.

IAM (least privilege per Lambda)

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

  • reminder role: s3:GetObject on the appointments, rules, and voice keys; dynamodb:Query + GetItem on ar-sends, ar-replies; events:PutEvents on the default bus. No bedrock:*.
  • dispatch role: events:ListSchedules + CreateSchedule for the deferred-send one-offs; sns:Publish for SMS with the registered origination identity; secretsmanager:GetSecretValue on the Slack bot-token secret; ses:SendRawEmail from the verified sender identity; dynamodb:PutItem on ar-sends; outbound network access to hooks.slack.com.
  • reply-handler role: dynamodb:PutItem on ar-replies and ar-audit; secretsmanager:GetSecretValue on the Sheets-API service-account secret and the reply-token HMAC secret; outbound network access to sheets.googleapis.com; dynamodb:Query for chain-state lookup; on reschedule, dynamodb:BatchWriteItem for archiving the old chain to ar-sends-archive.
  • intake-ses-parser role: s3:GetObject on ar-raw-mime; textract:StartDocumentTextDetection; bedrock:InvokeModel on the Haiku ARN; secretsmanager:GetSecretValue on the Slack bot token.
  • drive-sync and calendar-sync roles: secretsmanager:GetSecretValue on the Google service-account secret; s3:PutObject on the appointments and rules buckets; outbound network to www.googleapis.com.

Reply-link token flow

The one-tap links are public URLs, so each one carries a signed, single-use token rather than a raw appointment id. The token is an HMAC (key in Secrets Manager under ar/reply/hmac) over appt_id, the action (confirm/reschedule/cancel), and a per-send nonce, with a short expiry tied to the appointment time. reply-handler verifies the signature and the expiry, checks the nonce hasn’t already been spent (a single-use marker in ar-replies), then processes the action. A confirm or cancel is one tap and resolves immediately; a reschedule renders a slot-picker page that posts back to the same handler with a second token. This keeps the surface stateless and tamper-evident without standing up an auth system for customers who’ll never log in.

Observability and cost gates

  • CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on "error" + "throttle" + "timeout" to a CloudWatch metric for alerting.
  • Alarms: reminder Lambda failures > 0 in an hour (the tick is the one piece that has to run); SNS SMS delivery-failure rate > 5% in 24h (might mean an origination-identity or registration issue); reply-handler token-verification failures > 10/hour (might mean a leaked or rotated HMAC secret).
  • X-Ray: off by default. Not worth the cost at SMB volume.
  • AWS Budgets: $30/month threshold, alarm at 80% and 100%, posts to SNS topic ar-cost-alarm subscribed to the owner’s email and Slack. Back it with the SNS monthly SMS spend limit so a runaway loop can’t send unbounded texts.

Config and secrets

Service-account credentials for Drive, Sheets, and Calendar APIs all live in Secrets Manager under ar/drive/sa (one service account with scopes for all three APIs). The Slack bot token lives under ar/slack/bot-token. The reply-link HMAC key lives under ar/reply/hmac. SES sender identity lives in IAM and the verified-domain config; the SNS origination identity lives in the SNS config. The configured timezone, holiday list reference, quiet-hours window, per-service reminder schedules, and gap-alert Slack channel all live in Parameter Store under /ar/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.

Deploy

Whichever IaC you prefer. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for both ar-appointments-source and ar-rules-source so a bad Drive edit can be rolled back in one click, set the SNS monthly SMS spend limit explicitly so a bug can’t run up a text bill, and version the EventBridge Scheduler timezone setting so you don’t accidentally start running the hourly tick in UTC after a CI rotation. CDK with a Python stack file works well; SAM also fits. Total deployable surface: around seven Lambdas, four DDB tables, three S3 buckets, one EventBridge rule on the default bus (plus the Scheduler rules), one SES rule set, one SNS origination identity, 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 business, see Work with me.

All posts