A reusable AWS SAM pattern for a serverless form-ingest pipeline: HTTP API β Lambda β DynamoDB (storage) + SNS (fan-out notification) β with idempotency built in, not bolted on. Extracted and generalized from a production lead capture form.
flowchart LR
Client([Client]) -->|POST /lead| Lambda[Ingest Lambda]
Lambda --> Leads[(LeadsTable)]
Lambda --> SNS{{LeadsTopic}}
SNS --> Email[Email subscriber]
SNS --> Custom[Your subscribers]
See docs/ARCHITECTURE.md for the full request-flow sequence diagram (including the idempotency branches) and design rationale.
Duplicate form submissions β double-clicks, mobile-client retries after a dropped connection, an aggressive-retry frontend β should not fan out twice. This pattern solves that at the ingest layer:
- The Lambda expects an
Idempotency-Keyheader. Any stable string works: a UUID generated per submission attempt, a form-session ID, a hash the caller already computed. - If the header is absent, the Lambda falls back to a SHA256 hash of the request body, so callers that don't send the header still get exact-duplicate protection.
- The key is reserved in DynamoDB with a conditional write
(
ConditionExpression: attribute_not_exists(idempotency_key)) before the lead is stored or the SNS notification is published β this is what closes the race window for two near-simultaneous duplicate requests, not just sequential retries. - On a duplicate: the Lambda returns
200with the original response (idempotent retry contract β same request, same result, no second fan-out) instead of erroring. - Idempotency records TTL out of DynamoDB after 24 hours (configurable), so there's no cleanup job to run or table to prune.
Full write-up β including why the reservation happens before processing rather than after, what happens for genuinely concurrent duplicates, and why this doesn't use SNS FIFO message-deduplication IDs β is in docs/ARCHITECTURE.md.
- An AWS account, and the AWS SAM CLI installed
(
sam --version; see AWS's install docs). - AWS credentials configured (
aws configure, or the equivalentAWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYenvironment variables). - IAM permissions to create the resources this template deploys (Lambda, DynamoDB, API Gateway, SNS, and an execution role) β see AWS's SAM deployment permissions guidance if deploying under a restricted IAM policy.
- Python 3.12 (matches the Lambda runtime).
- To run the test suite locally (no AWS account needed for this part):
pip install -r requirements-dev.txt. See Testing.
Minimal example: a form that POSTs to the deployed endpoint with a fresh idempotency key per submission attempt.
<form id="lead-form">
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<input name="utm_source" type="hidden" value="landing-page" />
<button type="submit">Submit</button>
</form>
<script>
document.getElementById("lead-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.target));
try {
const res = await fetch("https://YOUR_API_ID.execute-api.YOUR_REGION.amazonaws.com/lead", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify(data),
});
if (!res.ok) throw new Error(`Request failed: ${res.status}`);
console.log("Lead submitted:", await res.json());
} catch (err) {
console.error("Submission failed:", err);
}
});
</script>name and email are the fields lambda_function.py reads directly;
utm_source shows that extra fields pass through the request body without
error, for whatever you extend the handler to do with them later. Call
crypto.randomUUID() once per submission attempt, not once per page load β
that's what lets a genuine resubmission (user edits the form and submits
again) go through instead of being deduplicated against the first attempt.
Before deploying to production, set CorsConfiguration.AllowOrigins in
template.yaml to your frontend's origin instead of ['*'].
Deployable in any AWS region β there's no CloudFront/ACM region constraint here.
sam build
sam deploy --guided--guided will prompt for:
| Parameter | Example |
|---|---|
NotificationEmail |
you@example.com |
IdempotencyTtlHours |
24 |
Answers are saved to samconfig.toml (gitignored β see
samconfig.toml.example for the format if you'd rather write it by hand and
skip --guided on subsequent deploys).
After deploy, confirm the SNS email subscription β check
NotificationEmail's inbox for a "AWS Notification - Subscription
Confirmation" email and click the link. CloudFormation can create the
subscription but can't auto-confirm it.
verify_idempotency.py posts the same payload with the same
Idempotency-Key twice against a deployed stack and asserts:
- Both responses are
200with an identical body. - Exactly one item lands in
LeadsTable.
python3 verify_idempotency.py --stack-name aws-serverless-lead-captureIt reads the API endpoint and table name from the stack's CloudFormation
outputs, so it only needs the stack name (defaults to
aws-serverless-lead-capture) and your normal AWS credentials/region.
Unit and end-to-end tests run against a moto-mocked DynamoDB and SNS β no AWS credentials or deployed stack required.
pip install -r requirements-dev.txt
pytest tests/ -vExpected: 24 tests passing.
Tracked from an internal code review (2026-07-12), deferred to v0.2:
idempotency.pyreserve()round-trip: a duplicate/retry request costs a conditionalPutItem(fails) then a separateGetItemto fetch the existing record. PassingReturnValuesOnConditionCheckFailure='ALL_OLD'toput_itemwould return the existing item directly from the failed write, removing the extra round-trip on the retry path specifically.- Duplicated
boto3client construction:lambda_function.pyandidempotency.pyeach independently construct their ownboto3.resource("dynamodb"). A shared construction point would remove the duplication without changing behavior.
- Scoring logic:
src/ingest/scorer.pyis a worked example (rule-based heuristic), not part of the idempotency pattern. Replacescore_lead()with your own logic or a model invocation β the{score, tier, factors}output contract is alllambda_function.pydepends on. - Storage: swap
LeadsTable's single-table design intemplate.yamlfor whatever schema your use case needs; the idempotency layer doesn't care what happens between "reserve" and "complete".
MIT β see LICENSE.