Skip to content
Back to Tutorials

How to Deploy a Serverless Contact Form with AWS SAM, Lambda, and DynamoDB

Intermediate · 1 hour 30 minutes · 24 min read · Byte Smith ·

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
1
2
3
4
5
6
7
8
9
10
11
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
Note

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.

Tip

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.

Note

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.

Note

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.log and console.error call emits JSON with an event field 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 catch block 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.

Warning

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 } },
      },
    })
  );
}
Tip

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.

Warning

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.

Tip

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.

Note

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.