DEV Community

Josh Blair
Josh Blair

Posted on

Serverless Contact Form — Lambda, API Gateway, DynamoDB, and SES

Overview

The contact form on bonefishsoftware.com is fully serverless — no EC2, no always-on server. A visitor submits the form, the request hits an API Gateway HTTP API, a Python Lambda function validates and stores the submission in DynamoDB, then sends an email notification via SES. Cost at low volume: effectively zero.

Flow

Contact form flow


Infrastructure — AWS SAM

The contact API is deployed using AWS SAM (Serverless Application Model), a CloudFormation extension that simplifies Lambda + API Gateway resource definitions.

Why SAM over plain CloudFormation?

SAM's AWS::Serverless::Function with Events automatically creates the API Gateway routes, integrations, Lambda permissions, and stage. Doing this in plain CloudFormation requires 6–8 separate resource definitions. SAM condenses it to one function resource with an Events section.

Key lesson: AWS::Serverless::HttpApi vs AWS::ApiGatewayV2::Api

The most important thing to get right: when using SAM's HttpApi event type, the ApiId must reference an AWS::Serverless::HttpApi resource — not a native AWS::ApiGatewayV2::Api.

Using the wrong resource type results in the SAM transform silently skipping route and integration creation, leaving you with a deployed API that returns 404 on every request.

# ✅ Correct — SAM manages routes/integrations automatically
ContactApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    CorsConfiguration:
      AllowOrigins: [https://bonefishsoftware.com]
      AllowMethods: [POST, OPTIONS]
      AllowHeaders: [Content-Type]

ContactFunction:
  Type: AWS::Serverless::Function
  Events:
    PostContact:
      Type: HttpApi
      Properties:
        ApiId: !Ref ContactApi   # ← references Serverless::HttpApi
        Path: /contact
        Method: POST
Enter fullscreen mode Exit fullscreen mode
# ❌ Wrong — routes NOT created by SAM
ContactApi:
  Type: AWS::ApiGatewayV2::Api  # ← native resource, SAM ignores events
Enter fullscreen mode Exit fullscreen mode

Lambda Function (Python)

# lambda/contact/handler.py

import json, os, uuid
from datetime import datetime, timezone
import boto3
from botocore.exceptions import ClientError

ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "https://bonefishsoftware.com")
TABLE_NAME = os.environ["TABLE_NAME"]
FROM_ADDRESS = os.environ["FROM_ADDRESS"]
TO_ADDRESS = os.environ["TO_ADDRESS"]

dynamodb = boto3.resource("dynamodb")
ses = boto3.client("ses")

def handler(event, context):
    # Handle CORS preflight
    if event.get("requestContext", {}).get("http", {}).get("method") == "OPTIONS":
        return _response(200, {})

    # Parse and validate
    try:
        body = json.loads(event.get("body") or "{}")
    except (json.JSONDecodeError, TypeError):
        return _response(400, {"error": "Invalid request body."})

    name    = (body.get("name") or "").strip()
    email   = (body.get("email") or "").strip()
    message = (body.get("message") or "").strip()
    company = (body.get("company") or "").strip()

    if not name or not email or not message:
        return _response(400, {"error": "Name, email, and message are required."})

    if "@" not in email or "." not in email.split("@")[-1]:
        return _response(400, {"error": "Please provide a valid email address."})

    submission_id = str(uuid.uuid4())
    timestamp = datetime.now(timezone.utc).isoformat()

    try:
        _save_to_dynamo(submission_id, timestamp, name, email, company, message)
        _send_email(submission_id, timestamp, name, email, company, message)
    except ClientError as e:
        print(f"AWS error: {e}")
        return _response(500, {"error": "Failed to process your message. Please try again."})

    return _response(200, {"message": "Message received! We'll be in touch soon."})
Enter fullscreen mode Exit fullscreen mode

Why no external dependencies?

Python's boto3 SDK is built into the Lambda runtime — no requirements.txt needed. The deployment package is just a single handler.py file, keeping Lambda cold-start time minimal.

Validation approach

Validation is intentionally lightweight:

  • Required field presence check
  • Naive email format check (contains @ and a . after it)

We're not the primary spam defense here — the contact form has no public financial incentive to spam, and rate limiting can be added at the API Gateway level later if needed.


DynamoDB Table

SubmissionsTable:
  Type: AWS::DynamoDB::Table
  Properties:
    TableName: bonefish-contact-submissions
    BillingMode: PAY_PER_REQUEST
    AttributeDefinitions:
      - AttributeName: submissionId
        AttributeType: S
      - AttributeName: timestamp
        AttributeType: S
    KeySchema:
      - AttributeName: submissionId
        KeyType: HASH
      - AttributeName: timestamp
        KeyType: RANGE
Enter fullscreen mode Exit fullscreen mode

PAY_PER_REQUEST billing — no provisioned capacity to manage. At the volume of a contact form (single-digit submissions per day at most), this costs essentially nothing.

Composite key (submissionId + timestamp) — the UUID ensures uniqueness; the timestamp makes it easy to sort and query submissions chronologically in future tooling.


SES Email Delivery

Domain verification (DKIM)

Emails sent from noreply@bonefishsoftware.com require the domain to be verified in SES. This involves adding three DKIM CNAME records to Route 53:

aws sesv2 create-email-identity \
  --email-identity bonefishsoftware.com \
  --region us-west-2
# → returns 3 DKIM tokens

# Add CNAME records: <token>._domainkey.bonefishsoftware.com → <token>.dkim.amazonses.com
Enter fullscreen mode Exit fullscreen mode

DKIM signing tells receiving mail servers that the email genuinely came from our domain, improving deliverability and preventing spoofing.

Sandbox mode

By default, SES operates in sandbox mode — you can only send to verified email addresses. This is sufficient for the contact form (we only send TO josh.blair@gmail.com, which is verified). The submitter's email address appears only in the Reply-To header and the email body — never as a direct recipient.

SES production access (removing sandbox restrictions) was requested via:

aws sesv2 put-account-details \
  --mail-type TRANSACTIONAL \
  --website-url https://bonefishsoftware.com \
  --use-case-description "..." \
  --production-access-enabled
Enter fullscreen mode Exit fullscreen mode

Reply-To header

ses.send_email(
    Source=FROM_ADDRESS,                  # noreply@bonefishsoftware.com
    Destination={"ToAddresses": [TO_ADDRESS]},  # josh.blair@gmail.com
    Message={...},
    ReplyToAddresses=[email],             # submitter's email
)
Enter fullscreen mode Exit fullscreen mode

Setting ReplyToAddresses to the submitter's email means hitting "Reply" in gmail automatically addresses the response to the client — no copy-pasting required.


CORS Configuration

CORS is configured at the API Gateway level (not in the Lambda response), via the AWS::Serverless::HttpApi resource:

ContactApi:
  Type: AWS::Serverless::HttpApi
  Properties:
    CorsConfiguration:
      AllowOrigins:
        - https://bonefishsoftware.com
        - http://localhost:5173    # local dev
      AllowHeaders:
        - Content-Type
      AllowMethods:
        - POST
        - OPTIONS
      MaxAge: 300
Enter fullscreen mode Exit fullscreen mode

The Lambda still returns Access-Control-Allow-Origin headers in its response (for safety), but API Gateway handles the OPTIONS preflight response automatically.

Lesson learned: An early version used AWS::ApiGatewayV2::Api instead of AWS::Serverless::HttpApi. The CORS configuration was present but the routes were never created — resulting in 404 responses and CORS errors in the browser. Switching to AWS::Serverless::HttpApi resolved both issues simultaneously.


Frontend Integration

The contact form in React fetches the API URL from a Vite environment variable:

// src/pages/Contact.tsx
const apiUrl = import.meta.env.VITE_CONTACT_API_URL;

async function handleSubmit(e: React.FormEvent) {
  e.preventDefault();
  setStatus('submitting');
  try {
    const res = await fetch(apiUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    if (!res.ok) throw new Error('Request failed');
    setStatus('success');
  } catch {
    setStatus('error');
  }
}
Enter fullscreen mode Exit fullscreen mode

VITE_CONTACT_API_URL is injected at build time by CodeBuild as an environment variable. Vite replaces import.meta.env.VITE_* references with literal string values during the build — there's no runtime environment lookup.


SAM Deployment

sam deploy \
  --template-file infra/stacks/contact-api.yml \
  --stack-name bonefish-contact-api \
  --region us-west-2 \
  --capabilities CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
  --s3-bucket bonefish-pipeline-artifacts-709085484102 \
  --s3-prefix sam-contact \
  --no-confirm-changeset
Enter fullscreen mode Exit fullscreen mode

CAPABILITY_AUTO_EXPAND is required when the template uses Transform: AWS::Serverless-2016-10-31. This tells CloudFormation to expand SAM macros before processing the template.

SAM packages the Lambda code (zips lambda/contact/), uploads to the artifacts S3 bucket, and replaces the local CodeUri path with the S3 URL in the transformed template — all automatically.


Cost Analysis

At typical consulting site traffic:

Resource Usage Estimated cost
API Gateway HTTP API 100 requests/month $0.001
Lambda 100 invocations × 128MB × 500ms < $0.01
DynamoDB 100 writes, PAY_PER_REQUEST < $0.01
SES 100 emails $0.01
Total < $0.05/month

The entire contact form backend costs pennies per month.

Top comments (0)