Skip to content

jeffgrosse/aws-serverless-lead-capture

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

2 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

aws-serverless-lead-capture

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]
Loading

See docs/ARCHITECTURE.md for the full request-flow sequence diagram (including the idempotency branches) and design rationale.

Idempotency

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-Key header. 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 200 with 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.

Prerequisites

  • An AWS account, and the AWS SAM CLI installed (sam --version; see AWS's install docs).
  • AWS credentials configured (aws configure, or the equivalent AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment 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.

Client integration

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 ['*'].

Deploy

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.

Verifying idempotency

verify_idempotency.py posts the same payload with the same Idempotency-Key twice against a deployed stack and asserts:

  • Both responses are 200 with an identical body.
  • Exactly one item lands in LeadsTable.
python3 verify_idempotency.py --stack-name aws-serverless-lead-capture

It 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.

Testing

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/ -v

Expected: 24 tests passing.

Known follow-ups

Tracked from an internal code review (2026-07-12), deferred to v0.2:

  • idempotency.py reserve() round-trip: a duplicate/retry request costs a conditional PutItem (fails) then a separate GetItem to fetch the existing record. Passing ReturnValuesOnConditionCheckFailure='ALL_OLD' to put_item would return the existing item directly from the failed write, removing the extra round-trip on the retry path specifically.
  • Duplicated boto3 client construction: lambda_function.py and idempotency.py each independently construct their own boto3.resource("dynamodb"). A shared construction point would remove the duplication without changing behavior.

Extending

  • Scoring logic: src/ingest/scorer.py is a worked example (rule-based heuristic), not part of the idempotency pattern. Replace score_lead() with your own logic or a model invocation β€” the {score, tier, factors} output contract is all lambda_function.py depends on.
  • Storage: swap LeadsTable's single-table design in template.yaml for whatever schema your use case needs; the idempotency layer doesn't care what happens between "reserve" and "complete".

License

MIT β€” see LICENSE.

About

SAM template for a serverless lead capture form endpoint. Ingests via API Gateway + Lambda, fans out via SNS so you can add subscribers (email, Slack, CRM, analytics) without touching the ingest Lambda.

Topics

Resources

License

Stars

1 star

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages