Case study
AWS Claims Pipeline
A serverless claims-intake pipeline defined entirely in Terraform, API Gateway to Lambda to SQS to DynamoDB, deployed for real to AWS with least-privilege IAM, exercised end to end, and then destroyed with the evidence captured.
- infra
- AWS
- Terraform
- Role
- Solo builder
- Timeframe
- 2026
Stack
- Terraform
- AWS API Gateway (HTTP API)
- AWS Lambda (Python)
- Amazon SQS
- Amazon DynamoDB
- IAM (least privilege)
- CloudWatch Logs
On this page
Problem
The interesting part of a claims-intake system is not the adjudication rule, it is the shape of the plumbing. Intake has to accept a claim and respond immediately, processing has to happen asynchronously so a slow or failing worker never blocks or drops an incoming claim, and every component has to hold only the permissions it actually needs. AWS Claims Pipeline is a weekend-scale build of exactly that shape: a synthetic insurance claim submitted over HTTP, recorded, queued, adjudicated asynchronously, and made queryable by id, with the whole thing defined in Terraform, deployed for real, and destroyed afterward.
Why it mattered
I built this to close a specific gap honestly. I could write Terraform and describe a serverless architecture, but I had not applied that configuration against a live account and verified the result end to end. The claim I wanted to be able to make is the applied one: I stood up a multi-service AWS stack from infrastructure-as-code, drove real HTTP traffic through it, confirmed the asynchronous path, tore it down, and verified nothing was left running or billing. The dead-letter queue was configured and bounded to three receives, though no poison message was driven in this run, so its routing is wired rather than exercised. Deploying for real and then destroying, with the apply, demo, scan, and destroy output captured, is what separates that from a diagram.
Architecture
- A client POSTs a JSON claim to an API Gateway HTTP API, which routes it to an intake Lambda. The intake Lambda writes the claim to DynamoDB as RECEIVED and sends it to an SQS queue, then returns an id immediately without waiting on processing.
- A worker Lambda is wired to the queue by an event source mapping. It applies a synthetic adjudication rule (auto-approve at or under $500, otherwise mark for review at 80% of the billed amount) and updates the DynamoDB record to PROCESSED with a decision and payout.
- A status Lambda serves GET requests by id, reading the current record from DynamoDB.
- Each Lambda has its own IAM role scoped to exactly what it needs: intake can only PutItem and SendMessage, worker can only consume the queue and UpdateItem, status is read-only with GetItem.
- Messages that fail processing three times land in a dead-letter queue instead of retrying forever. DynamoDB runs in on-demand mode, so idle cost is zero.
- All of it, 27 resources across API Gateway, Lambda, SQS, DynamoDB, IAM, and CloudWatch, is defined in Terraform split by service.
Technical decisions
A queue between intake and processing instead of synchronous adjudication. Alternative: adjudicate inside the intake request and return the decision in the same call. That couples intake latency and availability to the worker, so a slow or failing adjudicator degrades or drops incoming claims. The SQS queue decouples them: intake writes, enqueues, and returns, and the worker drains the queue on its own schedule.
A separate least-privilege IAM role per Lambda instead of one shared role. Alternative: give all three functions a single role that covers every action. A shared role means the read-only status endpoint can also write and enqueue, which is exactly the blast radius you do not want. Each function instead gets only its own verbs, so a bug or compromise in one path cannot act as another.
A dead-letter queue with a bounded receive count instead of unbounded retries. Alternative: let SQS redeliver a failing message indefinitely. A poison message would then loop forever, burning invocations and hiding the failure. Capping redelivery at three and routing the message to a DLQ makes a stuck claim visible and stops the loop.
Deploy for real and destroy, instead of validating the plan only. Alternative: stop at terraform plan and a passing configuration. A plan proves the config parses, not that the services actually wire together or that the async path completes. Applying against a live account, driving real HTTP traffic through it, and then destroying with CLI-verified teardown is the only thing that proves the architecture end to end.
Interesting challenges
The most satisfying part to verify was the asynchronous path, because it is the thing a synchronous design hides. A GET issued immediately after the POST returned the claim still in RECEIVED, and a GET a few seconds later returned it PROCESSED with the adjudicated amount, with an observed intake-to-processed latency of roughly 15 to 20 seconds from the SQS batching window plus a Lambda cold start. Watching RECEIVED become PROCESSED with no synchronous call between them is the whole design working as intended.
The teardown was treated as a first-class result rather than an afterthought. Terraform reported 27 resources destroyed, and then a set of AWS CLI queries confirmed no DynamoDB tables, no SQS queues, no Lambda functions, no API, no IAM roles, and no log groups matching the stack remained. That verification is what lets me claim the deploy was real without leaving anything billing.
Results
| Metric | Value | Qualifier |
|---|---|---|
| Resources deployed then destroyed | 27 | us-east-1; apply reported 27 added, destroy reported 27 destroyed |
| Post-destroy verification | zero residue | six AWS CLI queries across every service returned empty |
| End-to-end demo | 5 synthetic claims | all reached PROCESSED, adjudication applied correctly, SQS drained, DLQ empty |
| IAM roles | 3, one per Lambda | intake write plus enqueue, worker consume plus write, status read-only |
| Total cost incurred | under $0.01 | serverless free-tier, DynamoDB on-demand, roughly 30 Lambda invocations |
Scope and honest limits
This is a demo, not production. There is no custom domain, no TLS on a custom domain, no authentication or authorization, no WAF, no request-validation models, no X-Ray tracing, no remote state backend, and no CI/CD wiring, and none of those are claimed. The adjudication is a two-line synthetic rule, a threshold plus a flat rate, not a real claims engine. All data is synthetic: the member, provider, and procedure-code values in the payloads are made up, and there is no real patient or claims data anywhere in the repo. Terraform state is gitignored because it contains the account id, so only the configuration, handlers, docs, and the provider lock file are committed. The whole exercise ran inside the AWS always-free tier at free-tier scale. The dead-letter queue is configured with a bounded receive count and appears in the resource inventory, but the demo drove only valid claims, so no message ever landed in it; its routing is wired, not exercised.
Lessons
Applying is a different activity from planning, and only applying proves the architecture. The Terraform plan was clean well before the async path had ever actually run, and it was the live end-to-end exercise, not the plan, that gave me the right to describe the system as working.
Teardown verification is part of the deliverable, not cleanup. Reporting "27 destroyed" is not the same as confirming it, and the value of the CLI sweep is that it turns "I destroyed it" into something checkable, which is exactly the discipline a real account owner wants from anyone who provisions in their environment.
Future work
- Drive a poison message through the pipeline on a future deploy and capture it landing in the dead-letter queue, so the DLQ claim graduates from wired to exercised.
- Add request-validation models and authentication so the intake path rejects malformed and unauthorized claims before they reach a Lambda.
- Wire the apply, demo, scan, and destroy sequence into CI against a throwaway account, so the end-to-end proof runs on every change rather than once by hand.
- Move Terraform state to a remote backend with locking, which the current single-operator demo intentionally omits.
Want the walkthrough, or the parts that didn't work?
The fastest way to reach me is email. I respond within a day.