Build an AWS Event Lead Pipeline

Build a secure event lead form with AWS serverless fan-out and DynamoDB.

Introduction

30 Second Summary

An event inquiry can arrive in seconds. Manually copying it between Sales and Event Operations makes valuable leads easy to lose.

In this project, you will build an event inquiry site on AWS that captures every request through a secure web form. Each lead travels through an event-driven pipeline to two team inboxes plus a searchable database.

What You'll Build

You'll watch one booking request travel from an HTTPS form into separate Sales and Event Operations inboxes before appearing as a searchable lead record.

By the end of this project, you'll have:

  • A secure lead form that visitors can complete online, with a generated lead ID confirming each accepted inquiry.
  • A traceable fan-out flow where one archived lead produces matching messages for Sales and Event Operations.
  • A searchable lead record in Amazon DynamoDB that converges on one item when the same object event arrives more than once.
  • Secret Mission: Add an optional budget field that flows through the existing pipeline without changes to downstream consumers.

Do I need anything before starting?

You need an AWS account with permission to create the cloud resources used in this project. You also need AWS CLI v2 plus Visual Studio Code on a Mac.

Before We Start

This is your moment to define what Gather & Co. is building before the hands-on work begins. You will connect each event inquiry to two independent outcomes: Sales follows up on the commercial opportunity. Event Operations plans the delivery details.

Set Up the Project Workspace

This pipeline eventually creates several connected cloud resources. A known identity protects you from deploying them to the wrong account. A fixed Region keeps every service in the same place.

You will verify AWS CLI v2. You will then prepare the complete project workspace in Visual Studio Code so every infrastructure change stays reviewable.

In this step, get ready to:
  • Verify AWS CLI v2 and the active AWS identity.
  • Create the project folder and required file structure.
  • Populate the website and infrastructure files with the project code.
Verify your AWS CLI and identity

The AWS CLI sends commands using the credentials active on your Mac. Checking its major version first prevents an older installation from causing confusing differences later.

  • Press Cmd+Space to open Spotlight Search.
  • Type Terminal and press Return to open it.
  • Check the installed AWS CLI version by running this command:
aws --version

✔️ I see version 2 or higher

Your output starts with aws-cli/2. AWS CLI v2 is ready to use.

ⓧ I see version 1

Your Mac is finding AWS CLI v1. Install AWS CLI v2 before continuing so this project uses the current major version.

  • Download the official AWSCLIV2.pkg installer.
  • Run the downloaded installer by following its on-screen instructions.
  • Close Terminal after the installation finishes.
  • Open a fresh Terminal window through Spotlight Search.
  • Confirm AWS CLI v2 is active by running:
aws --version

You should now see output that starts with aws-cli/2.

ⓧ Command not found

AWS CLI is missing from your command path. Install the official macOS package before continuing.

  • Download the official AWSCLIV2.pkg installer.
  • Run the downloaded installer by following its on-screen instructions.
  • Close Terminal after the installation finishes.
  • Open a fresh Terminal window through Spotlight Search.
  • Confirm the installation by running:
aws --version

You should now see output that starts with aws-cli/2.

Still unable to run AWS CLI?

A terminal opened before the installation may not detect the new command. Close every Terminal window before trying again.

Use the official installation guide if your Mac still cannot locate AWS CLI.

Help me troubleshoot AWS CLI v2 installation on my Mac.

The next check reveals which account your credentials can access. This matters because every resource in the later deployment belongs to that account.

  • Check the active AWS identity in us-east-1 by running:
aws sts get-caller-identity --region us-east-1

What does this command confirm?

The command returns the active caller identity. Its account number and ARN identify the credentials that later deployment commands use.

Review these values before you create anything. This check prevents the stack from landing in an unintended account.

You should see the AWS account and ARN you intend to use. That identity is now the foundation for every deployment command in this project.

Unable to retrieve your identity?

Your AWS CLI may need an authenticated profile. Use the sign-in method approved for your AWS account before running the identity check again.

For long-term IAM user credentials, configure the CLI by running:

aws configure

For IAM Identity Center access, configure the sign-in by running:

aws configure sso

Help me authenticate AWS CLI on my Mac.

Create the project workspace

A predictable file layout separates the browser files from the cloud template. The site folder holds everything published to the website. The infrastructure.yaml file describes the AWS architecture.

  • Click the Desktop to make Finder active.
  • Press Shift+Command+N to create a folder on the Desktop.
  • Name the folder event-lead-pipeline and press Return.

You should see the new event-lead-pipeline folder on your Desktop.

  • Press Cmd+Space to open Spotlight Search.
  • Type Visual Studio Code and press Return to open it.
  • Select File from the menu bar.
  • Select Open Folder....
  • Choose the event-lead-pipeline folder from your Desktop.
  • Select Open.

You should see event-lead-pipeline at the top of the Explorer sidebar.

  • Select event-lead-pipeline in the Explorer sidebar.
  • Select the New Folder... button in the Explorer toolbar.
  • Name the folder site and press Return.

The Explorer should now show an empty site folder inside event-lead-pipeline.

  • Select the site folder in the Explorer sidebar.
  • Select the New File... button.
  • Name the file index.html and press Return.
  • Create styles.css in the same site folder with the New File... button.

You should see index.html and styles.css nested beneath site.

  • Create config.js inside the site folder with the New File... button.
  • Create app.js inside the site folder with the New File... button.

The site folder should now contain four empty files.

  • Select event-lead-pipeline at the top of the Explorer sidebar.
  • Select the New File... button.
  • Name the file infrastructure.yaml and press Return.

You should see infrastructure.yaml beside the site folder. The complete file structure is now visible in one workspace.

Populate and verify the project files

The browser files define the form and its submission behavior. The CloudFormation template keeps the later AWS deployment reproducible.

  • Select the second tab below to reveal the complete source files.
  • Copy each source block into the matching file shown in its heading.
  • Keep your-lambda-function-url-here unchanged in site/config.js.

✔️ Awesome, I've got everything!

Great. Save every file before completing the final checks below.

ⓧ I'd like to double check the full code

Check site/index.html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Gather & Co. Event Inquiry</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <main class="page-shell">
    <section class="intro-panel">
      <p class="eyebrow">Gather & Co.</p>
      <h1>Plan an event people remember.</h1>
      <p>Tell us what you are planning. Our Sales and Event Operations teams will coordinate the next steps.</p>
      <ul>
        <li>Private inquiry intake</li>
        <li>Fast team follow-up</li>
        <li>One request, coordinated response</li>
      </ul>
    </section>

    <section class="form-panel" aria-labelledby="form-title">
      <h2 id="form-title">Request event availability</h2>
      <form id="lead-form">
        <label>
          Name
          <input name="name" autocomplete="name" required maxlength="100">
        </label>

        <label>
          Email
          <input name="email" type="email" autocomplete="email" required maxlength="254">
        </label>

        <label>
          Phone
          <input name="phone" type="tel" autocomplete="tel" maxlength="40">
        </label>

        <label>
          Event type
          <select name="eventType" required>
            <option value="">Choose an event</option>
            <option>Wedding</option>
            <option>Corporate gathering</option>
            <option>Birthday</option>
            <option>Community event</option>
            <option>Other</option>
          </select>
        </label>

        <div class="field-row">
          <label>
            Event date
            <input name="eventDate" type="date" required>
          </label>
          <label>
            Estimated guests
            <input name="attendees" type="number" min="1" max="10000" required>
          </label>
        </div>

        <label>
          Notes
          <textarea name="notes" rows="4" maxlength="1000" placeholder="Venue ideas, schedule, catering, or accessibility needs"></textarea>
        </label>

        <label class="honeypot" aria-hidden="true">
          Company website
          <input name="companyWebsite" tabindex="-1" autocomplete="off">
        </label>

        <button id="submit-button" type="submit">Submit inquiry</button>
        <p id="form-status" class="status" role="status" aria-live="polite"></p>
      </form>
    </section>
  </main>

  <script src="config.js"></script>
  <script src="app.js"></script>
</body>
</html>

What does this file provide?

This file defines the Gather & Co. inquiry form. It includes required booking fields and an accessible status region for submission feedback.

The hidden honeypot field gives the later intake function a simple signal for automated submissions.

Check site/styles.css
:root {
  color-scheme: light;
  font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  color: #17211b;
  background: #edf1e9;
}

* {
  box-sizing: border-box;
}

body {
  min-height: 100vh;
  margin: 0;
  background:
    radial-gradient(circle at top left, rgba(245, 190, 96, 0.35), transparent 35%),
    #edf1e9;
}

.page-shell {
  width: min(1080px, calc(100% - 32px));
  margin: 40px auto;
  display: grid;
  grid-template-columns: 0.9fr 1.1fr;
  overflow: hidden;
  border-radius: 24px;
  background: #ffffff;
  box-shadow: 0 24px 80px rgba(23, 33, 27, 0.14);
}

.intro-panel,
.form-panel {
  padding: clamp(32px, 6vw, 64px);
}

.intro-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  color: #f7f4ea;
  background: #173f32;
}

.eyebrow {
  margin: 0 0 12px;
  color: #f5be60;
  font-size: 0.8rem;
  font-weight: 800;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}

h1,
h2 {
  margin-top: 0;
  line-height: 1.08;
}

h1 {
  max-width: 10ch;
  font-size: clamp(2.5rem, 6vw, 4.7rem);
}

h2 {
  font-size: clamp(1.8rem, 4vw, 2.6rem);
}

.intro-panel p,
.intro-panel li {
  color: #d8e6de;
  line-height: 1.65;
}

.intro-panel ul {
  padding-left: 20px;
}

form {
  display: grid;
  gap: 18px;
}

label {
  display: grid;
  gap: 7px;
  font-size: 0.9rem;
  font-weight: 700;
}

input,
select,
textarea {
  width: 100%;
  border: 1px solid #cbd5ce;
  border-radius: 10px;
  padding: 12px 14px;
  color: #17211b;
  background: #fbfcfa;
  font: inherit;
}

input:focus,
select:focus,
textarea:focus {
  outline: 3px solid rgba(40, 112, 87, 0.18);
  border-color: #287057;
}

.field-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}

button {
  border: 0;
  border-radius: 10px;
  padding: 14px 18px;
  color: #ffffff;
  background: #c4622d;
  font: inherit;
  font-weight: 800;
  cursor: pointer;
}

button:disabled {
  cursor: wait;
  opacity: 0.65;
}

.status {
  min-height: 1.5rem;
  margin: 0;
  font-weight: 700;
}

.status.success {
  color: #19633f;
}

.status.error {
  color: #a0352b;
}

.honeypot {
  position: absolute;
  left: -10000px;
}

@media (max-width: 760px) {
  .page-shell {
    grid-template-columns: 1fr;
    margin: 16px auto;
  }

  .field-row {
    grid-template-columns: 1fr;
  }
}

What does this file provide?

This stylesheet creates the two-panel desktop layout. Its mobile rule collapses the form into a single column on narrower screens.

Focus and status styles keep keyboard navigation and submission feedback visible.

Check site/config.js
window.APP_CONFIG = {
  intakeUrl: "your-lambda-function-url-here"
};

Why keep the placeholder?

The project does not have an intake endpoint yet. Keeping the placeholder allows the browser code to detect that missing connection deliberately.

A later step replaces this value after the backend exists.

Check site/app.js
const form = document.querySelector("#lead-form");
const statusMessage = document.querySelector("#form-status");
const submitButton = document.querySelector("#submit-button");
const apiUrl = window.APP_CONFIG?.intakeUrl ?? "";

function showStatus(message, type) {
  statusMessage.textContent = message;
  statusMessage.className = `status ${type}`;
}

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  if (!apiUrl || apiUrl.includes("your-lambda-function-url-here")) {
    showStatus("The intake API is not connected yet.", "error");
    return;
  }

  const payload = Object.fromEntries(new FormData(form).entries());
  payload.attendees = Number(payload.attendees);

  submitButton.disabled = true;
  showStatus("Sending your inquiry...", "");

  try {
    const response = await fetch(apiUrl, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload)
    });

    const result = await response.json();
    if (!response.ok) {
      throw new Error(result.message ?? "The inquiry could not be submitted.");
    }

    showStatus(`Lead captured. Reference: ${result.leadId}`, "success");
    form.reset();
  } catch (error) {
    showStatus(error.message, "error");
  } finally {
    submitButton.disabled = false;
  }
});

What does this file provide?

This script converts form entries into a request payload. It also controls the loading state and the message displayed beneath the form.

The placeholder check creates the deliberate missing-backend result used in the next step.

Check infrastructure.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Event-driven lead intake with S3, SNS, SQS, Lambda, DynamoDB, and CloudFront

Parameters:
  ProjectName:
    Type: String
    Default: event-lead-pipeline
    AllowedPattern: '^[a-z0-9-]+$'

Resources:
  WebsiteBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub '${ProjectName}-site-${AWS::AccountId}-${AWS::Region}'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

  LeadTopic:
    Type: AWS::SNS::Topic

  LeadTopicPolicy:
    Type: AWS::SNS::TopicPolicy
    Properties:
      Topics:
        - !Ref LeadTopic
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowLeadBucketPublish
            Effect: Allow
            Principal:
              Service: s3.amazonaws.com
            Action: sns:Publish
            Resource: !Ref LeadTopic
            Condition:
              ArnEquals:
                'aws:SourceArn': !Sub 'arn:${AWS::Partition}:s3:::${ProjectName}-leads-${AWS::AccountId}-${AWS::Region}'
              StringEquals:
                'aws:SourceAccount': !Ref AWS::AccountId

  LeadBucket:
    Type: AWS::S3::Bucket
    DependsOn: LeadTopicPolicy
    Properties:
      BucketName: !Sub '${ProjectName}-leads-${AWS::AccountId}-${AWS::Region}'
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      NotificationConfiguration:
        TopicConfigurations:
          - Event: 's3:ObjectCreated:*'
            Topic: !Ref LeadTopic
            Filter:
              S3Key:
                Rules:
                  - Name: prefix
                    Value: incoming/

  SalesQueue:
    Type: AWS::SQS::Queue

  OperationsQueue:
    Type: AWS::SQS::Queue

  TeamQueuePolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref SalesQueue
        - !Ref OperationsQueue
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowLeadTopicDelivery
            Effect: Allow
            Principal:
              Service: sns.amazonaws.com
            Action: sqs:SendMessage
            Resource:
              - !GetAtt SalesQueue.Arn
              - !GetAtt OperationsQueue.Arn
            Condition:
              ArnEquals:
                'aws:SourceArn': !Ref LeadTopic

  SalesSubscription:
    Type: AWS::SNS::Subscription
    DependsOn: TeamQueuePolicy
    Properties:
      TopicArn: !Ref LeadTopic
      Protocol: sqs
      Endpoint: !GetAtt SalesQueue.Arn
      RawMessageDelivery: true

  OperationsSubscription:
    Type: AWS::SNS::Subscription
    DependsOn: TeamQueuePolicy
    Properties:
      TopicArn: !Ref LeadTopic
      Protocol: sqs
      Endpoint: !GetAtt OperationsQueue.Arn
      RawMessageDelivery: true

  LeadTable:
    Type: AWS::DynamoDB::Table
    Properties:
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: leadId
          AttributeType: S
      KeySchema:
        - AttributeName: leadId
          KeyType: HASH
      SSESpecification:
        SSEEnabled: true

  IntakeFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: IntakeWriteAndLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: s3:PutObject
                Resource: !Sub '${LeadBucket.Arn}/incoming/*'
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*'

  IntakeFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.14
      Handler: index.handler
      Role: !GetAtt IntakeFunctionRole.Arn
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          LEAD_BUCKET: !Ref LeadBucket
      Code:
        ZipFile: |
          import base64
          import json
          import os
          import uuid
          from datetime import datetime, timezone

          import boto3

          s3 = boto3.client("s3")
          lead_bucket = os.environ["LEAD_BUCKET"]

          def reply(status_code, payload):
              return {
                  "statusCode": status_code,
                  "headers": {"content-type": "application/json"},
                  "body": json.dumps(payload),
              }

          def handler(event, context):
              method = event.get("requestContext", {}).get("http", {}).get("method")
              if method != "POST":
                  return reply(405, {"message": "Method not allowed"})

              raw_body = event.get("body") or "{}"
              if event.get("isBase64Encoded"):
                  raw_body = base64.b64decode(raw_body).decode("utf-8")

              try:
                  payload = json.loads(raw_body)
              except (TypeError, json.JSONDecodeError):
                  return reply(400, {"message": "Request body must be valid JSON"})

              if payload.get("companyWebsite"):
                  return reply(202, {"message": "Inquiry accepted"})

              required = ("name", "email", "eventType", "eventDate", "attendees")
              missing = [field for field in required if not str(payload.get(field, "")).strip()]
              if missing:
                  return reply(400, {"message": f"Missing fields: {', '.join(missing)}"})

              email = str(payload["email"]).strip()
              if "@" not in email:
                  return reply(400, {"message": "Enter a valid email address"})

              try:
                  attendees = int(payload["attendees"])
              except (TypeError, ValueError):
                  return reply(400, {"message": "Attendees must be a whole number"})

              if attendees < 1 or attendees > 10000:
                  return reply(400, {"message": "Attendees must be between 1 and 10000"})

              lead_id = str(uuid.uuid4())
              lead = {
                  "leadId": lead_id,
                  "name": str(payload["name"]).strip()[:100],
                  "email": email[:254],
                  "phone": str(payload.get("phone", "")).strip()[:40],
                  "eventType": str(payload["eventType"]).strip()[:100],
                  "eventDate": str(payload["eventDate"]).strip()[:20],
                  "attendees": attendees,
                  "notes": str(payload.get("notes", "")).strip()[:1000],
                  "submittedAt": datetime.now(timezone.utc).isoformat(),
              }

              if payload.get("budget") not in (None, ""):
                  try:
                      budget = int(payload["budget"])
                  except (TypeError, ValueError):
                      return reply(400, {"message": "Budget must be a whole number"})
                  if budget < 0:
                      return reply(400, {"message": "Budget cannot be negative"})
                  lead["budget"] = budget

              object_key = f"incoming/{lead_id}.json"
              s3.put_object(
                  Bucket=lead_bucket,
                  Key=object_key,
                  Body=json.dumps(lead).encode("utf-8"),
                  ContentType="application/json",
              )

              return reply(201, {"message": "Lead captured", "leadId": lead_id})

  IntakeFunctionUrl:
    Type: AWS::Lambda::Url
    Properties:
      TargetFunctionArn: !GetAtt IntakeFunction.Arn
      AuthType: NONE
      InvokeMode: BUFFERED
      Cors:
        AllowOrigins:
          - '*'
        AllowMethods:
          - POST
        AllowHeaders:
          - content-type
        MaxAge: 300

  IntakeUrlPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref IntakeFunction
      FunctionUrlAuthType: NONE
      Action: lambda:InvokeFunctionUrl
      Principal: '*'

  IntakeFunctionPermission:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !Ref IntakeFunction
      Action: lambda:InvokeFunction
      Principal: '*'
      InvokedViaFunctionUrl: true

  IndexerFunctionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: IndexLeadAndLogs
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action: s3:GetObject
                Resource: !Sub '${LeadBucket.Arn}/incoming/*'
              - Effect: Allow
                Action: dynamodb:PutItem
                Resource: !GetAtt LeadTable.Arn
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource: !Sub 'arn:${AWS::Partition}:logs:${AWS::Region}:${AWS::AccountId}:*'

  IndexerFunction:
    Type: AWS::Lambda::Function
    Properties:
      Runtime: python3.14
      Handler: index.handler
      Role: !GetAtt IndexerFunctionRole.Arn
      MemorySize: 128
      Timeout: 10
      Environment:
        Variables:
          LEAD_TABLE: !Ref LeadTable
      Code:
        ZipFile: |
          import json
          import os
          from urllib.parse import unquote_plus

          import boto3

          s3 = boto3.client("s3")
          table = boto3.resource("dynamodb").Table(os.environ["LEAD_TABLE"])

          def handler(event, context):
              processed = 0

              for sns_record in event.get("Records", []):
                  s3_event = json.loads(sns_record["Sns"]["Message"])

                  for record in s3_event.get("Records", []):
                      bucket = record["s3"]["bucket"]["name"]
                      key = unquote_plus(record["s3"]["object"]["key"])
                      if not key.startswith("incoming/"):
                          continue

                      response = s3.get_object(Bucket=bucket, Key=key)
                      lead = json.loads(response["Body"].read().decode("utf-8"))
                      table.put_item(Item=lead)
                      processed += 1

              return {"processed": processed}

  AllowSnsInvokeIndexer:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt IndexerFunction.Arn
      Action: lambda:InvokeFunction
      Principal: sns.amazonaws.com
      SourceArn: !Ref LeadTopic

  IndexerSubscription:
    Type: AWS::SNS::Subscription
    DependsOn: AllowSnsInvokeIndexer
    Properties:
      TopicArn: !Ref LeadTopic
      Protocol: lambda
      Endpoint: !GetAtt IndexerFunction.Arn

  SiteOriginAccessControl:
    Type: AWS::CloudFront::OriginAccessControl
    Properties:
      OriginAccessControlConfig:
        Name: !Sub '${ProjectName}-oac-${AWS::AccountId}'
        Description: Signed access from CloudFront to the private website bucket
        OriginAccessControlOriginType: s3
        SigningBehavior: always
        SigningProtocol: sigv4

  SiteDistribution:
    Type: AWS::CloudFront::Distribution
    Properties:
      DistributionConfig:
        Enabled: true
        Comment: !Sub '${ProjectName} secure static intake site'
        DefaultRootObject: index.html
        HttpVersion: http2
        IPV6Enabled: true
        Origins:
          - Id: PrivateS3Origin
            DomainName: !GetAtt WebsiteBucket.RegionalDomainName
            OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
            S3OriginConfig:
              OriginAccessIdentity: ''
        DefaultCacheBehavior:
          TargetOriginId: PrivateS3Origin
          ViewerProtocolPolicy: redirect-to-https
          AllowedMethods:
            - GET
            - HEAD
            - OPTIONS
          CachedMethods:
            - GET
            - HEAD
          Compress: true
          CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad

  WebsiteBucketPolicy:
    Type: AWS::S3::BucketPolicy
    Properties:
      Bucket: !Ref WebsiteBucket
      PolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Sid: AllowCloudFrontRead
            Effect: Allow
            Principal:
              Service: cloudfront.amazonaws.com
            Action: s3:GetObject
            Resource: !Sub '${WebsiteBucket.Arn}/*'
            Condition:
              StringEquals:
                'AWS:SourceArn': !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}'

Outputs:
  WebsiteBucketName:
    Value: !Ref WebsiteBucket
  LeadBucketName:
    Value: !Ref LeadBucket
  IntakeFunctionUrl:
    Value: !GetAtt IntakeFunctionUrl.FunctionUrl
  IntakeFunctionName:
    Value: !Ref IntakeFunction
  SiteUrl:
    Value: !Sub 'https://${SiteDistribution.DomainName}'
  SalesQueueUrl:
    Value: !Ref SalesQueue
  OperationsQueueUrl:
    Value: !Ref OperationsQueue
  LeadTableName:
    Value: !Ref LeadTable

What does this template provide?

This template defines the complete backend and delivery architecture for later steps. It includes private storage and an HTTPS website distribution.

It also defines the intake function and the event fan-out path. Separate team queues and a database indexer can process the same lead independently.

  • Select File from the VS Code menu bar.
  • Select Save All.

The unsaved indicators should disappear from every editor tab. Your Explorer should still show four files inside site plus infrastructure.yaml at the workspace level.

Do the files appear in the wrong place?

Make sure index.html, styles.css, config.js, and app.js are nested beneath site.

Keep infrastructure.yaml directly beneath event-lead-pipeline.

Help me correct my VS Code project file structure.

Before the final check, which account do you expect the AWS CLI to report?

  • Return to Terminal through Spotlight Search.
  • Verify the active account and ARN in us-east-1 by running:
aws sts get-caller-identity --region us-east-1

You should see the intended AWS account and caller ARN. The command confirms that your workspace is paired with the correct deployment identity.

Your identity and project files are ready. Next, you will open the Gather & Co. form and experience the missing-backend problem it is designed to expose.

Build the Lead Form

Your Visual Studio Code workspace already contains the files for the event inquiry experience. That foundation gives you a complete local form to inspect before any cloud resources exist.

The HTML form can collect a lead in the browser. However, a local page has no intake service that can persist or distribute that lead. This step lets you experience that gap before you deploy its solution.

In this step, get ready to:
  • Inspect the accessible structure of the event inquiry form.
  • Trace the responsive presentation and browser-side payload logic.
  • Submit a local inquiry to expose the missing backend.
Review the form structure

The form uses semantic controls so browsers understand each input. Its status region also gives submission feedback without sending visitors to another page.

  • Select site/index.html in the Explorer sidebar.
  • Find the lead-form form.
  • Trace the labels for name, email, phone, eventType, eventDate, attendees, and notes.
  • Find the form-status element below the submit button.
  • Locate the hidden companyWebsite input inside the honeypot label.
  • Confirm config.js loads before app.js at the bottom of the page.

How does the form support visitors?

  • Each visible control sits inside a label that names the information it collects.
  • The required fields let the browser block incomplete submissions.
  • The form-status region announces changing feedback through role="status".
  • The hidden honeypot field provides a simple signal for automated form submissions.
Trace the presentation and payload

The CSS turns the form into a responsive two-panel page. The JavaScript converts the submitted controls into the payload that the future intake endpoint expects.

  • Select site/styles.css in the Explorer sidebar.
  • Locate the .page-shell rules that create the two-column layout.
  • Locate the focus rules for input:focus, select:focus, and textarea:focus.
  • Find @media (max-width: 760px) near the bottom of the stylesheet.
  • Select site/app.js in the Explorer sidebar.
  • Locate Object.fromEntries(new FormData(form).entries()) inside the submit handler.
  • Find the conversion from payload.attendees through Number().
  • Locate the fetch(apiUrl request with method: "POST".

How does the browser build a lead?

  • FormData reads the named controls from lead-form.
  • Object.fromEntries() converts those values into one payload object.
  • Number() converts the attendee count from form text into a numeric value.
  • fetch() is ready to send that payload as JSON after the intake endpoint is connected.
Submit the local form

A local browser test shows how the finished interface behaves before AWS receives anything. You will submit realistic data through the same controls a visitor uses.

  • Control-click site/index.html in the Explorer sidebar.
  • Select Reveal in Finder from the context menu.
  • Double-click index.html in Finder.

You should see the green Gather & Co. introduction beside the white inquiry form. On a narrow browser window, you should see the two panels stack vertically.

Does the form look unstyled?

Confirm you opened site/index.html from the same folder as styles.css.

Check that the stylesheet reference still uses href="styles.css".

Help me diagnose the missing styles in my local event form.

  • Enter Jordan Lee in the Name field.
  • Enter jordan@example.com in the Email field.
  • Select Corporate gathering from the Event type field.
  • Choose a future date in the Event date field.
  • Enter 120 in the Estimated guests field.

Before you submit, do you think this local page can save the inquiry or send a copy to either team?

  • Select Submit inquiry.

You will stay on the form. The status region displays The intake API is not connected yet..

That shortfall is intentional. The form collects the lead correctly, while the missing endpoint prevents storage and distribution.

  • Switch back to the existing Visual Studio Code window.
  • Select site/config.js in the Explorer sidebar.
  • Confirm intakeUrl still contains your-lambda-function-url-here.
  • Leave the placeholder unchanged.
  • Return to the browser tab containing the local form.

Do you see different feedback?

Complete every required field before selecting Submit inquiry.

Confirm site/config.js still contains the original placeholder value.

Help me debug why my local form does not show the expected missing API message.

You have proved that the local form captures valid input and handles submissions in place. Next, you will deploy the event-driven backend that gives those inquiries somewhere to go.

Deploy the Event-Driven Backend

Your local event form already proves that the browser-side experience works. Its deliberate missing-API message now points to the backend gap.

An AWS CloudFormation stack closes that gap by creating the HTTPS intake endpoint plus the event-processing path. Its event-driven architecture archives each lead before distributing the event to independent consumers.

In this step, get ready to:
  • Review the template's private origins, event path, and least-privilege boundaries.
  • Deploy the CloudFormation stack in the verified AWS Region.
  • Prove the stack outputs and public intake endpoint are available.
Review the backend architecture

Your infrastructure.yaml file is an infrastructure as code blueprint for the whole backend. Reviewing its trust boundaries helps you understand why each resource can access only what it needs.

  • In the VS Code Explorer sidebar, select infrastructure.yaml.
  • Compare the public access settings in WebsiteBucket against those in LeadBucket.
  • Trace the LeadBucket NotificationConfiguration to confirm s3:ObjectCreated:* events are filtered to incoming/.
  • Inspect the destination policies to identify which AWS service can publish to each destination.
  • Compare the permission scope of IntakeFunctionRole against IndexerFunctionRole.
  • Review LeadTable to confirm it uses PAY_PER_REQUEST billing with server-side encryption.
  • Trace private site delivery from SiteDistribution through SiteOriginAccessControl to WebsiteBucketPolicy.
  • Count the entries under Outputs to confirm the template exposes eight generated values.

How does the stack work?

  • Amazon CloudFront uses Origin Access Control to read the private website bucket over HTTPS.
  • The public AWS Lambda Function URL accepts POST requests from the browser.
  • The intake Lambda function writes each lead as private JSON in Amazon S3 under incoming/.
  • An Amazon S3 event notification publishes each new object event to Amazon SNS.
  • Amazon SNS copies that event into each Amazon SQS team queue.
  • The topic invokes IndexerFunction through a separate subscription.
  • The indexer writes the lead to Amazon DynamoDB using leadId as the partition key.
  • Repeated processing writes the same leadId item again. This creates idempotent final-state storage.
  • This fan-out gives each consumer its own delivery path.
Deploy the CloudFormation stack

Deploying this template creates AWS resources that can incur pay-as-you-go charges. The cleanup section removes the stack after you finish.

CloudFront deployment can keep the command running for several minutes. A quiet terminal during part of that wait can be normal.

  • Return to the VS Code integrated terminal from earlier.
  • Create the stack from infrastructure.yaml in us-east-1 by running this command:
aws cloudformation deploy --template-file infrastructure.yaml --stack-name event-lead-pipeline --capabilities CAPABILITY_IAM --region us-east-1

What does this command do?

  • --template-file infrastructure.yaml selects the template in your existing workspace.
  • --stack-name event-lead-pipeline gives the deployed stack its fixed project name.
  • --capabilities CAPABILITY_IAM acknowledges that the template creates IAM resources.
  • --region us-east-1 keeps every resource in the Region you verified earlier.
  • Keep the terminal open until CloudFormation reports that the stack operation finished successfully.

That is the longest build done. Your backend resources now exist behind one repeatable stack.

Stack deployment failed?

  • Read the first failed resource reason in the terminal output.
  • Confirm the authenticated AWS identity has permission to create the resource named in that reason.
  • Confirm the terminal is inside the event-lead-pipeline folder containing infrastructure.yaml.

help me diagnose my CloudFormation deployment failure.

Verify the live backend

Stack outputs turn generated resource names into values you can reuse. The intake URL also gives you a direct way to test whether the public endpoint is reachable.

Before you run this, predict whether every named output will have a generated value.

  • Display the deployed stack outputs by running this command:
aws cloudformation describe-stacks --stack-name event-lead-pipeline --region us-east-1 --query "Stacks[0].Outputs" --output table

What does this command show?

Stacks[0].Outputs selects the output collection from the deployed stack. --output table formats those values as readable rows.

  • WebsiteBucketName identifies the private website origin bucket.
  • LeadBucketName identifies the private lead archive bucket.
  • IntakeFunctionUrl provides the public intake endpoint.
  • IntakeFunctionName identifies the intake Lambda function.
  • SiteUrl provides the CloudFront website address.
  • SalesQueueUrl identifies the Sales team queue.
  • OperationsQueueUrl identifies the Event Operations queue.
  • LeadTableName identifies the DynamoDB table.
  • Find the IntakeFunctionUrl row in the output table.
  • Record its value here: your intake function URL.

Before you open the endpoint, predict whether a browser GET request will be accepted or rejected.

  • Switch back to the browser from earlier.
  • Open a new tab with Cmd+T (macOS) or Ctrl+T (Windows).
  • Paste your intake function URL into the address bar.
  • Press Enter.

The browser shows a JSON response containing Method not allowed.

That is the backend online. The planned HTTP 405 response proves the function is reachable through its public URL.

Endpoint not responding?

  • Confirm you copied the complete value from the IntakeFunctionUrl output row.
  • Return to the stack output table to check the URL for missing characters.
  • Confirm the CloudFormation deployment finished successfully before testing the endpoint.

help me troubleshoot the Lambda Function URL response.

Your backend is ready to accept real form submissions. Next up, you'll connect the intake URL and publish the private site through CloudFront.

Connect and Publish the Secure Intake Site

The deployed stack gives your form a live Lambda Function URL. The browser still reads the placeholder in site/config.js.

Your private Amazon S3 bucket has no website files yet. Amazon CloudFront needs those files before it can serve the intake form over HTTPS.

In this step, get ready to:
  • Connect the form to the deployed intake endpoint.
  • Synchronize the four website files to the private website bucket.
  • Submit a lead through the secure CloudFront site.
Connect the form to the intake endpoint

The site/config.js file supplies the endpoint used by site/app.js. Replacing its placeholder connects every form submission to the deployed intake function.

  • Return to the stack output table in the terminal from earlier.
  • Copy the value beside IntakeFunctionUrl here: your IntakeFunctionUrl stack output.
  • Select site/config.js in the VS Code Explorer sidebar.
  • Find this code:
window.APP_CONFIG = {
  intakeUrl: "your-lambda-function-url-here"
};

Why is there a placeholder?

The current value creates the deliberate missing-backend result from the previous step. Replacing it gives the browser a real destination for its request.

  • Replace your-lambda-function-url-here with the captured IntakeFunctionUrl value.
  • Keep the quotation marks around the URL.
  • Make the file look like this:
window.APP_CONFIG = {
  intakeUrl: "[[INTAKE_FUNCTION_URL="your IntakeFunctionUrl stack output"]]"
};

How does the connection work?

The window.APP_CONFIG object makes the deployed endpoint available to the browser. The existing site/app.js file reads intakeUrl before sending the form as JSON.

  • Save site/config.js.

✔️ Awesome, I've got everything!

Your site/config.js file now points to the deployed intake endpoint.

ⓧ I'd like to double check the full code

window.APP_CONFIG = {
  intakeUrl: "[[INTAKE_FUNCTION_URL="your IntakeFunctionUrl stack output"]]"
};
Upload the site to the private bucket

The website bucket stays private throughout this upload. Origin Access Control lets CloudFront read the files without exposing the bucket itself.

  • Return to the stack output table in the terminal from earlier.
  • Copy the value beside WebsiteBucketName here: your WebsiteBucketName stack output.
  • Return to the VS Code terminal attached to the open event-lead-pipeline workspace.
  • Set the terminal's WEBSITE_BUCKET value by running:
WEBSITE_BUCKET="[[WEBSITE_BUCKET="your WebsiteBucketName stack output"]]"

What does this value do?

The WEBSITE_BUCKET shell variable holds the exact destination bucket name for the next command. This keeps the upload tied to your deployed stack.

  • Synchronize the site folder to the private website bucket by running:
aws s3 sync site "s3://$WEBSITE_BUCKET" --delete --region us-east-1

What does this command do?

  • The aws s3 sync command recursively copies new or updated files from the local site folder.
  • The upload places index.html, styles.css, config.js, and app.js in the website bucket.
  • CloudFront remains the allowed reader because the command does not make the bucket public.

You'll see upload entries for index.html, styles.css, config.js, and app.js. Those four entries confirm the private bucket now contains the complete site.

Site files did not upload?

  • Confirm the terminal is attached to the open event-lead-pipeline workspace.
  • Compare WEBSITE_BUCKET with the WebsiteBucketName stack output.
  • Run the same sync command again after correcting the bucket value.

help me troubleshoot why my four site files did not upload to the private S3 bucket

Submit a lead through CloudFront

CloudFront is the only allowed reader for the private website bucket. Opening the stack's SiteUrl confirms that secure delivery reaches your browser.

  • Return to the stack output table in the terminal from earlier.
  • Copy the value beside SiteUrl here: your SiteUrl stack output.
  • Paste your SiteUrl stack output into your browser's address bar.
  • Press Enter to load the HTTPS site.

You'll see the Gather & Co. event inquiry page with the heading Plan an event people remember. The form appears beside the introduction panel.

  • Enter Jordan Lee in the Name field.
  • Enter jordan@example.com in the Email field.
  • Select Corporate gathering from the Event type menu.
  • Enter 2026-12-12 in the Event date field.
  • Enter 120 in the Estimated guests field.
  • Enter Year-end community dinner with wheelchair access. in the Notes field.

Before you submit, do you expect the page to keep showing the missing-API warning or return a lead reference?

  • Select Submit inquiry.

You'll see Lead captured. Reference: followed by a generated lead ID. The form resets after the successful response.

  • Copy the generated lead ID here: your generated lead ID.

What did this prove?

  • The browser sent the form data as a JSON request to the deployed intake endpoint.
  • The intake function generated the lead ID shown on the page.
  • The intake function stored private JSON under the incoming/ prefix before returning the successful response.

Lead reference not appearing?

  • Compare site/config.js with the deployed IntakeFunctionUrl stack output.
  • Re-run the S3 sync command if the page still shows The intake API is not connected yet..
  • Confirm every required form field contains a value accepted by the browser.

help me troubleshoot why my published event inquiry form does not display a lead reference

That's the public path connected: your private site now captures a lead through CloudFront. Next, you'll trace that lead into both team queues and the DynamoDB table.

Trace the Lead Through Every Consumer

Your Amazon CloudFront site now returns a lead ID after the intake function archives the submission in Amazon S3. That proves the producer accepted the request.

A success message says nothing about the downstream consumers. First, you will trace the Amazon SNS publication into both Amazon SQS team queues.

Then you will match the same lead to its Amazon DynamoDB item. The shared leadId turns duplicate object events into an idempotent final-state write.

In this step, get ready to:
  • Capture the queue URLs plus table name from the stack outputs.
  • Compare the archived object key across both team queues.
  • Match the browser lead ID to its DynamoDB item.
Capture the consumer outputs

The stack outputs contain the generated addresses for both queues. They also contain the table name needed for the final check.

  • Switch back to the VS Code integrated terminal from earlier.
  • Display the stack outputs by running this command:
aws cloudformation describe-stacks --stack-name event-lead-pipeline --region us-east-1 --query "Stacks[0].Outputs" --output table

What does this command show?

The AWS CLI reads the outputs from the deployed event-lead-pipeline stack. The table includes SalesQueueUrl, OperationsQueueUrl and LeadTableName.

  • Copy the SalesQueueUrl value into the matching placeholder below.
  • Copy the OperationsQueueUrl value into the matching placeholder below.
  • Copy the LeadTableName value into the matching placeholder below.
  • Store the three values in terminal variables by running:
SALES_QUEUE_URL="[[SALES_QUEUE_URL="your SalesQueueUrl stack output"]]"
OPERATIONS_QUEUE_URL="[[OPERATIONS_QUEUE_URL="your OperationsQueueUrl stack output"]]"
LEAD_TABLE="[[LEAD_TABLE="your LeadTableName stack output"]]"

Why store these values?

The SALES_QUEUE_URL, OPERATIONS_QUEUE_URL and LEAD_TABLE variables keep the generated values available in your current terminal session. This prevents copy errors in the consumer checks.

Missing the stack outputs?

  • Confirm that the command uses the event-lead-pipeline stack name.
  • Confirm that the command uses the us-east-1 Region.
  • Check that your current AWS identity can read the deployed stack.

Help me diagnose missing outputs from my deployed CloudFormation stack.

Compare the two team queues

The SNS topic copies one S3 object-created event into separate Sales and Event Operations queues. Each queue keeps an independent message for its team.

  • Read up to 10 messages from the Sales queue by running:
aws sqs receive-message --queue-url "$SALES_QUEUE_URL" --max-number-of-messages 10 --wait-time-seconds 10 --region us-east-1

What does this queue check do?

  • The Sales queue URL selects the team inbox you want to inspect.
  • The command retrieves up to 10 available messages.
  • The 10 second wait gives the queue time to return a message through long polling.

You should see a raw S3 event containing an incoming/ object key. The key ends with the lead ID shown by the browser.

  • Read up to 10 messages from the Event Operations queue by running:
aws sqs receive-message --queue-url "$OPERATIONS_QUEUE_URL" --max-number-of-messages 10 --wait-time-seconds 10 --region us-east-1

What does the second result prove?

This command inspects the Event Operations queue independently. Its raw message contains the S3 event delivered through the second SNS subscription.

You should see another incoming/ object key ending with the same browser lead ID. The matching key proves that both teams received their own copy of one event.

  • Locate the incoming/ object key in the Sales output.
  • Locate the incoming/ object key in the Event Operations output.
  • Compare the lead ID inside both object keys.

Did one queue return no message?

An SQS receive call can return no messages during a poll. The archived lead and its event can still be processing asynchronously.

  • Run the command for the empty queue again.
  • Check that each queue variable contains the matching stack output.

Help me trace a missing S3 event in one SQS queue.

Match the lead in DynamoDB

The indexer reads the archived lead object from S3. Its table.put_item(Item=lead) call writes the complete JSON object into the lead table.

Before you run the final check, do you expect one item or several items for the browser lead ID?

  • Scan the lead table with a consistent read by running:
aws dynamodb scan --table-name "$LEAD_TABLE" --consistent-read --region us-east-1 --output table

What does this table check do?

  • The LEAD_TABLE variable selects the table created by the stack.
  • The --consistent-read option requests the latest committed item state.
  • The --output table option presents the scan result in a readable terminal table.

You should see a row whose leadId matches the value shown by the browser. Its contact details match the form submission.

The event type matches your submission. The event date plus attendance value match too.

Can't find the lead item?

  • Run the scan command again because SNS invokes the indexer asynchronously.
  • Compare the browser lead ID with the value shown in the table.
  • Check that LEAD_TABLE contains the LeadTableName stack output.

Help me diagnose why my archived S3 lead is missing from DynamoDB.

That matching item proves the indexer consumed the third SNS delivery. The browser lead has now reached every consumer in the pipeline.

Why duplicates stay contained

S3 Event Notifications can be delivered more than once. The indexer writes the archived JSON with leadId as the DynamoDB partition key.

PutItem creates or replaces an item with the same primary key. A repeated event therefore converges on one final item.

Strong finish. You proved that one browser submission reaches two independent team inboxes.

Your matching DynamoDB record completes the end-to-end trace.

Secret mission

Evolve the Lead Schema Without Rewiring Consumers

Lead requirements change after systems go live. Add an optional whole-dollar budget to the form, then prove the existing fan-out pipeline carries it into every consumer without infrastructure changes.

Clean Up Your Resources

The tutorial workload is estimated to cost under $1. That estimate is not guaranteed because charges vary by account eligibility, Region, requests, storage, and data transfer.

The public intake endpoint can receive unexpected requests. Choose Pause or Delete if you have finished using the form.

Nice Work!

Nice Work!

Outstanding work! Your live event lead pipeline now accepts browser inquiries over HTTPS.

You've learned how to:

  • Publish a private event inquiry site over HTTPS through Amazon CloudFront. Keep its origin private in Amazon S3. Accept browser submissions through an AWS Lambda Function URL.
  • Fan out each archived lead through Amazon SNS. Give Sales and Event Operations independent team inboxes with Amazon SQS.
  • Index complete lead records in Amazon DynamoDB with duplicate-safe final-state writes. Recreate the architecture from one AWS CloudFormation stack.
  • Secret Mission: Evolve the lead schema with an optional whole-dollar budget. Preserve every existing fan-out consumer without infrastructure changes.

Ready to quiz yourself?