How to Deploy a Serverless Contact Form with AWS SAM, Lambda, and DynamoDB
Before you begin
- An AWS account with permissions to create SAM stacks, Lambda functions, DynamoDB tables, API Gateway APIs, and SES identities
- AWS CLI and SAM CLI installed and configured
- Node.js 22 installed locally
- A verified SES sender domain or email address
- A static site where you want to add a contact form (Astro, Next.js, Hugo, or similar)
- Basic familiarity with TypeScript and AWS CloudFormation
What you'll learn
- Define a complete serverless API with SAM including API Gateway, Lambda, DynamoDB, and SES
- Design a DynamoDB single-table schema with composite keys, TTL, and partition-based multi-tenancy
- Implement input validation with a schema registry pattern
- Add DynamoDB-backed rate limiting with a fail-closed design
- Handle SES bounces and complaints with an SNS-triggered suppression list
- Set up CloudWatch metric filters and alarms for production monitoring
- Integrate the backend with a static site frontend using honeypot spam protection
- Support multiple sites from a single deployment using a site registry
On this page
This tutorial walks through building and deploying a production-grade serverless contact form backend using AWS SAM. The architecture uses API Gateway for CORS and routing, Lambda for request handling, DynamoDB for storage and rate limiting, and SES for email notifications. By the end, you will have a backend that supports multiple sites from a single deployment, includes rate limiting and spam prevention, handles email bounces automatically, and costs near-zero for typical traffic.
For the business case behind this architecture, see Serverless Contact Forms with AWS SAM: Why They Win on Cost, Security, and Simplicity.
Start by creating the project directory and installing the dependencies:
mkdir serverless-form-api
cd serverless-form-api
npm init -y
npm pkg set type="module"
npm install @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/client-ses
npm install -D typescript @types/aws-lambda esbuild
npx tsc --init --target ES2022 --module NodeNext --moduleResolution NodeNext --outDir dist --rootDir src --skipLibCheck
Tested with: AWS SAM CLI 1.131, Node.js 22.x, TypeScript 5.7, AWS SDK v3, API Gateway HTTP API (v2). AWS pricing and service behavior cited as of March 2026.
Step 1: Set up the SAM project and template
The SAM template defines the entire infrastructure stack in a single file. Start with the global settings, parameters, and API Gateway configuration.
Create template.yaml at the project root:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless form submission API with multi-site support
Globals:
Function:
Runtime: nodejs22.x
Timeout: 30
MemorySize: 256
Architectures:
- arm64
Environment:
Variables:
TABLE_NAME: !Ref SubmissionsTable
SUPPRESSION_TABLE: !Ref SuppressionTable
ALLOWED_ORIGINS: !Ref AllowedOrigins
NOTIFY_EMAIL: !Ref NotifyEmail
SES_FROM_EMAIL: !Ref SesFromEmail
Parameters:
AllowedOrigins:
Type: String
Default: "http://localhost:4321,http://localhost:8080"
Description: Comma-separated list of allowed CORS origins
NotifyEmail:
Type: String
Default: "admin@example.com"
Description: Email address to receive form submission notifications
SesFromEmail:
Type: String
Default: "noreply@example.com"
Description: Verified SES sender email address
The Globals section sets defaults for all Lambda functions: Node.js 22 runtime, ARM64 architecture for better price-performance, and shared environment variables. The Parameters section makes deployment-time configuration explicit.
Now add the API Gateway resource:
Resources:
HttpApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: prod
CorsConfiguration:
AllowOrigins: !Split [",", !Ref AllowedOrigins]
AllowMethods:
- GET
- POST
- OPTIONS
AllowHeaders:
- Content-Type
- x-api-key
MaxAge: 86400
The CorsConfiguration uses !Split to turn the comma-separated parameter into a list. This makes it easy to add new site origins without modifying the template. The MaxAge of 86400 seconds (24 hours) tells browsers to cache preflight responses, reducing the number of OPTIONS requests.
Use !Split with a parameter instead of hardcoding origins in the template. This way you can update the CORS allowlist during deployment without changing infrastructure code.
This CORS setup works because SAM generates an implicit OpenAPI definition for HttpApi and merges CorsConfiguration into it. If you later add a DefinitionBody with your own OpenAPI spec, SAM cannot modify it and CorsConfiguration will be silently ignored. In that case, define CORS directly in your OpenAPI spec instead.
Add the submit function:
SubmitFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/submit.handler
CodeUri: dist/
Events:
PostSubmission:
Type: HttpApi
Properties:
ApiId: !Ref HttpApi
Path: /submissions
Method: POST
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref SubmissionsTable
- DynamoDBReadPolicy:
TableName: !Ref SuppressionTable
- Version: "2012-10-17"
Statement:
- Effect: Allow
Action: ses:SendEmail
Resource:
- !Sub "arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/*"
Notice the IAM policies: DynamoDBCrudPolicy scoped to only the submissions table, DynamoDBReadPolicy for the suppression table (used to check email suppression status), and SES permissions scoped to the account’s verified identities. The function cannot access any other DynamoDB table or send email from any other account.
Step 2: Design the DynamoDB schema
The submissions table uses a composite key design that supports multi-site isolation and time-range queries from a single table.
Add both tables to the Resources section of your template:
SubmissionsTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
- AttributeName: SK
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
- AttributeName: SK
KeyType: RANGE
TimeToLiveSpecification:
AttributeName: ttl
Enabled: true
SuppressionTable:
Type: AWS::DynamoDB::Table
Properties:
BillingMode: PAY_PER_REQUEST
AttributeDefinitions:
- AttributeName: PK
AttributeType: S
KeySchema:
- AttributeName: PK
KeyType: HASH
The submissions table key design works like this:
- PK (partition key):
SITE#myapp— isolates each site’s data into its own partition - SK (sort key):
SUB#2026-03-12T10:30:00.000Z#a1b2c3d4— combines ISO timestamp and UUID for natural chronological ordering with zero collision risk
This design enables efficient queries: “get all submissions for site X in the last 7 days” is a single partition query with a sort key range condition. No scan, no filter, no secondary index.
The suppression table is simpler: PK is the email address (lowercased), and each item stores the suppression reason and timestamp.
Why PAY_PER_REQUEST? Contact form traffic is spiky and low-volume. Provisioned capacity would waste money during idle periods and require capacity planning for a workload that does not need it. On-demand billing costs about $1.25 per million write request units.
TTL is set to 90 days on each submission. DynamoDB automatically deletes expired items in the background. No cron jobs, no cleanup scripts, no indefinite storage growth.
Now create the TypeScript types. Create src/types.ts:
export interface Submission {
PK: string;
SK: string;
site: string;
type: string;
email: string;
data: Record<string, unknown>;
createdAt: string;
ip: string;
ttl: number;
}
export interface SubmissionInput {
site: string;
type: string;
email: string;
data: Record<string, unknown>;
}
export interface ValidationSchema {
requiredDataFields: string[];
}
export interface SiteConfig {
name: string;
notifyEmail: string;
allowedTypes: string[];
fromEmail: string;
replyTo: string;
}
Step 3: Build the submit handler
The submit handler is the core of the API. It validates input, checks rate limits, stores the submission, sends a notification, and returns the result.
Before writing the handler, create the response utility it depends on. Create src/utils/response.ts:
import type { APIGatewayProxyResultV2 } from "aws-lambda";
const corsHeaders = (origin?: string) => {
const allowed = (process.env.ALLOWED_ORIGINS || "")
.split(",")
.map((o) => o.trim());
const matchedOrigin = origin && allowed.includes(origin) ? origin : "";
return {
"Access-Control-Allow-Origin": matchedOrigin,
"Access-Control-Allow-Headers": "Content-Type, x-api-key",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Strict-Transport-Security": "max-age=63072000; includeSubDomains",
};
};
export function created(
body: unknown,
origin?: string
): APIGatewayProxyResultV2 {
return {
statusCode: 201,
headers: { "Content-Type": "application/json", ...corsHeaders(origin) },
body: JSON.stringify(body),
};
}
export function badRequest(
message: string,
origin?: string
): APIGatewayProxyResultV2 {
return {
statusCode: 400,
headers: { "Content-Type": "application/json", ...corsHeaders(origin) },
body: JSON.stringify({ error: message }),
};
}
export function tooManyRequests(origin?: string): APIGatewayProxyResultV2 {
return {
statusCode: 429,
headers: {
"Content-Type": "application/json",
"Retry-After": "3600",
...corsHeaders(origin),
},
body: JSON.stringify({
error: "Too many requests. Please try again later.",
}),
};
}
export function serverError(origin?: string): APIGatewayProxyResultV2 {
return {
statusCode: 500,
headers: { "Content-Type": "application/json", ...corsHeaders(origin) },
body: JSON.stringify({ error: "Internal server error" }),
};
}
The corsHeaders function only returns the requesting origin if it is in the allowlist. Unknown origins get an empty string, which causes the browser to reject the response. Security headers (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security) are included on every response.
Now create the handler. Create src/handlers/submit.ts:
import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda";
import { randomUUID } from "node:crypto";
import { validateSubmission } from "../middleware/validate.js";
import { isRateLimited } from "../middleware/rateLimit.js";
import { notifySubmission } from "../middleware/notify.js";
import { putSubmission } from "../services/dynamo.js";
import { isSuppressed } from "../services/suppression.js";
import { created, badRequest, tooManyRequests, serverError } from "../utils/response.js";
import type { Submission } from "../types.js";
export async function handler(
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> {
const requestId = event.requestContext?.requestId || "unknown";
const origin = event.headers?.origin;
// Validate input
const result = validateSubmission(event.body);
if (!result.valid || !result.parsed) {
return badRequest(result.error || "Invalid input", origin);
}
const { site, type, email, data } = result.parsed;
// Suppression check — fail open (don't block if check fails)
try {
if (await isSuppressed(email)) {
console.log(JSON.stringify({ event: "suppressed_submission", requestId, site, email }));
return created({ id: randomUUID(), createdAt: new Date().toISOString() }, origin);
}
} catch (err) {
console.error(JSON.stringify({
level: "ERROR", event: "suppression_check_failed", requestId, site, email,
error: err instanceof Error ? err.message : String(err),
}));
}
// Rate limit check — fail closed
try {
if (await isRateLimited(site, email, requestId)) {
console.log(JSON.stringify({ event: "rate_limit_hit", requestId, site, email }));
return tooManyRequests(origin);
}
} catch (err) {
console.error(JSON.stringify({
level: "ERROR", event: "rate_limit_check_failed", requestId, site, email,
error: err instanceof Error ? err.message : String(err),
}));
return serverError(origin);
}
const now = new Date().toISOString();
const id = randomUUID();
const ip = event.requestContext?.http?.sourceIp || "unknown";
const TTL_DAYS = 90;
const ttl = Math.floor(Date.now() / 1000) + TTL_DAYS * 86400;
const submission: Submission = {
PK: `SITE#${site}`,
SK: `SUB#${now}#${id}`,
site,
type,
email,
data: data || {},
createdAt: now,
ip,
ttl,
};
try {
await putSubmission(submission, requestId);
} catch (err) {
console.error(JSON.stringify({
level: "ERROR", event: "submission_store_failed", requestId, site, email,
error: err instanceof Error ? err.message : String(err),
}));
return serverError(origin);
}
console.log(JSON.stringify({ event: "submission_created", requestId, site, type, email, id }));
// Await notification to prevent Lambda freeze from cutting it off
try {
await notifySubmission(submission, requestId);
} catch (err) {
console.error(JSON.stringify({
level: "ERROR", event: "notification_failed", requestId, site, email,
error: err instanceof Error ? err.message : String(err),
}));
}
return created({ id, createdAt: now }, origin);
}
Key design decisions in this handler:
- Structured JSON logging: every
console.logandconsole.errorcall emits JSON with aneventfield that matches the CloudWatch metric filters defined in the template. This makes the alarms functional - Suppression check before rate limiting: suppressed emails get a fake success response so the sender cannot discover which addresses are suppressed. The check fails open — if DynamoDB is down, submissions still go through
- Fail-closed rate limiting: the
catchblock on the rate limit check returns a server error instead of allowing the request through. This prevents abuse during partial outages - Await the notification: Lambda can freeze the execution context after the response is returned, which may cut off in-flight async operations like email sends. Awaiting the notification ensures it completes before the function exits
- Notification errors do not fail the submission: the submission is already stored in DynamoDB. A notification failure is logged but does not cause a user-facing error
Step 4: Add validation and rate limiting
The validation layer uses two registries: a schema registry that defines required fields per submission type, and a site registry that defines per-site configuration.
Create src/config/schemas.ts:
import type { ValidationSchema } from "../types.js";
export const schemas: Record<string, ValidationSchema> = {
contact: {
requiredDataFields: ["name", "message"],
},
download: {
requiredDataFields: ["resource"],
},
};
Create src/config/sites.ts:
import type { SiteConfig } from "../types.js";
export const sites: Record<string, SiteConfig> = {
myapp: {
name: "My App",
notifyEmail: process.env.NOTIFY_EMAIL || "admin@example.com",
allowedTypes: ["contact", "download"],
fromEmail: process.env.SES_FROM_EMAIL || "noreply@example.com",
replyTo: "hello@example.com",
},
// Add more sites here — each gets its own config
// secondsite: {
// name: "Second Site",
// notifyEmail: process.env.NOTIFY_EMAIL || "admin@example.com",
// allowedTypes: ["contact"],
// fromEmail: process.env.SES_FROM_EMAIL || "noreply@example.com",
// replyTo: "support@secondsite.com",
// },
};
Adding a new site is a config change: add an entry to the sites object and add its origin to the ALLOWED_ORIGINS parameter. No new Lambda functions, no new routes.
Create the validation middleware at src/middleware/validate.ts:
import { schemas } from "../config/schemas.js";
import { sites } from "../config/sites.js";
import type { SubmissionInput } from "../types.js";
export interface ValidationResult {
valid: boolean;
error?: string;
parsed?: SubmissionInput;
}
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
export function validateSubmission(
body: string | null | undefined
): ValidationResult {
if (!body) return { valid: false, error: "Request body is required" };
let parsed: unknown;
try {
parsed = JSON.parse(body);
} catch {
return { valid: false, error: "Invalid JSON" };
}
const input = parsed as Record<string, unknown>;
// Required top-level fields
if (!input.site || typeof input.site !== "string") {
return { valid: false, error: "Field 'site' is required" };
}
if (!input.type || typeof input.type !== "string") {
return { valid: false, error: "Field 'type' is required" };
}
if (
!input.email ||
typeof input.email !== "string" ||
!EMAIL_RE.test(input.email)
) {
return { valid: false, error: "A valid 'email' is required" };
}
// Check site exists
const siteConfig = sites[input.site];
if (!siteConfig) {
return { valid: false, error: "Invalid site" };
}
// Check type is allowed for this site
if (!siteConfig.allowedTypes.includes(input.type)) {
return { valid: false, error: "Invalid type for this site" };
}
// Validate data fields against schema
const schema = schemas[input.type];
const data = (
input.data && typeof input.data === "object" ? input.data : {}
) as Record<string, unknown>;
// Limit data payload size
const dataKeys = Object.keys(data);
if (dataKeys.length > 20) {
return { valid: false, error: "Too many data fields (max 20)" };
}
for (const [key, val] of Object.entries(data)) {
if (key.length > 100) {
return { valid: false, error: `Data key too long (max 100 chars)` };
}
if (typeof val === "string" && val.length > 10_000) {
return {
valid: false,
error: `Field 'data.${key}' exceeds max length (10000)`,
};
}
}
if (schema?.requiredDataFields) {
for (const field of schema.requiredDataFields) {
if (data[field] === undefined || data[field] === null) {
return {
valid: false,
error: `Field 'data.${field}' is required for type '${input.type}'`,
};
}
}
}
return {
valid: true,
parsed: { site: input.site, type: input.type, email: (input.email as string).toLowerCase(), data },
};
}
The validation flow checks fields in order of cost: simple type checks first, then site registry lookup, then type allowlist, then schema validation, then size limits. Each check rejects immediately on failure.
Now create the rate limiter at src/middleware/rateLimit.ts:
import { countRecentByEmail } from "../services/dynamo.js";
const WINDOW_MS = 60 * 60 * 1000; // 1 hour
const MAX_PER_EMAIL = 5;
export async function isRateLimited(
site: string,
email: string,
requestId: string
): Promise<boolean> {
const count = await countRecentByEmail(site, email, WINDOW_MS, requestId);
return count >= MAX_PER_EMAIL;
}
The rate limiter counts recent submissions from the same email address within the same site over the last hour. Five per email per site per hour is enough for legitimate use. The handler’s catch block makes this fail-closed: if the DynamoDB query fails, the submission is rejected.
Create the DynamoDB service at src/services/dynamo.ts:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
QueryCommand,
} from "@aws-sdk/lib-dynamodb";
import type { Submission } from "../types.js";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.TABLE_NAME || "form-submissions";
export async function putSubmission(
item: Submission,
requestId: string
): Promise<void> {
await client.send(new PutCommand({ TableName: TABLE, Item: item }));
}
export async function countRecentByEmail(
site: string,
email: string,
windowMs: number,
requestId: string
): Promise<number> {
const cutoff = new Date(Date.now() - windowMs).toISOString();
const result = await client.send(
new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "PK = :pk AND SK >= :cutoff",
FilterExpression: "email = :email",
ExpressionAttributeValues: {
":pk": `SITE#${site}`,
":cutoff": `SUB#${cutoff}`,
":email": email,
},
Select: "COUNT",
})
);
return result.Count || 0;
}
The rate limit query uses the partition key (SITE#myapp) and a sort key range condition (SK >= SUB#<cutoff>) to scope the count to recent submissions only. The FilterExpression narrows to the specific email address. DynamoDB handles this efficiently because the query is already scoped to a single partition and a time range.
The rate limiter is fail-closed. If the DynamoDB count query fails for any reason, the submission is rejected with a server error. This prevents abuse during partial outages. Never fail open on a rate limit check.
Step 5: Set up SES email notifications
When a form submission arrives, the backend sends a notification email to the site admin. This uses SES with per-site sender configuration.
Create the notification middleware at src/middleware/notify.ts:
import { sites } from "../config/sites.js";
import { sendNotification } from "../services/ses.js";
import type { Submission } from "../types.js";
export async function notifySubmission(
submission: Submission,
requestId: string
): Promise<void> {
const siteConfig = sites[submission.site];
if (!siteConfig) return;
await sendNotification(
{
siteName: siteConfig.name,
type: submission.type,
email: submission.email,
data: submission.data,
createdAt: submission.createdAt,
ip: submission.ip,
fromEmail: siteConfig.fromEmail,
replyTo: siteConfig.replyTo,
notifyEmail: siteConfig.notifyEmail,
},
requestId
);
}
Create the SES service at src/services/ses.ts:
import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses";
const ses = new SESClient({});
interface NotificationData {
siteName: string;
type: string;
email: string;
data: Record<string, unknown>;
createdAt: string;
ip: string;
fromEmail: string;
replyTo: string;
notifyEmail: string;
}
export async function sendNotification(
payload: NotificationData,
requestId: string
): Promise<void> {
const subject = `New ${payload.type} submission from ${payload.siteName}`;
const dataLines = Object.entries(payload.data)
.map(([key, val]) => `${key}: ${String(val)}`)
.join("\n");
const body = [
`New ${payload.type} submission received`,
``,
`Site: ${payload.siteName}`,
`Email: ${payload.email}`,
`Time: ${payload.createdAt}`,
`IP: ${payload.ip}`,
``,
`Data:`,
dataLines,
].join("\n");
const fromEmail = payload.fromEmail || process.env.SES_FROM_EMAIL || "noreply@example.com";
const notifyEmail = payload.notifyEmail || process.env.NOTIFY_EMAIL || "admin@example.com";
await ses.send(
new SendEmailCommand({
Source: fromEmail,
Destination: { ToAddresses: [notifyEmail] },
ReplyToAddresses: payload.replyTo ? [payload.replyTo] : undefined,
Message: {
Subject: { Data: subject },
Body: { Text: { Data: body } },
},
})
);
}
Stay in the SES sandbox during development. You can only send to verified addresses, which prevents accidental email sends during testing. Request production sending access only when you are ready to go live.
The submit handler checks isSuppressed() before storing or notifying, so suppressed addresses are silently accepted without triggering emails. The bounce handler in the next step populates the suppression table automatically.
Step 6: Handle bounces and complaints
SES tracks your sending reputation. If your bounce rate exceeds 5 percent or your complaint rate exceeds 0.1 percent, SES may suspend your sending. Automatic suppression handling is not optional for production use.
The architecture uses two SNS topics — one for bounces, one for complaints — that trigger a single Lambda function. The function parses the SES notification and adds permanently bounced or complained addresses to the suppression table.
Add the SNS topics and bounce handler to your template:
BounceTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: ses-bounces
ComplaintTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: ses-complaints
BounceHandlerFunction:
Type: AWS::Serverless::Function
Properties:
Handler: handlers/bounceHandler.handler
CodeUri: dist/
Events:
BounceEvent:
Type: SNS
Properties:
Topic: !Ref BounceTopic
ComplaintEvent:
Type: SNS
Properties:
Topic: !Ref ComplaintTopic
Policies:
- DynamoDBCrudPolicy:
TableName: !Ref SuppressionTable
Create the bounce handler at src/handlers/bounceHandler.ts:
import type { SNSEvent } from "aws-lambda";
import { addSuppression } from "../services/suppression.js";
export async function handler(event: SNSEvent): Promise<void> {
for (const record of event.Records) {
const requestId = record.Sns.MessageId || "unknown";
let message: Record<string, unknown>;
try {
message = JSON.parse(record.Sns.Message);
} catch {
console.error(JSON.stringify({
level: "ERROR", event: "sns_parse_failed", requestId,
}));
continue;
}
try {
if (message.notificationType === "Bounce") {
const bounce = message.bounce as Record<string, unknown> | undefined;
if (bounce?.bounceType === "Permanent") {
const recipients = bounce.bouncedRecipients as
| Array<{ emailAddress: string }>
| undefined;
for (const recipient of recipients ?? []) {
await addSuppression(
recipient.emailAddress,
"bounce",
`${bounce.bounceType}/${bounce.bounceSubType ?? "unknown"}`
);
console.log(JSON.stringify({
event: "bounce_processed", requestId,
email: recipient.emailAddress,
bounceType: bounce.bounceType,
bounceSubType: bounce.bounceSubType ?? "unknown",
}));
}
}
}
if (message.notificationType === "Complaint") {
const complaint = message.complaint as
| Record<string, unknown>
| undefined;
const recipients = complaint?.complainedRecipients as
| Array<{ emailAddress: string }>
| undefined;
for (const recipient of recipients ?? []) {
await addSuppression(
recipient.emailAddress,
"complaint",
(complaint?.complaintFeedbackType as string) || "unknown"
);
console.log(JSON.stringify({
event: "complaint_processed", requestId,
email: recipient.emailAddress,
feedbackType: (complaint?.complaintFeedbackType as string) || "unknown",
}));
}
}
} catch (err) {
console.error(JSON.stringify({
level: "ERROR", event: "bounce_handler_failed", requestId,
notificationType: message.notificationType,
error: err instanceof Error ? err.message : String(err),
}));
}
}
}
Create the suppression service at src/services/suppression.ts:
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient, PutCommand, GetCommand } from "@aws-sdk/lib-dynamodb";
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.SUPPRESSION_TABLE || "email-suppressions";
export async function addSuppression(
email: string,
reason: string,
detail: string
): Promise<void> {
await client.send(
new PutCommand({
TableName: TABLE,
Item: {
PK: `EMAIL#${email.toLowerCase()}`,
email: email.toLowerCase(),
reason,
detail,
suppressedAt: new Date().toISOString(),
},
})
);
}
export async function isSuppressed(email: string): Promise<boolean> {
const result = await client.send(
new GetCommand({
TableName: TABLE,
Key: { PK: `EMAIL#${email.toLowerCase()}` },
})
);
return !!result.Item;
}
After connecting the SNS topics to your SES configuration set (done in the AWS console or via CLI), every permanent bounce and complaint will automatically suppress the affected email address. Future sends check the suppression table before sending.
SES will suspend your sending if your bounce rate exceeds 5 percent or your complaint rate exceeds 0.1 percent. Automatic suppression is not optional — it is required for production use.
Step 7: Add monitoring and alarms
Production monitoring is defined in the same SAM template. CloudWatch log groups get 30-day retention. Metric filters parse structured JSON logs and create custom metrics. Alarms fire when thresholds are exceeded and notify via SNS email.
Add log groups for each function:
SubmitFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${SubmitFunction}"
RetentionInDays: 30
BounceHandlerFunctionLogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: !Sub "/aws/lambda/${BounceHandlerFunction}"
RetentionInDays: 30
Add metric filters that parse structured JSON logs:
SubmitErrorsMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref SubmitFunctionLogGroup
FilterPattern: '{ $.level = "ERROR" }'
MetricTransformations:
- MetricName: SubmitErrors
MetricNamespace: FormApi
MetricValue: "1"
DefaultValue: 0
RateLimitMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref SubmitFunctionLogGroup
FilterPattern: '{ $.event = "rate_limit_hit" }'
MetricTransformations:
- MetricName: RateLimitHits
MetricNamespace: FormApi
MetricValue: "1"
DefaultValue: 0
SubmissionsCreatedMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref SubmitFunctionLogGroup
FilterPattern: '{ $.event = "submission_created" }'
MetricTransformations:
- MetricName: SubmissionsCreated
MetricNamespace: FormApi
MetricValue: "1"
DefaultValue: 0
BounceMetricFilter:
Type: AWS::Logs::MetricFilter
Properties:
LogGroupName: !Ref BounceHandlerFunctionLogGroup
FilterPattern: '{ $.event = "bounce_processed" }'
MetricTransformations:
- MetricName: BouncesProcessed
MetricNamespace: FormApi
MetricValue: "1"
DefaultValue: 0
Add alarms that notify when something is wrong:
AlarmTopic:
Type: AWS::SNS::Topic
Properties:
TopicName: form-api-alarms
Subscription:
- Protocol: email
Endpoint: !Ref NotifyEmail
HighErrorRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: FormApi-HighErrorRate
AlarmDescription: "More than 10 errors in 5 minutes"
Namespace: FormApi
MetricName: SubmitErrors
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 10
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
HighBounceRateAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: FormApi-HighBounceRate
AlarmDescription: "More than 5 bounces in 1 hour"
Namespace: FormApi
MetricName: BouncesProcessed
Statistic: Sum
Period: 3600
EvaluationPeriods: 1
Threshold: 5
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
RateLimitAbuseAlarm:
Type: AWS::CloudWatch::Alarm
Properties:
AlarmName: FormApi-RateLimitAbuse
AlarmDescription: "More than 20 rate limit hits in 5 minutes"
Namespace: FormApi
MetricName: RateLimitHits
Statistic: Sum
Period: 300
EvaluationPeriods: 1
Threshold: 20
ComparisonOperator: GreaterThanThreshold
TreatMissingData: notBreaching
AlarmActions:
- !Ref AlarmTopic
The TreatMissingData: notBreaching setting prevents alarms from firing during periods of zero traffic. This is important for contact forms, which may have hours of inactivity.
Add the outputs section so you can find your API URL after deployment:
Outputs:
ApiUrl:
Description: API Gateway endpoint URL
Value: !Sub "https://${HttpApi}.execute-api.${AWS::Region}.amazonaws.com/prod"
TableName:
Description: DynamoDB submissions table name
Value: !Ref SubmissionsTable
BounceTopicArn:
Description: SNS topic ARN for SES bounces
Value: !Ref BounceTopic
ComplaintTopicArn:
Description: SNS topic ARN for SES complaints
Value: !Ref ComplaintTopic
Step 8: Build the frontend contact form
The frontend is a static HTML form with honeypot spam protection, client-side validation, and a fetch call to the API. No framework required.
Create your contact form page. This example uses Astro, but the pattern works with any static site generator or plain HTML:
<form id="contact-form" class="contact-form">
<!-- Honeypot field — hidden from real users, bots will fill it -->
<div style="position: absolute; opacity: 0; z-index: -1;" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" autocomplete="off" tabindex="-1" />
</div>
<div>
<label for="name">Name</label>
<input type="text" id="name" required maxlength="100" />
</div>
<div>
<label for="email">Email</label>
<input type="email" id="email" required maxlength="254" />
</div>
<div>
<label for="message">Message</label>
<textarea id="message" required rows="5" maxlength="2000"></textarea>
</div>
<button type="submit" id="submit-btn">Send Message</button>
</form>
<div id="success" style="display: none;">
<p>Message sent! We'll get back to you soon.</p>
</div>
<div id="error" style="display: none;">
<p>Something went wrong. Please try again.</p>
</div>
Add the JavaScript handler:
const API_URL = "https://your-api-id.execute-api.us-east-1.amazonaws.com/prod";
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const form = document.getElementById("contact-form");
const submitBtn = document.getElementById("submit-btn");
const successEl = document.getElementById("success");
const errorEl = document.getElementById("error");
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorEl.style.display = "none";
// Honeypot check — if filled, silently pretend success
const honeypot = document.getElementById("website").value;
if (honeypot) {
form.style.display = "none";
successEl.style.display = "block";
return;
}
const name = document.getElementById("name").value.trim().slice(0, 100);
const email = document.getElementById("email").value.trim().slice(0, 254);
const message = document.getElementById("message").value.trim().slice(0, 2000);
if (!name || !email || !message) {
errorEl.querySelector("p").textContent = "Please fill in all fields.";
errorEl.style.display = "block";
return;
}
if (!EMAIL_RE.test(email)) {
errorEl.querySelector("p").textContent = "Please enter a valid email address.";
errorEl.style.display = "block";
return;
}
submitBtn.disabled = true;
submitBtn.textContent = "Sending...";
try {
const res = await fetch(`${API_URL}/submissions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
site: "myapp",
type: "contact",
email,
data: { name, message },
}),
});
if (!res.ok) {
const errBody = await res.json().catch(() => ({}));
throw new Error(errBody.error || "Something went wrong.");
}
form.style.display = "none";
successEl.style.display = "block";
} catch (err) {
errorEl.querySelector("p").textContent = err.message || "Something went wrong. Please try again.";
errorEl.style.display = "block";
submitBtn.disabled = false;
submitBtn.textContent = "Send Message";
}
});
The honeypot pattern is simple and effective: a hidden form field that real users never see or fill in. Bots that parse the HTML and fill every field will trigger the honeypot check. The frontend silently shows a success message without hitting the API, so the bot does not know it failed.
Client-side validation checks email format and field presence and shows inline error messages. The API error response is also parsed and displayed, so users see specific messages instead of a generic failure. Server-side validation (Step 4) enforces the real security boundary.
Replace "myapp" with your site identifier from the site registry, and set the API_URL to the output from your SAM deployment. For Astro or Next.js, use an environment variable like PUBLIC_FORM_API_URL to avoid hardcoding the URL.
Step 9: Deploy and test end-to-end
Build and deploy:
# Build TypeScript
npx tsc
# Build the SAM package
sam build
# Deploy (first time — use --guided for interactive setup)
sam deploy --guided
During guided deployment, set the parameters:
- AllowedOrigins: your site URL (e.g.,
https://myapp.com,http://localhost:4321) - NotifyEmail: the email address that should receive submission notifications
- SesFromEmail: your verified SES sender address
After deployment, SAM outputs the API URL. Test with curl:
# Get the API URL from the stack outputs
API_URL=$(aws cloudformation describe-stacks \
--stack-name your-stack-name \
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
--output text)
# Test a contact submission
curl -X POST "$API_URL/submissions" \
-H "Content-Type: application/json" \
-d '{
"site": "myapp",
"type": "contact",
"email": "test@example.com",
"data": { "name": "Test User", "message": "Hello from curl" }
}'
You should get a 201 response with an id and createdAt timestamp.
Test rate limiting by sending six requests with the same email. The sixth should return a 429:
for i in {1..6}; do
echo "Request $i:"
curl -s -o /dev/null -w "%{http_code}" -X POST "$API_URL/submissions" \
-H "Content-Type: application/json" \
-d '{
"site": "myapp",
"type": "contact",
"email": "ratelimit-test@example.com",
"data": { "name": "Test", "message": "Rate limit test" }
}'
echo ""
done
Update your frontend environment variable with the API URL, rebuild your static site, and deploy it. The contact form should now submit to your serverless backend.
Add the API Gateway URL to your site’s environment as PUBLIC_FORM_API_URL (or equivalent). For Astro, add it to your .env file. The SAM output named ApiUrl gives you the full base URL including the stage.
Common Setup Problems
CORS errors in the browser console
The most common cause is a mismatch between the origin your site sends requests from and the AllowedOrigins parameter. Check that the origin includes the protocol and port (e.g., http://localhost:4321, not localhost:4321). Redeploy with the corrected origins.
SES sandbox blocks emails to unverified addresses
In sandbox mode, SES only sends to verified email addresses. Verify the recipient address in the SES console, or request production sending access when ready to go live.
Rate limit check rejects everything
If the DynamoDB count query is failing (permissions, table name mismatch), the fail-closed design rejects all submissions. Check CloudWatch logs for errors in the submit function and verify the TABLE_NAME environment variable matches the actual table.
Lambda timeout on notification send
If the SES send takes longer than expected, the Lambda may time out. The default timeout is 30 seconds, which is generous for a contact form handler. If you see timeouts, check SES service health and verify the sender address is still verified.
Bounce handler not receiving events
The SNS topics need to be connected to your SES configuration set. In the SES console, create or edit a configuration set and add the bounce and complaint SNS topic ARNs from the SAM outputs. This step is not automated by the template.
Wrap-Up
You now have a production-grade serverless contact form backend: API Gateway for routing and CORS, Lambda for request handling, DynamoDB for storage with composite keys and TTL, SES for notifications with automatic bounce suppression, and CloudWatch for monitoring with metric filters and alarms.
The entire stack is defined in a single SAM template, deploys in two commands, costs near-zero for typical traffic, and scales to whatever load arrives without configuration changes.
To extend this architecture, the next steps are usually adding more submission types (newsletter signup, feedback forms), adding email templates for user confirmations, and connecting the monitoring alarms to a team notification channel like Slack or PagerDuty.
For the business perspective on why this architecture wins on cost, security, and simplicity, see Serverless Contact Forms with AWS SAM. For related security guides, see our API security best practices guide and the Serverless Form Architecture Checklist.