Serverless Static Website with a Contact Form (S3 → API Gateway → Lambda → SES) — A Complete, Real-World Guide
Building a serverless contact form is one of the most common use cases for AWS — yet many tutorials gloss over the parts that actually break things in production: CORS, Lambda to API Gateway wiring, IAM permissions, and SES sandbox rules.
S3 static website → API Gateway → Lambda → SES email delivery

Architecture Overview The goal is to provide a robust contact form without maintaining any traditional servers.
You host your static site (HTML/CSS/JS) on Amazon S3. The contact form submits a POST /submit request to API Gateway. API Gateway invokes an AWS Lambda function using Lambda proxy integration. Lambda sends an email through Amazon Simple Email Service (SES). Lambda returns a JSON response with the necessary CORS headers so the browser accepts the successful response. This is simple, secure, and has zero servers to maintain.
Prerequisites Before starting, ensure you have the following ready in your AWS account (using us-east-1 in this example):
AWS Permissions: Access to S3, API Gateway, Lambda, IAM, and SES. SES Verification: The sender email is verified. If your SES account is still in the sandbox, the recipient email must also be verified. Configure the S3 Static Website Create the Bucket and Policy Go to S3 → Create bucket. Give it a unique name (e.g., contform-s3-website-us-east-1.amazonaws.com).
Disable “Block all public access” (you need this for static hosting). Apply a Bucket policy for public read access (replace the bucket name):
{ "Version": "2012-10-17", "Statement": [ { "Sid": "PublicReadGetObject", "Effect": "Allow", "Principal": "", "Action": ["s3:GetObject"], "Resource": ["arn:aws:s3:::my-contact-site-12345/"] } ] }
Enable Static Hosting Go to the Bucket → Properties → Static website hosting → Enable. Set Index document: index.html Configure SES (Email Service) This step ensures you’re authorized to send emails.
In SES → Verified identities, verify both the sender email (required) and the recipient email (if you are in the SES sandbox). You will receive a verification email to the address you enter. Click the link to confirm.
Create the Lambda Function This Python function will receive the contact for data and send the email via SES.
Create IAM Role This role gives the Lambda function permission to interact with other AWS services, specifically SES and CloudWatch Logs.
IAM → Roles → Create role. Trusted entity type: AWS service. Use case: Select Lambda. In Add permissions, search for ses and select AmazonSESFullAccess. Note: For production, use a more restrictive policy, but AmazonSESFullAccess simplifies the initial setup.
Create the Lambda Function Lambda → Create function. Name: ContactFormSender. Runtime: Python 3.11 (or latest). Execution role: Choose the IAM role you just created (lambda-ses-send-role). Add these Environment variables to the Lambda’s configuration:
Key,Value,Description SENDER_EMAIL,your verified SES sender,Email used as the source of the message. RECIPIENT_EMAIL,the business owner's email,Email where the contact form submission will go. SES_REGION,us-east-1,The AWS region where SES is configured.
Lambda Code (Python) Replace the code in lambda_function.py with the following. Pay close attention to the CORS headers in the _response function, which are crucial for the frontend
import json import boto3 import logging import os from botocore.exceptions import ClientError
logger = logging.getLogger() logger.setLevel(logging.INFO)
SES_REGION = os.environ.get("SES_REGION", "us-east-1") SENDER = os.environ.get("SENDER_EMAIL") RECIPIENT = os.environ.get("RECIPIENT_EMAIL")
ses = boto3.client('ses', region_name=SES_REGION)
def lambda_handler(event, context): logger.info("Event: %s", event)
try:
# API Gateway uses Lambda Proxy Integration, so the body is a string
body = event.get("body")
data = json.loads(body)
except Exception:
return _response(400, {"error": "Invalid JSON body"})
name = (data.get("name") or "").strip()
sender_email = (data.get("email") or "").strip()
message = (data.get("message") or "").strip()
if not name or not sender_email or not message:
return _response(400, {"error": "name, email and message are required"})
subject = f"Contact form submission from {name}"
body_text = f"Name: {name}\nEmail: {sender_email}\n\nMessage:\n{message}"
try:
ses.send_email(
Source=SENDER,
Destination={'ToAddresses': [RECIPIENT]},
Message={
'Subject': {'Data': subject},
'Body': {'Text': {'Data': body_text}}
},
ReplyToAddresses=[sender_email]
)
except ClientError:
logger.exception("SES send failed")
return _response(500, {"error": "Failed to send email"})
return _response(200, {"message": "Email sent"})
def _response(status_code, body): # For browsers (CORS): include Access-Control-Allow-Origin header # Allow site origin. The specific origin is more secure than "*". return { "statusCode": status_code, "headers": { "Content-Type": "application/json", "Access-Control-Allow-Origin": "http://contform-s3-website-us-east-1.amazonaws.com", "Access-Control-Allow-Headers": "Content-Type,X-Amz-Date,Authorization,X-Api-Key,X-Amz-Security-Token", "Access-Control-Allow-Methods": "OPTIONS,POST" }, "body": json.dumps(body) }
Replace the Access-Control-Allow-Origin value with your actual S3 static website endpoint.
Create API Gateway (REST API)
While HTTP API is simpler, we’ll demonstrate a REST API setup for maximum control over CORS, as shown in your images.
Create the API API Gateway → Create API → REST API. Choose New API. API name: lambses.
Create Resource and Method Select the root resource (/) and Create resource. Resource Name: submit. Select the new /submit resource and Create method → POST. Integration type: Lambda Function. Lambda Region: us-east-1. Lambda Function: ContactFormSender.
Enable CORS on the Resource CORS is mandatory for the browser to allow the cross-domain request from S3 to API Gateway.
Select the /submit resource. Click Actions → Enable CORS. 3. Configure the CORS settings: * Gateway responses: Check Default 4XX and Default 5XX. * Access-Control-Allow-Origin: Enter your S3 website domain exactly (e.g., http://contform-s3-website-us-east-1.amazonaws.com). * Note: In production, do not use *.
- Click Save. This action automatically creates the necessary OPTIONS method for pre-flight requests.
Deploy the API Click Actions → Deploy API. Deployment stage: [New Stage] Stage name: prod. Copy the Invoke URL from the stage details — you will use this in your frontend JavaScript.
Frontend: HTML + CSS Form Upload these files to your S3 bucket. Remember to replace YOUR_API_ID.execute-api.us-east-1.amazonaws.com/prod/submit with your actual Invoke URL.
<title>Serverless Contact Form</title> <form id="contact-form">
<label for="name">Name</label>
<input type="text" id="name" name="name" required>
<label for="email">Email</label>
<input type="email" id="email" name="email" required>
<label for="subject">Subject</label>
<input type="text" id="subject" name="subject" required>
<label for="message">Message</label>
<textarea id="message" name="message" rows="5" required></textarea>
<button type="submit" id="submit-button">Send Message</button>
<p id="form-status" aria-live="polite"></p>
</form>
</div>
<script>
// *** IMPORTANT: REPLACE THIS PLACEHOLDER WITH YOUR DEPLOYED API GATEWAY INVOKE URL ***
const API_ENDPOINT = 'https://****.execute-api.us-east-1.amazonaws.com/prod/submit';
const form = document.getElementById('contact-form');
const statusMessage = document.getElementById('form-status');
const submitButton = document.getElementById('submit-button');
form.addEventListener('submit', function(event) {
// Prevent the default form submission (which would refresh the page)
event.preventDefault();
// Collect form data
const formData = new FormData(form);
const payload = {};
formData.forEach((value, key) => {
payload[key] = value;
});
// Show loading state
statusMessage.textContent = 'Sending...';
submitButton.disabled = true;
fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload) // Convert the data object to a JSON string
})
.then(response => {
// Check for a successful response (status 200-299)
if (response.ok) {
return response.json(); // Process the JSON response from Lambda/API Gateway
}
// Handle HTTP error responses
throw new Error(`Submission failed: ${response.status} ${response.statusText}`);
})
.then(data => {
// Success
statusMessage.textContent = '✅ Message sent successfully! We will be in touch soon.';
form.reset(); // Clear the form fields
})
.catch(error => {
// Error handling
console.error('Error submitting form:', error);
statusMessage.textContent = `❌ An error occurred: ${error.message}. Please try again.`;
})
.finally(() => {
// Re-enable the button after process completes
submitButton.disabled = false;
});
});
</script>
Checklist for Success API Gateway must have the OPTIONS method configured (done automatically when you click Enable CORS). Lambda must return the correct Access-Control-Allow-Origin header in its response. This is set in the Python code's _response function. The Allowed origin in both API Gateway and Lambda must exactly match your S3 website domain (including the http:// or https://) and must not have a trailing slash. Final Checklist Before Launch SES sender + recipient verified. Lambda IAM role includes ses:SendEmail and CloudWatch Logs permissions. API Gateway CORS enabled on the /submit resource. Lambda returns CORS headers in the response function. S3 website origin matches the Access-Control-Allow-Origin value. Fun Trouble — CORS messed me up like four hours straight… the joy that it eventually worked.
Happy Troubleshooting.