A production-ready serverless contact form backend built with AWS SAM, Lambda, DynamoDB, and SES.
Browser ──POST──▶ API Gateway (HTTP API)
│
▼
Lambda (submit)
│ │
▼ ▼
DynamoDB SES ──▶ Notification Email
│
▼
SES Bounce/Complaint
│
▼
SNS Topic(s)
│
▼
Lambda (bounceHandler)
│
▼
Suppression Table
- Multi-site support — single deployment serves multiple sites/domains
- Rate limiting — per-email rate limiting (5 submissions/hour)
- Honeypot spam protection — hidden field traps bots without CAPTCHAs
- Bounce & complaint suppression — automatic SES bounce/complaint handling via SNS
- CloudWatch monitoring — metric filters and alarms for errors, bounces, and abuse
- CORS — configurable per-origin allow list
- Security headers — HSTS, X-Content-Type-Options, X-Frame-Options on every response
- TTL cleanup — submissions auto-expire from DynamoDB after 90 days
- AWS account with CLI configured (
aws configure) - AWS SAM CLI
- Node.js 22+
- A verified domain or email address in SES
git clone https://github.com/inkbytestudio/serverless-form-api.git
cd serverless-form-api
npm install
cp samconfig.toml.example samconfig.toml # edit with your values
npx tsc
sam build
sam deploy --guidedSAM parameters (set in samconfig.toml or via --guided):
| Parameter | Description | Default |
|---|---|---|
AllowedOrigins |
Comma-separated CORS origins | http://localhost:4321,http://localhost:8080 |
NotifyEmail |
Email for submission notifications | admin@example.com |
SesFromEmail |
Verified SES sender address | noreply@example.com |
serverless-form-api/
├── template.yaml # SAM infrastructure template
├── package.json
├── tsconfig.json
├── samconfig.toml.example # Deploy config template
├── src/
│ ├── types.ts # Shared TypeScript interfaces
│ ├── handlers/
│ │ ├── submit.ts # POST /submissions handler
│ │ └── bounceHandler.ts # SNS bounce/complaint processor
│ ├── config/
│ │ ├── schemas.ts # Validation schemas per submission type
│ │ └── sites.ts # Per-site configuration
│ ├── middleware/
│ │ ├── validate.ts # Input validation
│ │ ├── rateLimit.ts # Per-email rate limiting
│ │ └── notify.ts # Email notification orchestration
│ ├── services/
│ │ ├── dynamo.ts # DynamoDB operations
│ │ ├── ses.ts # SES email sending
│ │ └── suppression.ts # Bounce suppression list
│ └── utils/
│ └── response.ts # HTTP response helpers with CORS
└── frontend/
├── index.html # Example contact form page
└── contact.js # Frontend submission handler
- Add a new entry to
src/config/sites.ts:
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",
},-
Add the site's origin to the
AllowedOriginsparameter insamconfig.toml. -
Rebuild and deploy:
npm run deploy
- Add a schema to
src/config/schemas.ts:
newsletter: {
requiredDataFields: ["source"],
},-
Add the type to the site's
allowedTypesarray insrc/config/sites.ts. -
Rebuild and deploy.
-
Set
API_URLinfrontend/contact.jsto your deployed API Gateway URL (from stack outputs). -
The form includes a honeypot field (
#website) — it's hidden from real users via CSS. Bots that fill it get a fake success response. Do not remove it. -
The frontend is framework-agnostic vanilla JS. Adapt the
fetchcall to any framework — just POST JSON to/submissions:
{
"site": "myapp",
"type": "contact",
"email": "user@example.com",
"data": { "name": "Jane", "message": "Hello!" }
}The SAM template creates SNS topics for bounces and complaints, but you must manually wire them to SES:
- Open the SES console → Configuration Sets.
- Create a configuration set (or use an existing one).
- Add an Event destination for Bounces → SNS topic → select the
ses-bouncestopic. - Add an Event destination for Complaints → SNS topic → select the
ses-complaintstopic. - Set this configuration set as the default, or specify it in your
SendEmailCommand.
New SES accounts start in sandbox mode — you can only send to verified email addresses. Request production access before going live.
# Get the API URL from stack outputs
API_URL=$(aws cloudformation describe-stacks \
--stack-name serverless-form-api \
--query 'Stacks[0].Outputs[?OutputKey==`ApiUrl`].OutputValue' \
--output text)
# Successful 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" }
}'
# Validation error (missing required field)
curl -X POST "$API_URL/submissions" \
-H "Content-Type: application/json" \
-d '{ "site": "myapp", "type": "contact", "email": "test@example.com", "data": {} }'
# Rate limit test (6th request should return 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 ""
doneThree CloudWatch alarms are configured:
| Alarm | Trigger |
|---|---|
FormApi-HighErrorRate |
> 10 errors in 5 minutes |
FormApi-HighBounceRate |
> 5 bounces in 1 hour |
FormApi-RateLimitAbuse |
> 20 rate limit hits in 5 minutes |
All alarms notify via the form-api-alarms SNS topic (subscribed to NotifyEmail).
| Problem | Solution |
|---|---|
| CORS errors | Ensure the requesting origin is in AllowedOrigins. Redeploy after changes. |
| SES sandbox | In sandbox mode, both sender and recipient must be verified. Request production access for real use. |
| Rate limit (429) | 5 submissions per email per hour. Wait or use a different email for testing. |
| Lambda timeout | Default is 30s. If SES is slow, check SES region and connectivity. |
| Bounce handler not firing | Verify the SES configuration set event destinations point to the correct SNS topics (manual step). |
For 1,000 submissions/month: **$0.50/month**
- Lambda: free tier covers it
- API Gateway: ~$0.001/request
- DynamoDB: on-demand, pennies at this scale
- SES: $0.10 per 1,000 emails
- SNS: negligible
- CloudWatch: free tier covers basic metrics/alarms
This repo is the companion code for the full step-by-step tutorial:
Deploy a Serverless Contact Form with AWS SAM, Lambda & DynamoDB