Build an AWS Event Lead Capture Site
Build a CloudFront website that stores validated event leads in DynamoDB.
Introduction
30 Second Summary
Event teams lose momentum when inquiries arrive with missing details or no clear path for follow-up. Visitors also expect quick confirmation that their request reached the right place.
In this project, you will build a public event inquiry website on AWS that turns visitor submissions into validated leads. The finished three-tier serverless application delivers the form over HTTPS with each approved submission stored in Amazon DynamoDB.
What You'll Build
A fake event inquiry submitted through your polished public website appears moments later as a validated DynamoDB lead with a reference you can show in the browser.
By the end of this project, you'll have:
- A responsive event inquiry form that shows clear pending, success, or error feedback in Microsoft Edge.
- A repeatable AWS SAM deployment that publishes a public HTTPS URL from a private Amazon S3 origin behind Amazon CloudFront.
- A validated lead flow that stores only approved fake fields in DynamoDB with a generated lead reference.
- Secret Mission: Add automatic lead-priority classification that displays each result in the stored item plus the browser confirmation.
Are there any prerequisites?
You need a Windows computer, an AWS account with deployment permissions, Visual Studio Code, and a configured AWS CLI. Small pay-as-you-go charges can occur, although eligible AWS allowances or credits may cover the tutorial workload.
Before We Start
Before the hands-on work begins, lock in the visitor journey you are building and why it matters to Northstar Events.
Set Up the Windows Toolchain
Your project brief now connects a visitor inquiry to reliable event-sales follow-up. The next step is preparing a Windows environment that can build the website and deploy its serverless stack.
A repeatable deployment needs a Lambda-compatible Node.js runtime. The AWS SAM CLI packages your application for deployment.
Your existing AWS CLI provides access to your configured AWS account. This step verifies that access without creating any AWS resources.
In this step, get ready to:
- Complete the pinned Node.js runtime plus AWS SAM CLI installations.
- Create the empty project structure in your editor.
- Verify the Windows toolchain plus your configured AWS account identity.
Install the pinned tools
Installer changes to PATH become available when a new Windows PowerShell session starts. Checking each tool first reveals whether you need a fresh installation or an upgrade.
- Press the Windows key to open Windows search.
- Type Windows PowerShell and press Enter to open it.
- Check the installed Node.js version by running:
node --version
The result maps your computer to one of the three Node.js installation states below.
✔️ I see version 24.22.0 or higher
Your Node.js runtime is ready. The installation also provides the npm package manager needed by the Lambda project.
ⓧ I see an older version
The project uses Node.js 24.22.0 LTS to match the supported Lambda runtime selected later.
- Download node-v24.22.0-x64.msi from the official Node.js release directory.
- Run node-v24.22.0-x64.msi from your browser downloads.
- Keep the standard PATH option selected.
- Finish the installation.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell and press Enter to open a new session.
- Confirm the upgraded runtime by running:
node --version
You should now see v24.22.0 in the output. The pinned runtime is ready for your Lambda source.
ⓧ Command not found
PowerShell cannot find Node.js because the runtime is missing from your computer or unavailable on PATH.
- Download node-v24.22.0-x64.msi from the official Node.js release directory.
- Run node-v24.22.0-x64.msi from your browser downloads.
- Keep the standard PATH option selected.
- Finish the installation.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell and press Enter to open a new session.
- Confirm the installed runtime by running:
node --version
You should now see v24.22.0 in the output. Node.js is now available through PATH.
AWS SAM CLI converts your application files plus infrastructure template into deployment artifacts. The project pins version 1.169.0 for a consistent walkthrough.
- Check the installed AWS SAM CLI version in PowerShell by running:
sam --version
The result identifies whether the deployment tool is ready or needs its Windows installer.
✔️ I see version 1.169.0
AWS SAM CLI is ready. Your computer can now build and deploy the serverless stack in the later steps.
ⓧ I see an older version
The installed AWS SAM CLI version is below the project's 1.169.0 pin. The current installer upgrades the deployment tool.
- Download AWS_SAM_CLI_64_PY3.msi from the official AWS SAM CLI release page.
- Run AWS_SAM_CLI_64_PY3.msi from your browser downloads.
- Keep the standard PATH option selected.
- Finish the installation.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell and press Enter to open a new session.
- Confirm the upgraded CLI by running:
sam --version
You should now see version 1.169.0 in the output. The deployment tool now matches this project.
ⓧ Command not found
PowerShell cannot find AWS SAM CLI because it is missing or unavailable on PATH.
- Download AWS_SAM_CLI_64_PY3.msi from the official AWS SAM CLI release page.
- Run AWS_SAM_CLI_64_PY3.msi from your browser downloads.
- Keep the standard PATH option selected.
- Finish the installation.
- Close the current PowerShell window.
- Press the Windows key to open Windows search.
- Type Windows PowerShell and press Enter to open a new session.
- Confirm the installed CLI by running:
sam --version
You should now see version 1.169.0 in the output. AWS SAM CLI is now available through PATH.
Installer finished but the command is missing?
- Close every open PowerShell window so the next session loads the updated PATH.
- Run the relevant version check again from a new PowerShell session.
If the command is still unavailable, help me check the Windows PATH entries for Node.js or AWS SAM CLI.
Create the project structure
The src folder reserves a home for the Lambda source plus its package manifest. The website folder keeps the static Northstar Events page separate.
Opening the shared parent folder in Visual Studio Code gives both parts one workspace. Its integrated terminal then starts from that project root.
- Press the Windows key to open Windows search.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select Desktop in the file picker's navigation pane.
- Click New folder.
- Enter event-lead-capture as the folder name.
A folder trust prompt may appear
VS Code can ask whether you trust a newly opened folder. This project folder is one you created yourself.
- Approve the folder if the trust prompt appears.
- Select event-lead-capture in the file picker.
- Click Select Folder.
- Select event-lead-capture in the Explorer sidebar.
- Click the New Folder icon beside the project name.
- Enter src and press Enter.
You should see an empty src folder under event-lead-capture in the Explorer.
- Select event-lead-capture in the Explorer sidebar.
- Click the New Folder icon beside the project name.
- Enter website and press Enter.
You should now see empty src plus website folders under the project root.
- Click Terminal in the top menu bar.
- Click New Terminal.
The integrated PowerShell prompt should end with event-lead-capture. This confirms the terminal started at the project root.
Verify the complete setup
A single verification pass proves that the new terminal can find every required command. The identity check returns account identifiers without revealing secret access keys.
Before you run these checks, which commands do you expect to report versions? Which command should report your configured AWS identity?
- Run the complete toolchain verification from the integrated PowerShell terminal:
node --version
npm --version
sam --version
aws --version
$PSVersionTable.PSVersion
aws sts get-caller-identity
What do these checks prove?
- The Node.js plus npm checks confirm that the runtime and package manager are available on PATH.
- The AWS SAM CLI check confirms that its deployment command is available.
- The AWS CLI plus PowerShell checks confirm that the existing Windows tools can report their versions.
- The final command returns the AWS identity associated with your configured CLI credentials.
You should see v24.22.0 for Node.js. You should also see 1.169.0 for AWS SAM CLI.
The other version checks should return values without errors. The final result should identify your configured AWS account identity.
One of the checks failed?
- Close the integrated terminal if a newly installed command is unavailable.
- Create another terminal so VS Code loads the latest PATH.
- Review your existing AWS CLI credential setup if the identity request cannot authenticate.
If you still see a failure, help me diagnose my Windows toolchain verification.
The website needs a local browser before it moves behind CloudFront. Microsoft Edge provides that browser on this Windows environment.
- Press the Windows key to open Windows search.
- Type Microsoft Edge and press Enter to open it.
You should see a Microsoft Edge browser window. This confirms the browser is available for the local form check in the next step.
That's the Windows foundation locked in. Next, you'll turn the empty website folder into the Northstar Events inquiry form.
Build the Event Inquiry Form
Your Windows toolchain is verified. Your VS Code project is ready for its first customer-facing result.
In this step, you will build the local Northstar Events experience with HTML, CSS, and JavaScript. The form will stop before any network request because its API address is still a placeholder.
In this step, get ready to:
- Build the Northstar Events page structure and inquiry form.
- Style the page for desktop and mobile screens.
- Add the browser submission flow and verify the placeholder message.
Create the page structure
The page structure defines everything a visitor can read or interact with. The first version starts with the Northstar Events hero so you can see the experience take shape immediately.
- Switch back to the event-lead-capture project in VS Code.
- Select the website folder in the Explorer sidebar.
- Create a file named index.html with the new file icon.
- Build the initial page shell by pasting this code into index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Plan a memorable event with Northstar Events.">
<title>Northstar Events | Start Your Event</title>
</head>
<body>
<main class="page-shell">
<section class="hero" aria-labelledby="page-title">
<p class="eyebrow">Northstar Events</p>
<h1 id="page-title">Bring your next event to life.</h1>
<p class="hero-copy">
Tell us what you are planning. Our event team will turn your ideas into a clear next step.
</p>
<ul class="highlights" aria-label="Event service highlights">
<li>Corporate experiences</li>
<li>Weddings and celebrations</li>
<li>Private parties</li>
</ul>
</section>
</main>
</body>
</html>
What does this code do?
- The metadata gives the browser the page language and responsive viewport settings.
- The hero section introduces Northstar Events and its three service areas.
- The aria-labelledby relationship gives the section an accessible name from the main heading.
- Save index.html.
- Open website\index.html in Microsoft Edge.
You should see the Northstar Events heading and three event-service highlights. The page is plain because its visual layer comes next.
Page not opening?
Confirm that index.html sits inside the existing website folder. Check that the file ends with the .html extension.
Help me find why my local HTML page does not open in Microsoft Edge.
The hero establishes the visitor’s context. The form now needs fields that capture only the information required for an event inquiry.
- In index.html, locate the closing </main> tag.
- Insert the form card directly above that tag with this code:
<section class="form-card" aria-labelledby="form-title">
<p class="step-label">Event inquiry</p>
<h2 id="form-title">Start the conversation</h2>
<p class="form-intro">Use fake data while completing this learning project.</p>
<form id="lead-form">
<button id="submit-button" type="submit">Request a consultation</button>
<p id="form-message" class="form-message" role="status" aria-live="polite"></p>
</form>
</section>
How does the form report progress?
- The lead-form identifier gives JavaScript a precise form to monitor.
- The submit-button identifier lets the submission handler control the button state.
- The form-message status region announces request feedback to assistive technology.
- Save index.html.
- Refresh the page in Microsoft Edge.
You should see an Event inquiry section beneath the hero. It contains the form heading and submission button.
Form card missing?
Confirm that the new section sits inside <main>. A section placed below </html> can render unpredictably.
Help me check where the form section belongs in my HTML.
Each input maps to one approved lead field. The consent checkbox makes the visitor’s permission an explicit part of the submission.
- In index.html, locate the opening <form id="lead-form"> tag.
- Insert the inquiry fields directly below that tag with this code:
<label for="name">Name</label>
<input id="name" name="name" type="text" minlength="2" maxlength="80" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" maxlength="254" autocomplete="email" required>
<label for="event-type">Event type</label>
<select id="event-type" name="eventType" required>
<option value="">Choose an event type</option>
<option value="corporate">Corporate event</option>
<option value="wedding">Wedding</option>
<option value="private-party">Private party</option>
</select>
<label for="guest-count">Estimated guests</label>
<input id="guest-count" name="guestCount" type="number" min="1" max="5000" step="1" required>
<label class="consent-row" for="consent">
<input id="consent" name="consent" type="checkbox" required>
<span>I agree that Northstar Events may contact me about this inquiry.</span>
</label>
What do these fields collect?
- The name and email inputs use browser validation before the submission handler runs.
- The event menu limits visitors to the three supported event categories.
- The guest count accepts whole numbers from 1 through 5000.
- The required consent checkbox prevents an incomplete inquiry from being submitted.
- Save index.html.
- Refresh the page in Microsoft Edge.
You should see fields for name, email, event type, estimated guests, and consent. The submission button remains beneath them.
Fields in the wrong place?
Confirm that the field code appears after <form id="lead-form">. It must appear before the submit button.
Help me fix the field order in my Northstar Events form.
Style the responsive form
The form has all its content. Styling now turns that structure into a two-column desktop experience with a focused mobile layout.
- Select the website folder in the VS Code Explorer sidebar.
- Create a file named styles.css with the new file icon.
- Set the page foundation by pasting this code into styles.css:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #f4f1eb;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 12% 12%, rgba(232, 172, 98, 0.34), transparent 32rem),
linear-gradient(135deg, #17233d 0 46%, #f4f1eb 46% 100%);
}
button,
input,
select {
font: inherit;
}
What does this styling establish?
- The root rules set the page colors and font stack.
- The universal box rule keeps element dimensions predictable.
- The body gradients divide the page into dark and light visual areas.
- The form controls inherit the page font for a consistent interface.
- Save styles.css.
- Return to index.html.
- Insert this stylesheet reference directly below the title:
<link rel="stylesheet" href="styles.css">
How is the stylesheet loaded?
The link tells Microsoft Edge to load styles.css with the page. Keeping the stylesheet beside index.html makes the relative path resolve directly.
- Save index.html.
- Refresh the page in Microsoft Edge.
You should see a dark blue and cream background. The page should also use a consistent system font.
Styles not loading?
Confirm that styles.css sits beside index.html in the website folder. Check that the link uses the exact filename.
Help me find why styles.css is not changing my local page.
- Add the main layout and section labels below the existing code in styles.css:
.page-shell {
width: min(1120px, calc(100% - 2rem));
min-height: 100vh;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 0.9fr;
gap: clamp(2rem, 6vw, 6rem);
align-items: center;
padding: 3rem 0;
}
.hero {
color: #ffffff;
max-width: 36rem;
}
.eyebrow,
.step-label {
margin: 0 0 0.75rem;
color: #dc9f52;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
How does the desktop layout work?
- The page shell uses two grid columns for the hero and form.
- The responsive gap grows with the available screen width.
- The eyebrow and step label create a shared visual cue across both columns.
- Save styles.css.
- Refresh the page in Microsoft Edge.
You should see the hero on the left and the form on the right. The small section labels should appear in gold uppercase text.
Columns not appearing?
Check that the HTML still uses class="page-shell" on <main>. Confirm that both sections remain inside that element.
Help me debug the two-column page-shell layout.
- Add the heading and hero-copy styles below the existing code in styles.css:
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 1.25rem;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(3rem, 7vw, 5.8rem);
line-height: 0.95;
letter-spacing: -0.045em;
}
h2 {
margin-bottom: 0.5rem;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(2rem, 4vw, 2.8rem);
line-height: 1;
}
.hero-copy {
max-width: 32rem;
color: #d8deea;
font-size: 1.1rem;
line-height: 1.7;
}
Why use responsive heading sizes?
The clamp() values let headings grow with the viewport while preserving readable limits. The tighter heading spacing gives the hero a clear visual hierarchy.
- Save styles.css.
- Refresh the page in Microsoft Edge.
You should see a larger serif hero heading and a smaller serif form heading. The introductory hero text should appear in a softer blue-white color.
Headings unchanged?
Check that both heading selector blocks include their closing braces. A missing brace can prevent later styles from loading.
Help me find a CSS syntax issue around my heading styles.
- Add the service-highlight and form-card styles below the existing code in styles.css:
.highlights {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0;
margin: 2rem 0 0;
list-style: none;
}
.highlights li {
padding: 0.65rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
color: #f6f7fa;
background: rgba(255, 255, 255, 0.06);
}
.form-card {
padding: clamp(1.5rem, 4vw, 2.75rem);
border: 1px solid rgba(23, 32, 51, 0.08);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1.5rem 4rem rgba(12, 22, 42, 0.18);
backdrop-filter: blur(12px);
}
What creates the card effect?
- The highlights wrap into pill-shaped labels when space becomes limited.
- The form card combines a pale background with a rounded border.
- The shadow separates the form from the page background.
- Save styles.css.
- Refresh the page in Microsoft Edge.
You should see three rounded service labels. The inquiry form should sit inside a pale raised card.
Card still looks plain?
Confirm that the form section uses class="form-card". Check that the selector begins with a full stop.
Help me debug why the form-card styles are not applied.
- Add the form layout and input styles below the existing code in styles.css:
.form-intro {
margin-bottom: 1.5rem;
color: #637086;
line-height: 1.5;
}
form {
display: grid;
gap: 0.7rem;
}
label {
margin-top: 0.35rem;
font-size: 0.9rem;
font-weight: 750;
}
input,
select {
width: 100%;
min-height: 3rem;
padding: 0.7rem 0.85rem;
border: 1px solid #cbd2dc;
border-radius: 0.75rem;
color: #172033;
background: #ffffff;
}
How does the form stay readable?
- The form grid creates consistent spacing between each label and control.
- Every input spans the available card width.
- The control height and padding create comfortable click targets.
- Save styles.css.
- Refresh the page in Microsoft Edge.
You should see evenly spaced labels and full-width form controls. Each control should have a rounded light border.
Inputs overflowing?
Confirm that the universal box-sizing rule remains near the top of styles.css. It keeps padding inside the declared width.
Help me debug why my form controls extend beyond the form card.
- Add the focus and consent styles below the existing code in styles.css:
input:focus,
select:focus {
outline: 3px solid rgba(220, 159, 82, 0.24);
border-color: #b77423;
}
.consent-row {
display: grid;
grid-template-columns: 1.2rem 1fr;
gap: 0.65rem;
align-items: start;
margin: 0.75rem 0;
color: #4f5b6e;
font-weight: 500;
line-height: 1.45;
}
.consent-row input {
min-height: 0;
margin-top: 0.2rem;
}
Why style focus clearly?
The focus outline makes the active field visible during keyboard navigation. The consent grid keeps its checkbox aligned with the full permission statement.
- Save styles.css.
- Refresh the page in Microsoft Edge.
- Select the name field with the keyboard.
You should see a gold focus outline around the active field. The consent checkbox should align with the first line of its text.
Focus outline missing?
Check that the selectors use input:focus and select:focus. Confirm that a comma separates them.
Help me find why my focused form control has no visible outline.
- Add the button and message styles below the existing code in styles.css:
button {
min-height: 3.2rem;
border: 0;
border-radius: 0.8rem;
color: #ffffff;
background: #17233d;
font-weight: 800;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
background: #263758;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.form-message {
min-height: 1.5rem;
margin: 0.35rem 0 0;
font-weight: 700;
line-height: 1.45;
}
How does the button show activity?
- The enabled button moves slightly when the visitor hovers over it.
- The disabled style changes the cursor and lowers the opacity during a request.
- The message area reserves space so feedback does not shift the form layout.
- Save styles.css.
- Refresh the page in Microsoft Edge.
You should see a dark blue consultation button. Moving the pointer over it should lift the button slightly.
Button style missing?
Check that the button remains inside the form. Confirm that no earlier CSS block is missing its closing brace.
Help me find why the consultation button is not styled.
- Add the message-state and mobile-layout styles below the existing code in styles.css:
.form-message[data-state="pending"] {
color: #755116;
}
.form-message[data-state="success"] {
color: #17633a;
}
.form-message[data-state="error"] {
color: #a12b2b;
}
@media (max-width: 820px) {
body {
background: linear-gradient(180deg, #17233d 0 38rem, #f4f1eb 38rem 100%);
}
.page-shell {
grid-template-columns: 1fr;
gap: 2.5rem;
padding: 4rem 0 2rem;
}
.hero {
min-height: 29rem;
}
}
How do states and mobile screens differ?
- The message color changes according to its pending, success, or error state.
- Screens up to 820px wide use one page column.
- The mobile background stacks its dark and light regions vertically.
- Save styles.css.
- Refresh the page in Microsoft Edge.
- Narrow the Microsoft Edge window below 820px.
You should see the form move beneath the hero in one column. The layout should stay inside the visible screen width.
Mobile layout still has two columns?
Confirm that the media rule appears after the desktop .page-shell rule. Check that both closing braces remain at the end of the file.
Help me debug why the mobile page-shell still uses two columns.
✔️ Awesome, I've got everything!
Great. Your styles.css file now covers the desktop layout, mobile layout, form controls, and message states.
ⓧ I'd like to double check the full code
Compare your saved styles.css file with this complete version:
:root {
color-scheme: light;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: #172033;
background: #f4f1eb;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(circle at 12% 12%, rgba(232, 172, 98, 0.34), transparent 32rem),
linear-gradient(135deg, #17233d 0 46%, #f4f1eb 46% 100%);
}
button,
input,
select {
font: inherit;
}
.page-shell {
width: min(1120px, calc(100% - 2rem));
min-height: 100vh;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 0.9fr;
gap: clamp(2rem, 6vw, 6rem);
align-items: center;
padding: 3rem 0;
}
.hero {
color: #ffffff;
max-width: 36rem;
}
.eyebrow,
.step-label {
margin: 0 0 0.75rem;
color: #dc9f52;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1,
h2,
p {
margin-top: 0;
}
h1 {
margin-bottom: 1.25rem;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(3rem, 7vw, 5.8rem);
line-height: 0.95;
letter-spacing: -0.045em;
}
h2 {
margin-bottom: 0.5rem;
font-family: Georgia, "Times New Roman", serif;
font-size: clamp(2rem, 4vw, 2.8rem);
line-height: 1;
}
.hero-copy {
max-width: 32rem;
color: #d8deea;
font-size: 1.1rem;
line-height: 1.7;
}
.highlights {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0;
margin: 2rem 0 0;
list-style: none;
}
.highlights li {
padding: 0.65rem 0.9rem;
border: 1px solid rgba(255, 255, 255, 0.24);
border-radius: 999px;
color: #f6f7fa;
background: rgba(255, 255, 255, 0.06);
}
.form-card {
padding: clamp(1.5rem, 4vw, 2.75rem);
border: 1px solid rgba(23, 32, 51, 0.08);
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 1.5rem 4rem rgba(12, 22, 42, 0.18);
backdrop-filter: blur(12px);
}
.form-intro {
margin-bottom: 1.5rem;
color: #637086;
line-height: 1.5;
}
form {
display: grid;
gap: 0.7rem;
}
label {
margin-top: 0.35rem;
font-size: 0.9rem;
font-weight: 750;
}
input,
select {
width: 100%;
min-height: 3rem;
padding: 0.7rem 0.85rem;
border: 1px solid #cbd2dc;
border-radius: 0.75rem;
color: #172033;
background: #ffffff;
}
input:focus,
select:focus {
outline: 3px solid rgba(220, 159, 82, 0.24);
border-color: #b77423;
}
.consent-row {
display: grid;
grid-template-columns: 1.2rem 1fr;
gap: 0.65rem;
align-items: start;
margin: 0.75rem 0;
color: #4f5b6e;
font-weight: 500;
line-height: 1.45;
}
.consent-row input {
min-height: 0;
margin-top: 0.2rem;
}
button {
min-height: 3.2rem;
border: 0;
border-radius: 0.8rem;
color: #ffffff;
background: #17233d;
font-weight: 800;
cursor: pointer;
transition: transform 150ms ease, background 150ms ease;
}
button:hover:not(:disabled) {
transform: translateY(-1px);
background: #263758;
}
button:disabled {
cursor: wait;
opacity: 0.65;
}
.form-message {
min-height: 1.5rem;
margin: 0.35rem 0 0;
font-weight: 700;
line-height: 1.45;
}
.form-message[data-state="pending"] {
color: #755116;
}
.form-message[data-state="success"] {
color: #17633a;
}
.form-message[data-state="error"] {
color: #a12b2b;
}
@media (max-width: 820px) {
body {
background: linear-gradient(180deg, #17233d 0 38rem, #f4f1eb 38rem 100%);
}
.page-shell {
grid-template-columns: 1fr;
gap: 2.5rem;
padding: 4rem 0 2rem;
}
.hero {
min-height: 29rem;
}
}
Wire the submission states
The visual experience is ready. The browser logic now collects the form values and controls the pending, success, or error message shown to the visitor.
- Select the website folder in the VS Code Explorer sidebar.
- Create a file named app.js with the new file icon.
- Add the browser references and placeholder guard by pasting this code into app.js:
const form = document.querySelector("#lead-form");
const submitButton = document.querySelector("#submit-button");
const formMessage = document.querySelector("#form-message");
const apiUrl = window.APP_CONFIG?.apiUrl;
function showMessage(message, state) {
formMessage.textContent = message;
formMessage.dataset.state = state;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!apiUrl || apiUrl === "your-api-endpoint-here") {
showMessage("The form is ready. Connect the API next.", "error");
return;
}
});
How does the placeholder guard work?
- The first four constants connect the script to the form controls and API configuration.
- The showMessage() function updates both the visible text and its visual state.
- The submit listener prevents the browser’s default page reload.
- The placeholder check returns immediately before any network request can run.
- Save app.js.
- Return to index.html.
- Insert this script reference directly above </body>:
<script src="app.js"></script>
Why load the script at the bottom?
The browser reads the form markup before it runs app.js. The script can then find all three referenced elements.
- Save index.html.
- Refresh the page in Microsoft Edge.
- Complete every form field with fake valid data.
- Select the required consent checkbox.
- Select Request a consultation.
You should see The form is ready. Connect the API next. in red beneath the button. The missing API configuration activates the guard.
Submission reloads the page?
Confirm that app.js is saved beside index.html. Check that the script reference appears before the closing body tag.
Help me debug why my local form reloads instead of showing its placeholder message.
The guard also needs an explicit configuration value. Keeping that value in its own file makes the API endpoint easy to replace after deployment.
- Select the website folder in the VS Code Explorer sidebar.
- Create a file named config.js with the new file icon.
- Define the placeholder API address by pasting this code into config.js:
window.APP_CONFIG = {
apiUrl: "your-api-endpoint-here"
};
What does the configuration hold?
The window.APP_CONFIG object makes the endpoint available to app.js. Its current value deliberately keeps the form in local preview mode.
- Save config.js.
- Return to index.html.
- Insert this script reference directly above the existing app.js reference:
<script src="config.js"></script>
Why does script order matter?
The browser must create window.APP_CONFIG before app.js reads its apiUrl value. Loading config.js first guarantees that order.
- Save index.html.
- Refresh the page in Microsoft Edge.
- Submit the completed fake inquiry again.
You should still see The form is ready. Connect the API next.. This time the guard is matching the explicit placeholder in config.js.
Configuration error showing?
Confirm that config.js loads before app.js. Check that the property is spelled apiUrl in both files.
Help me debug the APP_CONFIG value used by app.js.
The guard controls today’s local result. The rest of the handler prepares the form for the live endpoint you will deploy next.
- In app.js, locate the closing brace of the placeholder check.
- Insert the payload and pending state directly below that check with this code:
const payload = {
name: form.elements.name.value.trim(),
email: form.elements.email.value.trim(),
eventType: form.elements.eventType.value,
guestCount: Number(form.elements.guestCount.value),
consent: form.elements.consent.checked
};
submitButton.disabled = true;
showMessage("Sending your inquiry...", "pending");
What goes into the request?
- The payload includes only name, email, event type, guest count, and consent.
- The name and email values have surrounding spaces removed.
- The guest count becomes a number before transmission.
- The pending state disables the button while a live request is active.
- Save app.js.
- Refresh the page in Microsoft Edge.
- Submit another fake valid inquiry.
You should still see the local placeholder message. The early return keeps the new live-request path from running.
Seeing a syntax error?
Confirm that the payload appears after the placeholder check. Check every comma inside the payload object.
Help me find a syntax problem in the form payload.
- Insert the network request directly below the pending message in app.js with this code:
try {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "The inquiry was not accepted.");
}
form.reset();
showMessage(`${data.message} Reference: ${data.leadId}`, "success");
} catch (error) {
const message =
error instanceof TypeError
? "We could not send your inquiry."
: error.message || "We could not send your inquiry.";
showMessage(message, "error");
} finally {
submitButton.disabled = false;
}
How will the live request behave?
- The request sends the approved payload as JSON with the POST method.
- A successful response resets the form and shows its message with the generated lead reference.
- A rejected response shows the server’s safe error message.
- A browser network failure becomes We could not send your inquiry..
- The final block re-enables the submit button after the request finishes.
- Save app.js.
- Refresh the page in Microsoft Edge.
Form script stopped working?
Confirm that the request block sits inside the submit listener. Compare every closing brace and parenthesis with the complete file below.
Help me debug the fetch-based submit handler in app.js.
✔️ Awesome, I've got everything!
Great. Your app.js file now contains the local guard and complete live submission path.
ⓧ I'd like to double check the full code
Compare your saved app.js file with this complete version:
const form = document.querySelector("#lead-form");
const submitButton = document.querySelector("#submit-button");
const formMessage = document.querySelector("#form-message");
const apiUrl = window.APP_CONFIG?.apiUrl;
function showMessage(message, state) {
formMessage.textContent = message;
formMessage.dataset.state = state;
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
if (!apiUrl || apiUrl === "your-api-endpoint-here") {
showMessage("The form is ready. Connect the API next.", "error");
return;
}
const payload = {
name: form.elements.name.value.trim(),
email: form.elements.email.value.trim(),
eventType: form.elements.eventType.value,
guestCount: Number(form.elements.guestCount.value),
consent: form.elements.consent.checked
};
submitButton.disabled = true;
showMessage("Sending your inquiry...", "pending");
try {
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.message || "The inquiry was not accepted.");
}
form.reset();
showMessage(`${data.message} Reference: ${data.leadId}`, "success");
} catch (error) {
const message =
error instanceof TypeError
? "We could not send your inquiry."
: error.message || "We could not send your inquiry.";
showMessage(message, "error");
} finally {
submitButton.disabled = false;
}
});
✔️ My configuration matches
Your config.js file should keep the placeholder endpoint until the backend is deployed.
ⓧ I'd like to double check the full code
Compare your saved config.js file with this complete version:
window.APP_CONFIG = {
apiUrl: "your-api-endpoint-here"
};
✔️ My page structure matches
Your index.html file should contain the hero, inquiry form, stylesheet reference, and both scripts.
ⓧ I'd like to double check the full code
Compare your saved index.html file with this complete version:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="description" content="Plan a memorable event with Northstar Events.">
<title>Northstar Events | Start Your Event</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<main class="page-shell">
<section class="hero" aria-labelledby="page-title">
<p class="eyebrow">Northstar Events</p>
<h1 id="page-title">Bring your next event to life.</h1>
<p class="hero-copy">
Tell us what you are planning. Our event team will turn your ideas into a clear next step.
</p>
<ul class="highlights" aria-label="Event service highlights">
<li>Corporate experiences</li>
<li>Weddings and celebrations</li>
<li>Private parties</li>
</ul>
</section>
<section class="form-card" aria-labelledby="form-title">
<p class="step-label">Event inquiry</p>
<h2 id="form-title">Start the conversation</h2>
<p class="form-intro">Use fake data while completing this learning project.</p>
<form id="lead-form">
<label for="name">Name</label>
<input id="name" name="name" type="text" minlength="2" maxlength="80" autocomplete="name" required>
<label for="email">Email</label>
<input id="email" name="email" type="email" maxlength="254" autocomplete="email" required>
<label for="event-type">Event type</label>
<select id="event-type" name="eventType" required>
<option value="">Choose an event type</option>
<option value="corporate">Corporate event</option>
<option value="wedding">Wedding</option>
<option value="private-party">Private party</option>
</select>
<label for="guest-count">Estimated guests</label>
<input id="guest-count" name="guestCount" type="number" min="1" max="5000" step="1" required>
<label class="consent-row" for="consent">
<input id="consent" name="consent" type="checkbox" required>
<span>I agree that Northstar Events may contact me about this inquiry.</span>
</label>
<button id="submit-button" type="submit">Request a consultation</button>
<p id="form-message" class="form-message" role="status" aria-live="polite"></p>
</form>
</section>
</main>
<script src="config.js"></script>
<script src="app.js"></script>
</body>
</html>
Before you submit the finished local form, what message do you expect the placeholder guard to show?
- Refresh website\index.html in Microsoft Edge.
- Enter Jamie Test in the name field.
- Enter jamie@example.com in the email field.
- Select Corporate event as the event type.
- Enter 120 as the estimated guest count.
- Select the required consent checkbox.
- Select Request a consultation.
You should see The form is ready. Connect the API next. beneath the button. The guard has stopped the submission before fetch() can make a network request.
That is your first visible win: the responsive customer experience works locally and gives clear feedback. The cloud backend can now be added without redesigning the form.
Your Northstar Events form is ready for a real destination. Next, you will deploy the API, Lambda logic, data table, and private website origin that preserve each valid lead.
Deploy the Serverless Backend
Your local Northstar Events form now delivers a polished visitor experience. A refresh still loses every inquiry because no backend stores it.
In this step, AWS SAM packages the AWS Lambda code. A single AWS CloudFormation template deploys Amazon API Gateway, Amazon DynamoDB, private Amazon S3 storage, plus Amazon CloudFront delivery.
A direct Windows PowerShell request will prove that the backend validates a fake lead before storing it.
Why AWS SAM for this stack?
AWS SAM keeps the Lambda configuration beside standard CloudFormation resources in one template. This gives you one repeatable stack instead of a sequence of console changes.
The same template also defines the function's narrow write permission. Infrastructure code makes that security boundary visible during review.
In this step, get ready to:
- Create the Lambda package with validation plus a safe DynamoDB write.
- Define the backend plus private site origin in an AWS SAM template.
- Deploy the stack before proving that a direct API request stores a fake lead.
Create the Lambda package and test request
The Lambda function accepts only the fields from your inquiry form. It normalizes those values before deciding whether DynamoDB should receive the lead.
How does the privacy boundary work?
The handler writes approved fields to DynamoDB without printing the request body. Its responses contain a generated reference plus a safe status message.
Use fake contact details throughout this project. Real customer data needs retention rules plus a privacy review.
- In the VS Code Explorer sidebar, expand the existing src folder.
- Click the new file icon beside the src folder.
- Name the file package.json.
- Define the Lambda package by pasting this code into src/package.json:
{
"name": "event-lead-capture-function",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-dynamodb": "3.1152.0"
}
}
What does this package file do?
- The module package type enables the ES module import syntax used by the Lambda source.
- The @aws-sdk/client-dynamodb dependency provides the DynamoDB client plus the item-write command.
- The dependency is pinned to 3.1152.0 so the deployment uses a known package version.
- Save src/package.json by pressing Ctrl+S.
- Confirm that package.json now appears inside src in the Explorer sidebar.
- Click the new file icon beside the src folder.
- Name the file app.mjs.
- Set up the DynamoDB client plus validation constants by pasting this code into src/app.mjs:
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const dynamoDB = new DynamoDBClient({});
const tableName = process.env.TABLE_NAME;
const allowedEventTypes = new Set(["corporate", "wedding", "private-party"]);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
What does this setup do?
- DynamoDBClient creates the service client that Lambda uses at runtime.
- TABLE_NAME supplies the deployed table name without hardcoding a generated resource name.
- allowedEventTypes limits submissions to the three choices provided by the form.
- emailPattern checks the basic structure of the normalized email address.
- Save src/app.mjs.
- Check the current JavaScript syntax by running this command in the integrated PowerShell terminal:
node --check .\src\app.mjs
A successful syntax check returns you to the PowerShell prompt without reporting an error.
Seeing a JavaScript syntax error?
Check the import braces plus the quotes around the package name. Confirm that the regular expression ends with a forward slash.
Use the line number in the terminal to locate the affected line in src/app.mjs.
Help me fix the syntax error in my Lambda setup code.
- Add the reusable API response function below the existing constants in src/app.mjs:
function response(statusCode, payload) {
return {
statusCode,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
};
}
What does the response helper do?
- statusCode tells API Gateway whether the request succeeded or failed.
- Content-Type identifies the response body as JSON.
- JSON.stringify(payload) converts the response object into the string format required by a Lambda proxy response.
- Save src/app.mjs.
- Check the expanded file by running this command again:
node --check .\src\app.mjs
You should return to the PowerShell prompt without a syntax error.
- Add the request parsing plus field validation below the response function:
export const handler = async (event, context) => {
try {
const body = JSON.parse(event.body || "{}");
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
const eventType = typeof body.eventType === "string" ? body.eventType : "";
const guestCount = Number(body.guestCount);
const consent = body.consent === true;
const isValid =
name.length >= 2 &&
name.length <= 80 &&
email.length <= 254 &&
emailPattern.test(email) &&
allowedEventTypes.has(eventType) &&
Number.isInteger(guestCount) &&
guestCount >= 1 &&
guestCount <= 5000 &&
consent;
if (!isValid) {
return response(400, {
message: "Check every field and confirm consent before submitting."
});
}
How does the validation work?
- JSON.parse(event.body || "{}") reads the API Gateway proxy request body.
- The handler trims the name before checking its permitted length.
- The handler normalizes the email to lowercase before checking its format.
- isValid requires an approved event type plus a whole-number guest count from 1 through 5000.
- A submission also requires explicit consent before processing continues.
- Add the generated lead reference plus DynamoDB write below the validation block:
const leadId = context.awsRequestId;
await dynamoDB.send(
new PutItemCommand({
TableName: tableName,
Item: {
leadId: { S: leadId },
name: { S: name },
email: { S: email },
eventType: { S: eventType },
guestCount: { N: String(guestCount) },
consent: { BOOL: consent },
submittedAt: { S: new Date().toISOString() }
}
})
);
How is the lead stored?
- context.awsRequestId provides a generated identifier for this Lambda invocation.
- PutItemCommand writes one item to the table supplied through TABLE_NAME.
- The S, N, plus BOOL tags describe DynamoDB string, number, plus Boolean values.
- submittedAt records when the validated request reached the function.
- Finish the handler with safe success plus error responses:
return response(201, {
message: "Thanks. Your event inquiry has been received.",
leadId
});
} catch (error) {
if (error instanceof SyntaxError) {
return response(400, { message: "Send a valid JSON request body." });
}
return response(500, {
message: "We could not save the inquiry. Please try again."
});
}
};
How are results kept safe?
- A successful request returns the message plus the generated leadId.
- Malformed JSON receives a safe validation response.
- Unexpected failures receive a generic message without exposing customer data.
- The handler contains no request-body logging.
- Save the completed src/app.mjs file.
- Check the completed handler by running:
node --check .\src\app.mjs
You should return to the prompt without a syntax error. Your Lambda source can now be packaged by AWS SAM.
Handler syntax check failing?
- Match each opening brace in the handler with its closing brace.
- Confirm that the PutItemCommand block closes before the success response begins.
- Compare your handler with the full-file reference below.
Help me debug my completed Lambda handler.
- Click the new file icon beside the event-lead-capture project name in the Explorer sidebar.
- Name the root-level file test-lead.json.
- Create the fake direct-test lead by pasting this JSON into test-lead.json:
{
"name": "Jamie Test",
"email": "jamie@example.com",
"eventType": "corporate",
"guestCount": 120,
"consent": true
}
What does this test lead cover?
This fake record supplies every field required by the handler. Its values satisfy the name, email, event type, guest count, plus consent checks.
- Save test-lead.json.
- Confirm that test-lead.json appears beside the src folder in the Explorer sidebar.
✔️ Awesome, I've got everything!
Your Lambda package plus fake request are ready. Confirm that all three files are saved before continuing.
ⓧ I'd like to double check the full code
Compare these complete files with the versions in your project.
{
"name": "event-lead-capture-function",
"version": "1.0.0",
"private": true,
"type": "module",
"dependencies": {
"@aws-sdk/client-dynamodb": "3.1152.0"
}
}
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const dynamoDB = new DynamoDBClient({});
const tableName = process.env.TABLE_NAME;
const allowedEventTypes = new Set(["corporate", "wedding", "private-party"]);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function response(statusCode, payload) {
return {
statusCode,
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(payload)
};
}
export const handler = async (event, context) => {
try {
const body = JSON.parse(event.body || "{}");
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
const eventType = typeof body.eventType === "string" ? body.eventType : "";
const guestCount = Number(body.guestCount);
const consent = body.consent === true;
const isValid =
name.length >= 2 &&
name.length <= 80 &&
email.length <= 254 &&
emailPattern.test(email) &&
allowedEventTypes.has(eventType) &&
Number.isInteger(guestCount) &&
guestCount >= 1 &&
guestCount <= 5000 &&
consent;
if (!isValid) {
return response(400, {
message: "Check every field and confirm consent before submitting."
});
}
const leadId = context.awsRequestId;
await dynamoDB.send(
new PutItemCommand({
TableName: tableName,
Item: {
leadId: { S: leadId },
name: { S: name },
email: { S: email },
eventType: { S: eventType },
guestCount: { N: String(guestCount) },
consent: { BOOL: consent },
submittedAt: { S: new Date().toISOString() }
}
})
);
return response(201, {
message: "Thanks. Your event inquiry has been received.",
leadId
});
} catch (error) {
if (error instanceof SyntaxError) {
return response(400, { message: "Send a valid JSON request body." });
}
return response(500, {
message: "We could not save the inquiry. Please try again."
});
}
};
{
"name": "Jamie Test",
"email": "jamie@example.com",
"eventType": "corporate",
"guestCount": 120,
"consent": true
}
Define the serverless stack
The template turns the backend plus delivery layer into one deployable unit. Its resource references keep generated names out of your source code.
- Click the new file icon beside the event-lead-capture project name in the Explorer sidebar.
- Name the root-level file template.yaml.
- Define the global Lambda settings plus on-demand table by pasting this code into template.yaml:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless event lead capture website
Globals:
Function:
Runtime: nodejs24.x
Architectures:
- x86_64
MemorySize: 128
Timeout: 10
Resources:
LeadsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: leadId
AttributeType: S
KeySchema:
- AttributeName: leadId
KeyType: HASH
What does this template section define?
- Globals sets the Node.js 24 runtime plus shared memory, timeout, plus architecture values.
- LeadsTable uses on-demand capacity through PAY_PER_REQUEST.
- leadId is the string partition key for each stored lead.
- Save template.yaml.
- Confirm that VS Code recognizes the file as YAML in the status bar.
- Add the private S3 bucket below the LeadsTable resource:
SiteBucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
How is the bucket kept private?
BucketOwnerEnforced disables object ACL ownership choices. The four public access controls block public ACLs plus public bucket policies.
- Save template.yaml.
- Confirm that SiteBucket appears beneath Resources in the VS Code Outline view.
- Add the static-site cache policy below the bucket resource:
SiteCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: !Sub '${AWS::StackName}-site-cache'
DefaultTTL: 300
MaxTTL: 3600
MinTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
CookiesConfig:
CookieBehavior: none
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig:
HeaderBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
What does the cache policy control?
SiteCachePolicy caches static assets without varying the cache key by cookies, headers, or query strings. Compression remains enabled for supported viewers.
- Save template.yaml.
- Confirm that SiteCachePolicy appears beneath Resources in the Outline view.
- Add the CloudFront origin access control below the cache policy:
SiteOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-oac'
Description: Signed access from CloudFront to the private site bucket
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
What does origin access control do?
SiteOriginAccessControl makes CloudFront sign requests to the private S3 origin with Signature Version 4. The always setting keeps the origin connection authenticated.
- Save template.yaml.
- Confirm that SiteOriginAccessControl appears in the Outline view.
- Start the CloudFront distribution below the origin access control:
SiteDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: SiteBucketOrigin
DomainName: !GetAtt SiteBucket.RegionalDomainName
OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
How does CloudFront reach the bucket?
The distribution uses the bucket's regional domain as its origin. The origin access control ID connects signed CloudFront requests to that private origin.
- Save template.yaml.
- Confirm that the Origins section remains nested beneath DistributionConfig.
- Complete the distribution below its origin configuration:
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref SiteCachePolicy
Compress: true
TargetOriginId: SiteBucketOrigin
ViewerProtocolPolicy: redirect-to-https
ViewerCertificate:
CloudFrontDefaultCertificate: true
How is the website delivered?
The default behavior permits read-only website requests. The generated CloudFront certificate plus redirect-to-https deliver the public site over HTTPS.
- Save template.yaml.
- Confirm that DefaultCacheBehavior plus ViewerCertificate align beneath DistributionConfig.
- Add the scoped bucket policy plus REST API below the distribution:
SiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SiteBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${SiteBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}'
LeadApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
What access does this section grant?
- SiteBucketPolicy permits object reads from the CloudFront service principal.
- AWS:SourceArn limits that permission to this stack's distribution.
- LeadApi creates the REST API with a prod stage.
- Save template.yaml.
- Confirm that SiteBucketPolicy plus LeadApi appear as separate resources in the Outline view.
- Add the Lambda function plus API event below LeadApi:
CreateLeadFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: app.handler
Environment:
Variables:
TABLE_NAME: !Ref LeadsTable
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt LeadsTable.Arn
Events:
CreateLead:
Type: Api
Properties:
RestApiId: !Ref LeadApi
Path: /leads
Method: post
How is the function connected securely?
- CodeUri points AWS SAM to the existing src folder.
- TABLE_NAME receives the generated DynamoDB table name.
- dynamodb:PutItem grants the function only the write action required by this handler.
- /leads exposes the handler through an API Gateway POST event.
- Save template.yaml.
- Confirm that CreateLeadFunction appears beneath Resources in the Outline view.
- Add the deployment outputs below the resource definitions:
Outputs:
ApiUrl:
Description: POST endpoint for lead submissions
Value: !Sub 'https://${LeadApi}.execute-api.${AWS::Region}.amazonaws.com/prod/leads'
WebsiteBucketName:
Description: Bucket that stores the static site files
Value: !Ref SiteBucket
WebsiteUrl:
Description: HTTPS URL for the event inquiry website
Value: !Sub 'https://${SiteDistribution.DomainName}'
DistributionId:
Description: CloudFront distribution ID used for invalidations
Value: !Ref SiteDistribution
LeadsTableName:
Description: DynamoDB table that stores lead records
Value: !Ref LeadsTable
Why expose these outputs?
The outputs surface generated resource addresses plus names after deployment. You will reuse them for direct testing, website upload, cache invalidation, plus database verification.
- Save the completed template.yaml file.
- Validate the completed template by running:
sam validate --lint
The command should complete without validation errors. This confirms that the template passes AWS SAM plus CloudFormation lint checks.
Template validation failing?
- Use the reported line number to find the affected property in template.yaml.
- Check that every nested YAML level uses two spaces.
- Compare the named resource with the full template below.
Help me fix my AWS SAM template validation error.
✔️ Awesome, I've got everything!
Your template is saved plus validated. It is ready for its first AWS SAM build.
ⓧ I'd like to double check the full code
Compare your complete template.yaml with this deployment version.
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless event lead capture website
Globals:
Function:
Runtime: nodejs24.x
Architectures:
- x86_64
MemorySize: 128
Timeout: 10
Resources:
LeadsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: leadId
AttributeType: S
KeySchema:
- AttributeName: leadId
KeyType: HASH
SiteBucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
SiteCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: !Sub '${AWS::StackName}-site-cache'
DefaultTTL: 300
MaxTTL: 3600
MinTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
CookiesConfig:
CookieBehavior: none
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig:
HeaderBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
SiteOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-oac'
Description: Signed access from CloudFront to the private site bucket
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
SiteDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: SiteBucketOrigin
DomainName: !GetAtt SiteBucket.RegionalDomainName
OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref SiteCachePolicy
Compress: true
TargetOriginId: SiteBucketOrigin
ViewerProtocolPolicy: redirect-to-https
ViewerCertificate:
CloudFrontDefaultCertificate: true
SiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SiteBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${SiteBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}'
LeadApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
CreateLeadFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: app.handler
Environment:
Variables:
TABLE_NAME: !Ref LeadsTable
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt LeadsTable.Arn
Events:
CreateLead:
Type: Api
Properties:
RestApiId: !Ref LeadApi
Path: /leads
Method: post
Outputs:
ApiUrl:
Description: POST endpoint for lead submissions
Value: !Sub 'https://${LeadApi}.execute-api.${AWS::Region}.amazonaws.com/prod/leads'
WebsiteBucketName:
Description: Bucket that stores the static site files
Value: !Ref SiteBucket
WebsiteUrl:
Description: HTTPS URL for the event inquiry website
Value: !Sub 'https://${SiteDistribution.DomainName}'
DistributionId:
Description: CloudFront distribution ID used for invalidations
Value: !Ref SiteDistribution
LeadsTableName:
Description: DynamoDB table that stores lead records
Value: !Ref LeadsTable
Build, deploy, and verify the stack
Your source plus infrastructure now describe the complete backend. AWS SAM can install the pinned dependency before CloudFormation creates the resources in your configured Region.
This deployment creates pay-as-you-go AWS resources in your account. Eligible allowances or credits may cover a small tutorial workload, while the cleanup section gives you a complete deletion path.
- Build the Lambda package plus transformed template by running:
sam build
The build should complete successfully. You will also see AWS SAM prepare build artifacts inside the project.
SAM build failing?
- Confirm that the integrated terminal is still at the event-lead-capture project root.
- Confirm that src/package.json contains the pinned DynamoDB dependency.
- Confirm that CodeUri points to the existing src/ folder.
Help me diagnose my AWS SAM build failure.
The first deployment can sit quietly while CloudFront plus the other AWS resources are created. Several minutes of waiting is normal.
- Start the guided deployment by running:
sam deploy --guided
- Enter event-lead-capture when AWS SAM asks for the stack name.
- Choose your configured commercial AWS Region when prompted.
- Allow AWS SAM to create the required IAM role when prompted.
- Approve the public API confirmation if AWS SAM asks about the unauthenticated endpoint.
- Save the guided deployment settings when prompted.
- Approve the displayed CloudFormation changeset when prompted.
That is the provisioning work complete. Your stack now contains the API, function, table, private bucket, plus HTTPS distribution.
Deployment stopped before completion?
- Read the failed CloudFormation event shown near the end of the deployment output.
- Confirm that your AWS identity can create CloudFormation, IAM, Lambda, API Gateway, DynamoDB, S3, plus CloudFront resources.
- Confirm that you selected a commercial AWS Region.
Help me troubleshoot my guided AWS SAM deployment.
The deployment output now lists five values generated for your stack. Capture each value so later commands can reuse it without guesswork.
- Copy the ApiUrl output here: your ApiUrl output.
- Copy the LeadsTableName output here: your LeadsTableName output.
- Copy the WebsiteBucketName output here: your WebsiteBucketName output.
- Copy the WebsiteUrl output here: your WebsiteUrl output.
- Copy the DistributionId output here: your DistributionId output.
- Assign the deployed API endpoint to $apiUrl by running:
$apiUrl = "[[API_URL="your ApiUrl output"]]"
PowerShell now holds the deployed POST endpoint for the direct request.
Before you send the request, do you expect every field in test-lead.json to pass the server-side checks?
- Submit the fake lead directly to the deployed API by running:
Invoke-RestMethod -Method Post -Uri $apiUrl -ContentType "application/json" -InFile ".\test-lead.json"
You should see the success message plus a generated lead ID. This proves that API Gateway reached Lambda before the function accepted the request.
Direct request failing?
- Confirm that $apiUrl contains the complete ApiUrl output.
- Confirm that PowerShell is running from the project root containing test-lead.json.
- Confirm that the fake lead still contains all five required fields.
Help me diagnose my direct lead API request.
Before you scan the table, do you expect the stored item to contain only the approved lead fields plus the generated metadata?
- Scan the deployed leads table by running:
aws dynamodb scan --table-name [[LEADS_TABLE_NAME="your LeadsTableName output"]]
You should see the fake lead with leadId, name, email, eventType, guestCount, consent, plus submittedAt.
You have now proved the complete backend path. A direct request reaches the API before Lambda validates the payload and DynamoDB stores the resulting lead.
Lead missing from the scan?
- Confirm that the table name matches the LeadsTableName output from this deployment.
- Confirm that the direct request returned a generated lead ID before scanning.
- Confirm that your AWS CLI still targets the Region used for the stack.
Help me find the stored DynamoDB lead.
Your serverless backend now validates plus stores fake leads through a repeatable stack. Next, you will publish the existing website through CloudFront before testing that same API from Microsoft Edge.
Connect the Live Form
Your direct request from Windows PowerShell reached Amazon API Gateway. AWS Lambda stored the lead in Amazon DynamoDB.
A browser applies CORS rules when one origin calls another. You will connect the Amazon CloudFront site to the API. The browser will reveal whether that cross-origin request has permission.
In this step, get ready to:
- Point the form configuration at the deployed API.
- Upload the website files to the private site bucket.
- Submit the live form through CloudFront to diagnose the browser result.
Point the form at your API
The website/config.js file keeps the API address separate from the form handler. Replacing its placeholder lets the unchanged website/app.js send submissions to your deployed endpoint.
- Select config.js inside the website folder in the VS Code Explorer sidebar.
- Find this existing configuration:
window.APP_CONFIG = {
apiUrl: "your-api-endpoint-here"
};
What does this configuration do?
The apiUrl value tells app.js where to send the form request. The current placeholder activates the local message without making a network request.
- Replace your-api-endpoint-here with the stack's ApiUrl output.
- Save website/config.js with Ctrl+S.
- Compare the finished file with this structure:
window.APP_CONFIG = {
apiUrl: "<deployed ApiUrl output>"
};
What changed?
The form handler now reads your deployed POST /leads endpoint from window.APP_CONFIG. The handler itself remains unchanged.
✔️ Awesome, I've got everything!
Good progress. Your form configuration now points at the deployed API.
ⓧ I'd like to double check the full code
Compare your complete website/config.js file with this version:
window.APP_CONFIG = {
apiUrl: "<deployed ApiUrl output>"
};
What should match?
Your file should contain one window.APP_CONFIG object. Its apiUrl value should be the deployed API URL.
Publish the website through CloudFront
The live distribution reads static files from your private Amazon S3 bucket. Synchronizing the website folder places the latest form files behind that distribution.
- Switch back to the integrated PowerShell terminal in VS Code.
- Move from the event-lead-capture project root into website by running this command:
cd website
What does this command do?
PowerShell changes its current location to the website folder. The next command can use . to represent that folder.
- Synchronize the website files by replacing <WebsiteBucketName> with the stack's WebsiteBucketName output in this command:
aws s3 sync . s3://<WebsiteBucketName>
What does this command do?
The aws s3 sync command recursively copies new files to the named bucket. It also copies files whose local versions have changed.
The bucket stays private. CloudFront reads these objects through the origin access control you deployed earlier.
The output should list index.html plus styles.css. It should also list config.js plus app.js.
Website files not uploading?
Confirm that the bucket name matches the WebsiteBucketName stack output. Confirm that your terminal path ends with event-lead-capture\website.
Your existing AWS CLI credentials also need permission to upload objects to the bucket.
Help me troubleshoot the website upload.
- Return to the event-lead-capture project root by running this command:
cd ..
What does this command do?
The doubled period represents the parent folder. PowerShell returns the terminal to the event-lead-capture project root.
- Create a CloudFront invalidation by replacing <DistributionId> with the stack's DistributionId output in this command:
aws cloudfront create-invalidation --distribution-id <DistributionId> --paths "/*"
What does this command do?
The invalidation tells CloudFront to stop serving cached versions from every path. The next browser request can retrieve the synchronized files from the private bucket.
You should see details for a new invalidation in the terminal. CloudFront may need a short while to finish applying it.
Invalidation not created?
Confirm that <DistributionId> was replaced with the DistributionId output. The website URL is a different value.
Help me troubleshoot the CloudFront invalidation.
Test the browser boundary
The website now loads through one HTTPS origin. Its form sends the request to a separate API origin, so the browser checks the cross-origin permission before allowing the page to read the response.
- Return to Microsoft Edge.
- Select the address bar with Ctrl+L.
- Paste the stack's WebsiteUrl output.
- Press Enter.
- Confirm that the address begins with https://.
What does the live page prove?
Your customer-facing site is now live over HTTPS. CloudFront can read the static files from the private S3 origin.
- Open the Microsoft Edge Console with Ctrl+Shift+J.
- Enter Jamie Test in the Name field.
- Enter jamie@example.com in the Email field.
- Select Corporate event in the Event type field.
- Enter 120 in the Estimated guests field.
- Select the consent checkbox.
Before you submit, pause on this question: will the browser let the page read the API response.
- Click Request a consultation.
You will see We could not send your inquiry. below the form. The Edge Console will report a CORS failure for the cross-origin request.
This failure is intentional
The direct PowerShell request succeeded because command-line clients do not enforce browser CORS rules. Microsoft Edge checks whether the API grants permission to the CloudFront origin.
The API has no preflight configuration. The Lambda proxy response also lacks the required CORS headers, so the page cannot read the response.
Seeing a different result?
If the page still shows The form is ready. Connect the API next., confirm that the uploaded config.js contains your deployed ApiUrl output. Repeat the synchronization command after saving the file.
If the live page does not load, confirm that you used the WebsiteUrl output. Wait for the CloudFront invalidation to finish before refreshing.
Help me diagnose my live form result.
You have isolated the browser boundary. Next, you will grant the CloudFront origin explicit CORS access and prove that a live form submission reaches DynamoDB.
Grant CORS Access and Capture a Lead
Your live CloudFront form can reach the API URL. The browser's Cross-Origin Resource Sharing check blocks the response because the CloudFront origin has no permission yet.
In this step, you will grant that permission through API Gateway. You will also make every AWS Lambda response return the matching CORS headers.
In this step, get ready to:
- Restrict API access to the generated CloudFront origin.
- Return matching CORS headers from every Lambda proxy response.
- Redeploy the stack and capture a lead through the browser.
Allow the CloudFront origin
The browser sends a preflight request before the form's cross-origin submission. The API must allow the form's exact HTTPS origin before the browser releases the response.
Why restrict the origin?
A wildcard origin would let pages from any website read the API response. The generated CloudFront domain limits this permission to your deployed form.
- In template.yaml, find the current LeadApi block:
LeadApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
What is missing here?
The API has its prod stage. It has no preflight rules for the browser.
- Replace the current LeadApi block with this CORS-enabled version:
LeadApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Cors:
AllowMethods: "'POST,OPTIONS'"
AllowHeaders: "'Content-Type'"
AllowOrigin: !Sub "'https://${SiteDistribution.DomainName}'"
What does this configuration do?
- The Cors block allows form submissions plus browser preflight requests.
- The allowed header matches the form's JSON request content type.
- The generated CloudFront HTTPS domain becomes the only permitted origin.
- In the same file, find the current Environment block inside CreateLeadFunction:
Environment:
Variables:
TABLE_NAME: !Ref LeadsTable
What does Lambda receive now?
The function currently receives only the table name. It also needs the permitted browser origin so its responses can use the same value as the API.
- Replace the current Environment block with this version:
Environment:
Variables:
TABLE_NAME: !Ref LeadsTable
ALLOWED_ORIGIN: !Sub 'https://${SiteDistribution.DomainName}'
Why pass the origin to Lambda?
The ALLOWED_ORIGIN environment variable gives the function the exact CloudFront origin generated during deployment. The API preflight layer and function responses now share one source of truth.
- Save template.yaml by pressing Ctrl+S.
- Check the updated AWS SAM template by running this command from the project root:
sam build
What did this build check?
The build processes the updated template plus the current function package. Successful build output confirms that SAM can prepare this version of the stack.
Build not completing?
Confirm that the integrated terminal is still at the event-lead-capture project root. Check the spacing beneath both YAML blocks because indentation controls their structure.
Compare each quote around the CORS values with the snippets above.
Help me diagnose the template build failure.
✔️ I have the complete template
Your template.yaml now configures the API preflight layer plus the function's allowed origin.
ⓧ I'd like to double check the full code
Compare your complete template.yaml with this version:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless event lead capture website
Globals:
Function:
Runtime: nodejs24.x
Architectures:
- x86_64
MemorySize: 128
Timeout: 10
Resources:
LeadsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: leadId
AttributeType: S
KeySchema:
- AttributeName: leadId
KeyType: HASH
SiteBucket:
Type: AWS::S3::Bucket
Properties:
OwnershipControls:
Rules:
- ObjectOwnership: BucketOwnerEnforced
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
SiteCachePolicy:
Type: AWS::CloudFront::CachePolicy
Properties:
CachePolicyConfig:
Name: !Sub '${AWS::StackName}-site-cache'
DefaultTTL: 300
MaxTTL: 3600
MinTTL: 0
ParametersInCacheKeyAndForwardedToOrigin:
CookiesConfig:
CookieBehavior: none
EnableAcceptEncodingBrotli: true
EnableAcceptEncodingGzip: true
HeadersConfig:
HeaderBehavior: none
QueryStringsConfig:
QueryStringBehavior: none
SiteOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-oac'
Description: Signed access from CloudFront to the private site bucket
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
SiteDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: SiteBucketOrigin
DomainName: !GetAtt SiteBucket.RegionalDomainName
OriginAccessControlId: !GetAtt SiteOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
CachePolicyId: !Ref SiteCachePolicy
Compress: true
TargetOriginId: SiteBucketOrigin
ViewerProtocolPolicy: redirect-to-https
ViewerCertificate:
CloudFrontDefaultCertificate: true
SiteBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref SiteBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontServicePrincipal
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${SiteBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${SiteDistribution}'
LeadApi:
Type: AWS::Serverless::Api
Properties:
StageName: prod
Cors:
AllowMethods: "'POST,OPTIONS'"
AllowHeaders: "'Content-Type'"
AllowOrigin: !Sub "'https://${SiteDistribution.DomainName}'"
CreateLeadFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: src/
Handler: app.handler
Environment:
Variables:
TABLE_NAME: !Ref LeadsTable
ALLOWED_ORIGIN: !Sub 'https://${SiteDistribution.DomainName}'
Policies:
- Version: '2012-10-17'
Statement:
- Effect: Allow
Action: dynamodb:PutItem
Resource: !GetAtt LeadsTable.Arn
Events:
CreateLead:
Type: Api
Properties:
RestApiId: !Ref LeadApi
Path: /leads
Method: post
Outputs:
ApiUrl:
Description: POST endpoint for lead submissions
Value: !Sub 'https://${LeadApi}.execute-api.${AWS::Region}.amazonaws.com/prod/leads'
WebsiteBucketName:
Description: Bucket that stores the static site files
Value: !Ref SiteBucket
WebsiteUrl:
Description: HTTPS URL for the event inquiry website
Value: !Sub 'https://${SiteDistribution.DomainName}'
DistributionId:
Description: CloudFront distribution ID used for invalidations
Value: !Ref SiteDistribution
LeadsTableName:
Description: DynamoDB table that stores lead records
Value: !Ref LeadsTable
What should match?
The CORS origin in LeadApi matches the ALLOWED_ORIGIN value passed to CreateLeadFunction.
The function still has only dynamodb:PutItem permission on LeadsTable.
Return CORS headers from Lambda
The API can now answer the browser's preflight request. The actual Lambda proxy response must also name the allowed origin or the browser still hides it from the form.
- In src/app.mjs, select everything from const tableName through the closing brace of response().
- Replace the selected section with this CORS-enabled version:
const tableName = process.env.TABLE_NAME;
const allowedOrigin = process.env.ALLOWED_ORIGIN;
const allowedEventTypes = new Set(["corporate", "wedding", "private-party"]);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function response(statusCode, payload) {
return {
statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS"
},
body: JSON.stringify(payload)
};
}
What does this code do?
- The allowedOrigin constant reads the CloudFront origin from the function environment.
- The origin header grants the deployed website permission to read each response.
- The allowed headers value covers the form's JSON content type.
- The allowed methods value covers the form submission plus its preflight request.
- Save src/app.mjs by pressing Ctrl+S.
✔️ I have the complete Lambda code
Your response helper now applies the same CORS headers to successful responses plus safe error responses.
ⓧ I'd like to double check the full code
Compare your complete src/app.mjs with this version:
import { DynamoDBClient, PutItemCommand } from "@aws-sdk/client-dynamodb";
const dynamoDB = new DynamoDBClient({});
const tableName = process.env.TABLE_NAME;
const allowedOrigin = process.env.ALLOWED_ORIGIN;
const allowedEventTypes = new Set(["corporate", "wedding", "private-party"]);
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function response(statusCode, payload) {
return {
statusCode,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": allowedOrigin,
"Access-Control-Allow-Headers": "Content-Type",
"Access-Control-Allow-Methods": "POST,OPTIONS"
},
body: JSON.stringify(payload)
};
}
export const handler = async (event, context) => {
try {
const body = JSON.parse(event.body || "{}");
const name = typeof body.name === "string" ? body.name.trim() : "";
const email = typeof body.email === "string" ? body.email.trim().toLowerCase() : "";
const eventType = typeof body.eventType === "string" ? body.eventType : "";
const guestCount = Number(body.guestCount);
const consent = body.consent === true;
const isValid =
name.length >= 2 &&
name.length <= 80 &&
email.length <= 254 &&
emailPattern.test(email) &&
allowedEventTypes.has(eventType) &&
Number.isInteger(guestCount) &&
guestCount >= 1 &&
guestCount <= 5000 &&
consent;
if (!isValid) {
return response(400, {
message: "Check every field and confirm consent before submitting."
});
}
const leadId = context.awsRequestId;
await dynamoDB.send(
new PutItemCommand({
TableName: tableName,
Item: {
leadId: { S: leadId },
name: { S: name },
email: { S: email },
eventType: { S: eventType },
guestCount: { N: String(guestCount) },
consent: { BOOL: consent },
submittedAt: { S: new Date().toISOString() }
}
})
);
return response(201, {
message: "Thanks. Your event inquiry has been received.",
leadId
});
} catch (error) {
if (error instanceof SyntaxError) {
return response(400, { message: "Send a valid JSON request body." });
}
return response(500, {
message: "We could not save the inquiry. Please try again."
});
}
};
What should match?
The handler still validates only the approved form fields. It still writes only the generated lead ID plus approved lead data.
Every path through response() now returns the same three CORS headers.
- Rebuild the updated stack from the project root by running:
sam build
What does this build prepare?
SAM packages the updated function source with its pinned DynamoDB client. It also prepares the CORS-enabled infrastructure template for deployment.
Lambda build failing?
Confirm that allowedOrigin sits directly below tableName. Check that each response header remains inside the headers object.
Help me inspect the Lambda build failure.
Expect the deployment to take a few minutes while AWS CloudFormation updates the existing stack. The saved deployment settings keep this update tied to event-lead-capture.
- Deploy the rebuilt stack by running:
sam deploy
What should happen?
SAM updates the existing stack with the API preflight rules. It also deploys the function environment plus response headers.
Deployment not finishing?
Confirm that the AWS CLI session from earlier is still authenticated. Review the deployment events for the first resource that could not update.
If the template is rejected, compare template.yaml with the full-code tab above. If the function package fails, compare src/app.mjs with its full-code tab.
Help me diagnose the failed stack update.
Redeploy and capture a lead
The deployed API now recognizes the CloudFront website as its allowed origin. A successful browser submission proves that the website, API, function, and DynamoDB table can complete the full lead-capture path.
- Switch back to the WebsiteUrl tab in Microsoft Edge.
- Refresh the page.
- Enter Jamie Test in the Name field.
- Enter jamie@example.com in the Email field.
- Select Corporate event from the Event type field.
- Enter 120 in the Estimated guests field.
- Select the consent checkbox.
Before you submit, do you expect the browser to accept the response from the same API that failed in the previous step?
- Click Request a consultation.
You should see Thanks. Your event inquiry has been received. followed by a lead reference. The form should reset after the successful response.
Still seeing the send error?
Refresh the CloudFront page before trying again. An older browser tab may still hold the previous failed state.
Confirm that website/config.js still contains the deployed API URL. Check the stack deployment events if the API update did not complete.
Help me trace the remaining browser CORS failure.
Before you scan the table, do you expect to find a new item with the same generated lead reference shown by the form?
- Scan the deployed table after replacing <LeadsTableName> with the stack output in this command:
aws dynamodb scan --table-name <LeadsTableName>
What should I see?
You should see a new item for the browser submission. Its generated leadId should match the reference displayed by the form.
The item should contain the approved lead fields plus submittedAt. It should not contain extra request data.
New lead missing from the scan?
Confirm that you substituted the current LeadsTableName output. Check that the form displayed its success message before scanning.
If the form succeeded, compare its lead reference with the leadId values returned by the scan.
Help me find the missing DynamoDB lead.
You closed the browser's permission gap. Your HTTPS form now validates a fake inquiry and stores it through the complete serverless stack.
Secret mission
Prioritize High-Value Leads
Your lead funnel stores every valid inquiry with the same follow-up signal. Add automatic priority classification so the event team can identify high-value leads from the confirmation message and saved record.
Clean Up Your Resources
Clean Up Your Resources
Choose whether to keep the deployment available, pause testing for now, or delete it entirely. These AWS resources can continue to incur pay-as-you-go charges.
Cost Warning
Eligible allowances or credits may cover a small tutorial workload. Charges remain possible when those allowances or credits do not apply.
Stored data can create charges. Request traffic can also create charges.
Compute usage affects cost. Data transfer affects cost.
Invalidations beyond free allowances can add cost. A public unauthenticated endpoint can also receive unwanted traffic.
Resources you used:
- An Amazon CloudFormation stack named event-lead-capture manages the deployed architecture.
- A private Amazon S3 bucket stores the priority-enabled website. A scoped bucket policy allows reads only through CloudFront.
- An Amazon CloudFront distribution delivers the website over HTTPS. Its cache policy controls static asset caching. Its origin access control signs reads from S3.
- An Amazon API Gateway REST API exposes POST /leads with CORS restricted to the CloudFront origin.
- An AWS Lambda function validates incoming fields. It also classifies each lead by priority.
- An IAM execution role grants the Lambda function only DynamoDB write access.
- An on-demand Amazon DynamoDB table stores the fake lead records.
- AWS SAM deployment artifacts support the deployed stack.
Keep everything running
No action is needed. Choose this option if you are still demonstrating the lead funnel or extending the project.
- Retain the event-lead-capture stack for future demonstrations.
- Use only fake data for every inquiry.
- Monitor AWS Billing for unexpected usage.
- Add abuse protection before sending real traffic to the endpoint.
- Define retention rules before storing real customer data.
- Complete a privacy review before presenting the endpoint as production-ready.
Pause - I'll come back to this later
Stop public use to reduce serverless execution traffic. Stored data and configured resources remain deployed, so charges are still possible.
- Stop sharing the WebsiteUrl output.
- Stop sharing the ApiUrl output.
- Stop submitting test inquiries through the website.
- Continue monitoring AWS Billing while the stack remains deployed.
Delete - I don't want to use this again
Remove all deployed resources and start fresh. Your local event-lead-capture/ files stay available for a future deployment.
- Return to the integrated PowerShell terminal at the event-lead-capture/ project root.
- Replace <WebsiteBucketName> in the command below with the WebsiteBucketName stack output.
- Empty the private S3 bucket by running this command:
aws s3 rm s3://<WebsiteBucketName> --recursive
Why Empty the Bucket First?
CloudFormation can delete an S3 bucket only when the bucket is empty. This command recursively removes the deployed website files.
- Confirm PowerShell lists each removed website object.
Bucket Still Contains Objects?
- Confirm that <WebsiteBucketName> was replaced with the exact stack output.
- Confirm that the AWS CLI still uses the authenticated identity from this project.
- Help me troubleshoot why the S3 cleanup command did not empty my website bucket.
AWS SAM asks for confirmation before deleting the stack. The command also removes the associated deployment artifacts.
- Delete the deployed stack by running this command:
sam delete --stack-name event-lead-capture
What Does This Command Remove?
- CloudFormation removes the API Gateway REST API.
- CloudFormation removes the Lambda function.
- CloudFormation removes the DynamoDB table with its fake leads.
- CloudFormation removes the CloudFront resources and the empty S3 bucket.
- AWS SAM removes the associated deployment artifacts.
- Approve the deletion when AWS SAM asks for confirmation.
- Confirm PowerShell reports that the stack deletion completed.
Stack Deletion Did Not Complete?
- Confirm that the bucket cleanup command completed before the stack deletion began.
- Confirm that your authenticated AWS identity has permission to delete the stack resources.
- Help me troubleshoot why my SAM stack deletion did not complete.
That's a thorough cleanup. Your public website and lead API no longer accept requests.
Nice Work!
Nice Work!
You did it! Your three-tier serverless architecture on AWS now turns event inquiries into validated, prioritized leads.
What you learned:
- Built a responsive event inquiry form with required consent. Visitors see clear pending, success, or error feedback after each submission.
- Delivered a private Amazon S3 origin over HTTPS through Amazon CloudFront with CloudFront origin access control. Packaged the full architecture in a repeatable AWS SAM stack.
- Connected an Amazon API Gateway REST endpoint to AWS Lambda for validated lead creation in Amazon DynamoDB. Resolved a real browser CORS failure at the preflight layer plus the Lambda proxy response layer. Kept write access scoped through a least-privilege IAM role.
- Secret Mission: added automatic lead-priority classification with calculatePriority(). The browser confirmation shows each priority. DynamoDB stores the same classification with the lead.
Ready to quiz yourself?