Part 7 of 7 · Missed-call text-back series ~7 min read

Engineering reference: the missed-call text-back architecture

This is the missed-call text-back with the friendly labels removed: the real resource names, the runtime, the table key schemas, the single public Function URL, the escalation 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, wired through one SQS queue with a dead-letter queue.
  • One public surface: a single Lambda Function URL on mctr-webhook that takes both missed-call events and inbound replies — no API Gateway.
  • Five DynamoDB tables, all on-demand: calls, threads, customers mirror, opt-out list, and an append-only audit log.
  • One EventBridge Scheduler with two rules: the 15-minute no-reply sweep and the customer-sheet sync.
  • One Bedrock model, Claude Haiku 4.5 via Global cross-Region inference, called only by the replier. 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 surface is one Lambda Function URL, outbound SMS goes through SNS, and work is buffered on a single SQS queue.

Engineering architecture: the webhook Function URL, SQS, six Lambdas, five DynamoDB tables, Bedrock, SNS and SES, and the scheduled jobs An external box at the top, the telephony and SMS provider, posts both missed-call events and inbound replies to a single Lambda Function URL, mctr-webhook, inside a dotted AWS account container labelled eu-west-2. mctr-webhook verifies the signature with a secret from Secrets Manager, de-duplicates and records the call in the DynamoDB calls table mctr-calls, matches the caller against the customers table mctr-customers, and enqueues a job on the SQS queue mctr-jobs with dead-letter queue mctr-jobs-dlq. From the queue, two processing Lambdas run, all Python 3.14 arm64: mctr-replier makes the single Bedrock Claude Haiku 4.5 call and sends one SMS via SNS, which the provider delivers to the caller, writing the result to mctr-calls and mctr-audit; and mctr-router threads inbound replies into mctr-threads and triages them, passing urgent or unmatched ones to mctr-escalator. mctr-escalator emails the support inbox via SES. On the left, EventBridge Scheduler drives two scheduled Lambdas: mctr-drive-sync, which pulls the customer sheet into mctr-customers, and mctr-sweep, which every fifteen minutes queries mctr-calls for texted-but-unanswered calls and hands the stale ones to mctr-escalator. The five DynamoDB tables sit in a column on the right: mctr-calls keyed by phone and call timestamp, mctr-threads keyed by thread id, mctr-customers keyed by phone, mctr-optout keyed by phone, and mctr-audit keyed by call id and timestamp. Secrets Manager holds two provider secrets. Bedrock is called only by mctr-replier. Telephony / SMS provider missed-call + reply webhooks; SMS out AWS account · eu-west-2 mctr-webhook Function URL · verify, dedup, match event in mctr-jobs SQS + mctr-jobs-dlq EventBridge Scheduler 2 rules mctr-drive-sync customer sheet → mctr-customers mctr-sweep no-reply, every 15 min mctr-replier Bedrock compose + SNS send mctr-router thread + triage replies mctr-escalator handover via SES Secrets Manager 2 provider secrets Bedrock Haiku 4.5, Global SNS (SMS) text-back out SMS out SES handover email Support inbox external mctr-calls PK phone, SK call_ts mctr-threads PK thread_id mctr-customers PK phone mctr-optout PK phone (STOP) mctr-audit PK call_id, SK ts All Lambdas: Python 3.14, arm64 · CloudWatch Logs 7-day retention · AWS Budgets cost alarm · one Function URL, no API Gateway, no NAT
Fig 7. The missed-call text-back drawn for engineers: one Function URL on mctr-webhook, an SQS-buffered set of six Lambdas, five DynamoDB tables, Bedrock called only by the replier, SNS for SMS and SES for handover, and two scheduled jobs. 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 (mctr-jobs, with mctr-jobs-dlq as its dead-letter queue after five attempts) decouples the public webhook from the slower model and SMS calls.

  • mctr-webhook — the only public surface. Backs the single Lambda Function URL and handles both event types from the provider. For a missed call: verifies the signature, de-duplicates against mctr-calls with a conditional write, checks mctr-optout and quiet hours, matches the caller against mctr-customers, and enqueues a compose job. For an inbound reply: enqueues a routing job. Nothing slow happens here.
  • mctr-replier — SQS-triggered on compose jobs. Makes the single Bedrock call, validates the draft, injects the booking link, sends one SMS via SNS, and writes the result to mctr-calls (state texted) and mctr-audit.
  • mctr-router — SQS-triggered on routing jobs. Matches the reply to its open call in mctr-calls, appends to mctr-threads, handles STOP by writing mctr-optout, and assigns or escalates by keyword-and-recency triage.
  • mctr-escalator — builds the handover (call + matched customer + text-back + reason), de-duplicates against open threads, and emails the support inbox via SES.
  • mctr-drive-sync — scheduled. Pulls the customer sheet from Drive and upserts rows into mctr-customers.
  • mctr-sweep — scheduled. Queries mctr-calls for texted calls past the no-reply window with no reply and not yet escalated, hands each to mctr-escalator, and marks the call escalated so it’s only flagged once.

Data stores, schedules, and messaging

  • DynamoDB (all on-demand). mctr-calls — PK phone (E.164), SK call_ts; one item per missed call with its dedup marker, text-back, and state (texted / replied / escalated), queried newest-first to match replies. mctr-threads — PK thread_id, the conversation and assignment state. mctr-customers — PK phone, the directory mirrored from the sheet. mctr-optout — PK phone, the STOP suppression list checked before every send. mctr-audit — PK call_id, SK ts, append-only, holding each text-back and the facts it was built from.
  • Function URL. One, on mctr-webhook, with provider signature verification in-function; AuthType NONE at the edge because authenticity is enforced by the shared secret, not by IAM. No API Gateway.
  • SNS and SES. SNS sends the outbound text-back (or the provider’s own SMS API does, if you route SMS through them); SES sends escalation and handover email to the team from a verified domain with DKIM.
  • EventBridge Scheduler. Two rules — mctr-sweep at rate(15 minutes) gated to opening hours, and mctr-drive-sync at rate(15 minutes).
  • Secrets Manager. Two secrets — the webhook signing secret and the SMS/provider API key — fetched at call time, never in env vars or the sheet.
  • Bedrock. Model id anthropic.claude-haiku-4-5 via the Global cross-Region inference profile, invoked only by mctr-replier.

IAM scope and region

Each function gets its own execution role scoped to exactly what it touches, no wildcards. mctr-webhook can read mctr-customers and mctr-optout, conditionally write mctr-calls, read the signing secret, and send to mctr-jobs — it cannot call Bedrock or SNS. mctr-replier is the only role with bedrock:InvokeModel, scoped to the one Haiku profile; it can publish to SNS and write mctr-calls and mctr-audit, but cannot delete from any table. mctr-router can write mctr-threads and mctr-optout and invoke the escalator, nothing more. mctr-escalator can send via SES and read its inputs only. The scheduled functions hold the narrow Drive and table permissions they need and no inbound surface at all. 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. 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.

Design rules that shaped the build

  • One job per function. Six small Lambdas beat one that does everything; the queue decouples the slow calls from the webhook.
  • One public surface. Only mctr-webhook is reachable from outside, on a single Function URL, authenticated by a shared secret.
  • Least privilege, per role. Only the replier can call Bedrock and SNS; only the webhook and router touch the opt-out list.
  • State in DynamoDB. Tables for calls, threads, customers, opt-out, and audit; the calls table’s state field drives the sweep.
  • One region, one model. eu-west-2 throughout; Bedrock Haiku 4.5 via Global inference, called once per text-back.
  • A budget alarm is a smoke detector. With SMS the variable line, a Budgets alert is the cheapest way to catch a runaway.
All posts