Engineering reference: the dunning recovery architecture
Same system, drawn for engineers. Region, service names, resource identifiers, the Bedrock model id, the Lambda inventory, IAM scopes, the SES configuration set, the EventBridge Scheduler rules, the DynamoDB schemas, and the idempotency design that stops a double-charge. Read it alongside the previous six posts — this one is the build sheet.
Key takeaways
- One region throughout: eu-west-2. One AWS account dedicated to the system keeps the IAM blast radius small.
- Seven Lambdas, three DynamoDB tables, one S3 bucket, one SQS queue with a DLQ, one SES configuration set.
- EventBridge Scheduler drives one-off retries (self-deleting) plus a daily sweep and a monthly report.
- The double-charge guard is a deterministic idempotency key on the invoice-and-attempt, passed to the processor.
- Bedrock Haiku 4.5 has exactly two callsites — the mailer and the monthly summary — never the money path.
Region and account shape
Default region: eu-west-2 (London). SES outbound, Bedrock cross-Region inference, EventBridge Scheduler, and Lambda Function URLs are all in good shape there, and it keeps customer data in-region for a UK/EU subscriber base. A second region for resilience isn’t worth the setup at this volume: the failure mode is a retry firing an hour late, not a regional outage, and because every charge is idempotent there is no in-flight payment to protect across a failover. One AWS account dedicated to dunning recovery (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.
Topology
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.
dunr-webhook— Lambda Function URL, public withAuthType: NONE; security comes from HMAC signature verification, not network auth. Verifies the processor signature againstdunr/stripe/webhook-secret, rejects stale timestamps (replay window 5 min), de-duplicates by event id with a conditionalPutItemondunr-dedupe, writesdunr-dunning, and sends todunr-events. Routes the four event types (failed / succeeded / payment-method-updated / subscription-cancelled). Returns200in milliseconds. Memory: 256 MB. Timeout: 10 s.dunr-scheduler— SQS trigger ondunr-events. Computes the business-day backoff in plain Python (next business day, then +3 business days ×3, weekends skipped; count and gaps from config), creates one-off EventBridge Scheduler rulesdunr-retry-{sub}-{n}withat(...)expressions and--action-after-completion DELETE, and triggers the first dunning email. Memory: 256 MB. Timeout: 30 s. No Bedrock.dunr-retrier— EventBridge Scheduler target, one invocation per retry. Reads the live invoice from the processor (API key indunr/stripe/api-key) and attempts the charge with a deterministic idempotency keydunr-{invoice}-a{n}. On success: marksdunr-dunningrecovered, deletes remainingdunr-retry-{sub}-*rules, restores access, fires a receipt email. On failure: advances the attempt or, if final, sets paused. Memory: 256 MB. Timeout: 30 s. No Bedrock.dunr-mailer— invoked per attempt by scheduler/retrier. Reads the template, logo, and tone brief froms3://dunr-assets/, makes one Bedrock Haiku 4.5 call for subject and body copy (falls back to a plain template on error), builds the signed single-use update-card link (HMAC fromdunr/links/secret), and sends via SES with configuration setdunr-mail. Memory: 512 MB. Timeout: 30 s.dunr-gatekeeper— Lambda Function URL, public withAuthType: NONEbut called with a short app-issued bearer token. Single keyed read ofdunr-dunning; returns granted for active/retrying/recovered and paused for paused. Also exposes the internal pause/restore writes used by the retrier. Single-digit-ms responses; the app caches for 1–2 min. Memory: 256 MB. Timeout: 5 s.dunr-feedback— SNS trigger from the SES configuration set. Writes hard-bounce and complaint addresses to the suppression list so future sends skip them. Memory: 256 MB. Timeout: 10 s.dunr-sweep— EventBridge Scheduler target, daily at 9am local. Reconciles state: catches a retry whose schedule never fired (re-creates it), re-pings paused subscriptions that have a stale card, and closes rows past a configurable abandonment horizon. No Bedrock; a plain pass. Memory: 256 MB.dunr-summary— EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month ofdunr-log; one Bedrock Haiku 4.5 call writes a short narrative (charges recovered, value saved, recovery rate by failure code); emails it via SES and writes it tos3://dunr-assets/reports/. Memory: 512 MB.
Storage
- DynamoDB ·
dunr-dunning— one row per subscription in or recently out of dunning. PKsubscription_id; attributes:customer_id,status(active/retrying/paused/recovered/cancelled),attempt_no,max_attempts,next_retry_at,invoice_id,failure_code,access(granted/paused),schedule_names,entered_dunning_at. On-demand. - DynamoDB ·
dunr-log— one row per attempt or state change. PKsubscription_id; SKts(ISO-8601 + ULID suffix). Attributes:event(charge_failed/retry_attempted/retry_succeeded/email_sent/access_paused/access_restored/card_updated/cancelled),attempt_no,result,failure_code,before,after. On-demand. No TTL — this is the long-term audit trail. - DynamoDB ·
dunr-dedupe— one row per processed webhook event id, for idempotency at the front door. PKevent_id; attributeseen_at. TTL 30 days. On-demand. - S3 ·
dunr-assets— email HTML template, logo, tone brief, and the monthly reports underreports/. Versioning enabled so a bad template edit rolls back in one click. No public access.
Bedrock
- Foundation model.
anthropic.claude-haiku-4-5-20251001-v1:0via the Global cross-Region inference profileglobal.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites only:dunr-mailerfor the dunning-email copy, anddunr-summaryfor the monthly narrative. No model is ever on the schedule, charge, pause, or restore path. - Embeddings / Knowledge Base. Not used. There is no corpus to retrieve over — the state is a handful of structured rows.
EventBridge Scheduler config
- One-off retry rules — created by
dunr-scheduler, nameddunr-retry-{sub}-{n},at(YYYY-MM-DDTHH:MM:SS)expressions in the configured TZ, targetdunr-retrier, with--action-after-completion DELETEso each self-cleans. Deleted en masse by name prefix when a retry succeeds. dunr-daily-sweep—cron(0 9 * * ? *)in TZ. Target:dunr-sweep.dunr-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:dunr-summary.
SES (outbound only)
- Verify a sender identity at
billing@your-company.comwith DKIM and SPF on the parent domain; out of sandbox by request. No inbound rule set — this system only sends. - Configuration set
dunr-mailwith event publishing to an SNS topic forBounceandComplaint;dunr-feedbackconsumes it and maintains the suppression list. Sends check the suppression list first. - One template per escalation level lives in
s3://dunr-assets/; the rendered subject/body come from Bedrock at send time, with the template as the deterministic fallback.
IAM (least privilege per Lambda)
Each Lambda has its own role scoped to exact ARNs. Sketch:
- dunr-webhook role:
secretsmanager:GetSecretValueon the webhook secret;dynamodb:PutItemondunr-dedupeanddunr-dunning;sqs:SendMessageondunr-events. No processor API key — it never charges. - dunr-scheduler role:
sqs:ReceiveMessage/DeleteMessageondunr-events;scheduler:CreateSchedule/DeleteScheduleon thedunr-retry-*name prefix;dynamodb:UpdateItemondunr-dunning;lambda:InvokeFunctionondunr-mailer. Nobedrock:*. - dunr-retrier role:
secretsmanager:GetSecretValueon the processor API key;dynamodb:UpdateItemondunr-dunningandPutItemondunr-log;scheduler:DeleteScheduleon thedunr-retry-*prefix;lambda:InvokeFunctionondunr-mailer. The only role that can move money. - dunr-mailer role:
s3:GetObjectondunr-assets;bedrock:InvokeModelon the Haiku ARN;ses:SendRawEmailfrom the verified sender;secretsmanager:GetSecretValueon the link-signing secret. - dunr-gatekeeper role:
dynamodb:GetItemondunr-dunning, andUpdateItemscoped to the access attribute. No secrets, no Bedrock, no charging.
The idempotency design (why it can’t double-charge)
Two independent guards make a double-charge impossible. At the front door, dunr-webhook de-duplicates webhook events by id on a conditional write, so a replayed or re-delivered event is a no-op. At the charge, dunr-retrier passes the processor a deterministic idempotency key — dunr-{invoice}-a{n}, built from the invoice id and attempt number, never random. If EventBridge Scheduler fires the same retry twice, or a manual card update overlaps a scheduled retry, both requests reconstruct the same key, and the processor collapses the second into the first without taking money. The key is keyed on the attempt, not the clock, precisely so duplicates converge.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Metric filter on
"error","throttle","signature_fail". - SQS DLQ:
dunr-eventshas a dead-letter queue; a message that fails twice lands there with an alarm, so a malformed event never silently blocks the pipeline. - Alarms: retrier failures > 0/hour; DLQ depth > 0; webhook signature failures > 5/hour (the secret may have rotated); SES complaint rate over threshold.
- AWS Budgets: $10/month threshold, alarm at 80% and 100% to an SNS topic
dunr-cost-alarm.
Deploy
GitHub Actions with OIDC into a deploy role (no long-lived keys) and AWS SAM. The opinionated bits: turn on S3 versioning for dunr-assets so a bad template edit rolls back in one click; give dunr-scheduler an SQS source with a DLQ so a single bad event never wedges the pipeline; and scope the processor API key to the retrier role alone, so it is the one and only function that can charge a card. Total deployable surface: seven Lambdas, three DynamoDB tables, one S3 bucket, one SQS queue with a DLQ, one SES configuration set, two recurring EventBridge schedules (plus the one-off retry rules created at runtime), 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