Engineering reference: the chargeback responder architecture
Same system, drawn for engineers. Region, service names, resource identifiers, the Bedrock model id, the Lambda inventory, IAM scopes, the SES configuration, the two Function URLs, the EventBridge dispute rule and the one-off Scheduler deadline watch, and the DynamoDB schemas for the disputes, the evidence index, and the deadlines. Read it alongside the previous six posts — this one is the build sheet.
Region and account shape
Default region: eu-west-2 (London). Lambda Function URLs, EventBridge Scheduler, SES outbound, DynamoDB, S3, and Bedrock cross-Region inference are all in good shape there, and it keeps the data close to a UK or EU customer base. A second region for multi-region resilience isn’t worth the extra work at SMB volume — the failure mode for a chargeback responder is a single dispute taking an hour longer to file, and the deadline guard already gives a day of slack, so a regional blip never causes a missed deadline. One AWS account dedicated to the responder 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.
cbr-webhook— Lambda Function URL,AuthType: NONE; authenticated by the processor’s signature. Verifies the HMAC over the raw body using the signing secret from Secrets Manager and checks the timestamp window. Oncharge.dispute.created: de-duplicates with a conditionalPutItemoncbr-disputes, recordsdue_byand the reason code incbr-deadlines, creates a one-off EventBridge Scheduler rule targetingcbr-deadline-guard, emitscbr.dispute.received, and returns200. Oncharge.dispute.closed: records won/lost on the case and deletes any remaining Scheduler rule. Memory: 256 MB. Timeout: 15 s.cbr-gather— SQS trigger oncbr-work. Reads the dispute, then pulls the four sources targeted by the reason code: the order from the commerce platform, proof of delivery from the carrier API (key in Secrets Manager), the customer’s messages from the support inbox/chat, and the accepted policy snapshot. Writes each raw piece tos3://cbr-evidence/<dispute_id>/and an index row to thecbr-evidencetable with a found/missing flag; a not-yet-available delivery confirmation is recorded for re-gather, not treated as an error. Enqueues assembly. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.cbr-assemble— triggered after gather. Reads the evidence index and the reason-code playbook froms3://cbr-evidence/playbook.txt, pulls the cited pieces from S3, and makes one Bedrock Haiku 4.5 call (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) returning structured JSON: cover narrative, cited rebuttal points, exhibit list, winnability verdict. Drops any claim whose cited evidence id is absent or marked missing, renders the packet PDF (and the field-and-attachment form) to S3, and sets the casereadyorrecommend_accept_loss. Emails the owner via SES. In auto-file mode, astrongcase is handed straight tocbr-file. Memory: 1024 MB. Timeout: 60 s.cbr-file— submits the assembled evidence through the processor’s evidence API using the key from Secrets Manager. Idempotent: a conditional update moves the case fromreadytofiledonly once, so the owner’s tap and the guard can never double-file. Writes the processor’s submission id and timestamp tocbr-disputesand deletes the Scheduler rule. Memory: 256 MB. Timeout: 30 s.cbr-deadline-guard— EventBridge Scheduler target, one per dispute, fired a safe margin (default 24 h) beforedue_by. Reads case state: iffiledoraccept_loss, does nothing; ifweakand auto-file off, raises a last-call alert only; otherwise triggers a re-gather of any pending piece and callscbr-fileon the assembled packet. Memory: 256 MB. Timeout: 60 s.cbr-ack— Lambda Function URL,AuthType: NONE; verifies a signed, single-use token (HMAC over(dispute_id, action, nonce, expiry)keyed from Secrets Manager) so a forwarded email link can’t be replayed. On File: callscbr-file. On Hold: setsheldwithout disarming the guard. On Accept loss: setsaccept_lossand deletes the Scheduler rule. Always writes the action tocbr-disputes. Memory: 256 MB. Timeout: 15 s.cbr-summary— EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’s disputes and outcomes; calls Bedrock Haiku 4.5 to write a one-paragraph narrative (disputes received, value defended, win rate by reason code, recommended-accept-loss count); emails it via SES to the owner. Memory: 512 MB.
Storage
- DynamoDB ·
cbr-disputes— one row per dispute. PKdispute_id; attributes:processor,charge_id,reason_code,amount,currency,customer,state(received/gathering/assembled/ready/held/filed/accept_loss/won/lost),winnability,submission_id,created_at,filed_at. On-demand. - DynamoDB ·
cbr-evidence— the evidence index, one row per gathered piece. PKdispute_id; SKevidence_type(order/delivery/comms/policy); attributes:s3_key,source,gathered_at,status(found/missing/pending),note. On-demand. - DynamoDB ·
cbr-deadlines— one row per dispute deadline. PKdispute_id; attributes:due_by,safety_cutoff,schedule_arn,filed(bool). On-demand. The authoritative record the guard checks against. - S3 ·
cbr-evidence— one prefix per dispute holding the raw gathered evidence (order JSON, carrier tracking, message exports, policy snapshot) and the compiled packet PDF, plus the editableplaybook.txtat the bucket root. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 7 years for the audit trail.
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:cbr-assemblefor the rebuttal, andcbr-summaryfor the monthly narrative. Neithercbr-webhook,cbr-gather,cbr-file, norcbr-deadline-guardever calls Bedrock — every money or deadline decision is deterministic. - Grounding. The assemble prompt carries only the gathered evidence with its index ids; the renderer drops any claim citing an absent or missing id. The model cannot introduce evidence, only arrange what was gathered.
- Heavier model.
anthropic.claude-sonnet-4-6-...is configured but off by default; worth switchingcbr-assembleto Sonnet only for high-value disputes where a more careful narrative is justified, via a per-dispute amount threshold. - Embeddings. Not used. There’s no corpus to search — each dispute’s evidence is gathered fresh. No Knowledge Base, no S3 Vectors.
EventBridge and Scheduler config
- Dispute rule — an EventBridge rule on the default bus matching
cbr.dispute.received, target thecbr-workSQS queue (which triggerscbr-gather). - Deadline watch — one-off Scheduler rules created by
cbr-webhook, one per dispute, with anat(YYYY-MM-DDTHH:MM:SS)expression set todue_byminus the safety margin, targetcbr-deadline-guard, and--action-after-completion DELETEso the rule self-cleans. Deleted early bycbr-fileorcbr-ackwhen the case is filed or accept-loss. cbr-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:cbr-summaryLambda.
SES and the two Function URLs
- SES outbound only. Verify a sender identity at
disputes@your-company.comwith DKIM and SPF on the parent domain; out of sandbox by request. Used for the per-dispute owner alert and the monthly summary. There is no SES inbound — disputes arrive by webhook, not email. - Webhook Function URL —
cbr-webhook, public, signature-checked inside the function. This is the address registered in the processor’s webhook settings. - Ack Function URL —
cbr-ack, public, signed single-use token in each button link. The owner’s email renders File, Hold, and Accept-loss as links to this URL.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- cbr-webhook role:
dynamodb:PutItem/UpdateItemoncbr-disputesandcbr-deadlines;scheduler:CreateSchedule/DeleteSchedule;events:PutEventson the default bus;secretsmanager:GetSecretValueon the webhook signing secret. Nobedrock:*. - cbr-gather role:
sqs:ReceiveMessage/DeleteMessageoncbr-work;s3:PutObjectoncbr-evidence;dynamodb:PutItemon thecbr-evidencetable;secretsmanager:GetSecretValueon the carrier key; outbound network to the carrier, commerce, and inbox hosts. Nobedrock:*. - cbr-assemble role:
s3:GetObject/PutObjectoncbr-evidence;dynamodb:Queryon the evidence index;bedrock:InvokeModelon the Haiku ARN;ses:SendEmailfrom the verified sender;secretsmanager:GetSecretValueon the ack-link signing secret. - cbr-file role:
dynamodb:UpdateItemoncbr-disputesandcbr-deadlines;scheduler:DeleteSchedule;s3:GetObjecton the packet;secretsmanager:GetSecretValueon the processor API key. - cbr-deadline-guard role: the
cbr-filepermissions plusdynamodb:GetItemon the deadlines and disputes tables, and permission to re-invokecbr-gather. - cbr-ack role:
dynamodb:UpdateItemoncbr-disputes;scheduler:DeleteSchedule; permission to invokecbr-file;secretsmanager:GetSecretValueon the ack-link signing secret.
Idempotency and the deadline invariant
Two invariants hold the system together. First, a dispute is filed at most once: filing is a conditional state transition from ready to filed, so the owner’s tap, an auto-file, and the deadline guard all race safely — whoever wins moves the state, the others find it already filed and stop. Second, a winnable dispute is never filed late: the one-off Scheduler rule is armed before any slow work runs and is only deleted when the case is genuinely filed or deliberately dropped, so there is no path where a case is forgotten. The guard fires on a timer that AWS owns, independent of whether any earlier step succeeded.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"throttle"+"timeout"to a metric for alerting. - SQS DLQ:
cbr-workhas a dead-letter queue; a gather that fails twice lands there with an alarm, so a flaky carrier API never silently blocks a case from being assembled in time. - Alarms: any case within 12 h of
due_bystill inready(the guard should have it, but page anyway); DLQ depth > 0;cbr-filefailures > 0;cbr-webhooksignature-verification failures > 5/hour. - X-Ray: off by default. Not worth the cost at SMB volume.
- AWS Budgets: $25/month threshold, alarm at 80% and 100%, posts to SNS topic
cbr-cost-alarmsubscribed to the owner’s email.
Config and secrets
The processor API key and webhook signing secret live in Secrets Manager under cbr/processor/*; the carrier API key under cbr/carrier/key; the ack-link signing secret under cbr/links/secret. The mode (auto-file vs owner-approval), the safety-margin hours, the high-value Sonnet threshold, and the owner’s email live in Parameter Store under /cbr/config/. The reason-code playbook is plain text in S3 so it can be tuned without a deploy. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.
Deploy
GitHub Actions with OIDC into a deploy role (no long-lived keys) and AWS SAM. The opinionated bits: turn on S3 versioning for cbr-evidence so a packet is always reproducible and a bad playbook edit rolls back in one click; give cbr-gather an SQS source with a DLQ so one unreachable carrier never wedges the pipeline; and keep the deadline guard’s schedule creation in cbr-webhook so the safety net is armed at the earliest possible moment. Total deployable surface: seven Lambdas, three DynamoDB tables, one S3 bucket, one SQS queue with a DLQ, one EventBridge rule on the default bus (plus the per-dispute Scheduler rules), two Function URLs, an SES sender 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