Serverless Email API: Building a Cost-Effective Contact Form Backend


Why I Built This

I could’ve just used Web3Forms or FormSpree. They work fine, and some are even free. But where’s the fun in that?

Building a serverless contact form with AWS Lambda and .NET taught me more about production backend development than any tutorial could. I wanted to actually understand what goes into building a production contact form backend. What are the challenges? How do you handle spam? What does it take to make something reliable that you’d trust on your own site? Plus, there’s something satisfying about using infrastructure you built and deployed yourself. In the age of AI and “vibe coding”, it felt good to just sit down and put in the work of mapping out the project rather than just “flipping a switch”. (Full disclosure: AI did help me with a few things 😆)

But of course, cost was a factor. After all, my first use case was for this website, which is not exactly a guaranteed revenue stream. So my main goals were to:

  1. Find a nearly free solution for deploying a production HTTP API.
  2. Figure out reCAPTCHA integration myself instead of relying on someone else’s implementation.
  3. Build a configuration system that could handle multiple sites cleanly.
  4. Have something I fully control and understand, not just another service I’m dependent on.

What I Built

An email API using ASP.NET Core Minimal API deployed to AWS Lambda. It handles contact form submissions, validates them with Google reCAPTCHA, and sends emails via Amazon SES. Nothing groundbreaking, but it works well and I learned a ton building it. A bonus is that it is flexible enough to work with (nearly) any form submission, and it can be configured to support an infinite number of client websites.

Key Features

  • Serverless Architecture - AWS Lambda with native .NET 8/9 runtime
  • Multi-Origin Support - Configure N domains without code changes
  • Bot Protection - Google reCAPTCHA validation
  • Custom Domain - HTTPS via CloudFront and ACM certificates
  • Infrastructure as Code - AWS CDK for repeatable deployments

Cost Comparison

SolutionMonthly CostNotes
AWS Lambda (This Project)$0-2Stays in free tier indefinitely for contact forms
App Runner$25+Always running, wastes resources
FormSpree Pro$10+Limited control, third-party dependency
EC2 t3.micro$7.50+Overkill for contact forms

For a typical contact form getting 1,000-10,000 requests a month, this stays completely within AWS’s free tier. Even better.

How It Works

User Request

CloudFront (Custom Domain + HTTPS)

Lambda Function URL (CORS Configured)

.NET 8 API
    ├─→ Amazon SES (Email Delivery)
    └─→ reCAPTCHA (Bot Validation)

Tech Choices

I went with Lambda because I only pay when someone actually submits a form. Cold starts are around a second, which is fine for a contact form. CloudFront gives me free SSL and lets me use a custom domain. SES handles the actual email sending and gives you 62,000 free emails per month (way more than I’ll ever need).

.NET made sense because I’m comfortable with it, and the native runtime starts up fast. AWS CDK let me write my infrastructure in C# instead of YAML, which is a nicer developer experience in my opinion.

The API

Endpoints

GET /health

Just a basic health check.

Response:

{
  "status": "healthy"
}

POST /api/send-email

The actual form handler.

Request:

{
  "name": "John Doe",
  "email": "john@example.com",
  "message": "Hello, this is a test message",
  "recaptchaToken": "token_from_frontend"
}

Response (Success):

{
  "success": true,
  "message": "Email sent successfully",
  "messageIds": ["0100018b-abc123..."]
}

Response (Error):

{
  "success": false,
  "errors": ["reCAPTCHA verification failed"]
}

How Requests Get Processed

Each request goes through a few checks:

  1. Rate limiting - Max 5 requests per 10 seconds per IP
  2. Form validation - Clean up and validate the submitted data
  3. Origin check - Make sure the request is coming from an allowed domain
  4. reCAPTCHA - Verify the token to catch bots
  5. Send the email - Actually deliver it via SES
  6. Respond - Let the frontend know if it worked

Supporting Multiple Sites

Here’s something cool — I can add as many sites as I want without touching the code. Everything’s configured in JSON.

Configuration Structure

appsettings.json:

{
  "EmailConfig": {
    "Origins": [
      {
        "AllowedOrigin": "https://sheldonnofer.dev",
        "FromAddress": "noreply@sheldonnofer.dev",
        "Recipients": ["contact@sheldonnofer.dev"],
        "RecaptchaConfig": {
          "RecaptchaUrl": "https://www.google.com/recaptcha/api/siteverify"
        }
      },
      {
        "AllowedOrigin": "https://staging.example.com",
        "FromAddress": "noreply@staging.example.com",
        "Recipients": ["staging@example.com"],
        "RecaptchaConfig": {
          "RecaptchaUrl": "https://www.google.com/recaptcha/api/siteverify"
        }
      }
    ]
  }
}

Security-First Approach

Secrets are handled via environment variables:

# AWS Region
export AWS_REGION=us-east-1

# reCAPTCHA secrets per origin (never committed to git)
export EmailConfig__Origins__0__RecaptchaConfig__SiteKey=your_site_key
export EmailConfig__Origins__0__RecaptchaConfig__SecretKey=your_secret_key

export EmailConfig__Origins__1__RecaptchaConfig__SiteKey=staging_key
export EmailConfig__Origins__1__RecaptchaConfig__SecretKey=staging_secret

This keeps my secrets out of git while making it easy to add new sites. Each environment can have its own reCAPTCHA keys too.

Deployment

AWS CDK Setup

I defined everything in AWS CDK using C#. The nice part is it’s type-safe and I can version control my infrastructure.

The CDK does some clever stuff:

  1. Reads my appsettings.json and auto-configures CORS on the Lambda Function URL
  2. Handles SSL certificate provisioning for CloudFront
  3. Picks up all the reCAPTCHA secrets from environment variables automatically
// Simplified CDK stack excerpt
private string[] ReadAllowedOriginsFromAppSettings()
{
    var appSettingsPath = Path.Combine("..", "..",
        "swn14.EmailServer", "appsettings.json");
    var json = File.ReadAllText(appSettingsPath);

    // Parse and extract all AllowedOrigin values
    // Returns: ["https://example.com", "https://staging.example.com"]
}

// Configure Lambda Function URL with automatic CORS
var allowedOrigins = ReadAllowedOriginsFromAppSettings();

var functionUrl = emailFunction.AddFunctionUrl(new FunctionUrlOptions
{
    Cors = new FunctionUrlCorsOptions
    {
        AllowedOrigins = allowedOrigins,
        AllowedMethods = [HttpMethod.POST, HttpMethod.GET],
        AllowedHeaders = ["*"],
        MaxAge = Duration.Minutes(5)
    }
});

Deploying It

One command and I’m done:

cd infrastructure/lambda-cdk
./deploy.sh api.yourdomain.com

The script builds everything, bootstraps CDK if needed, deploys the Lambda function, sets up CloudFront, provisions the SSL cert, and spits out the DNS records I need to add.

After that, I just:

  1. Add the DNS validation records
  2. Point my subdomain to the CloudFront distribution
  3. Wait a few minutes for DNS to propagate

Some Code Worth Showing

Sending Emails with SES

public class EmailService
{
    private readonly IAmazonSimpleEmailServiceV2 _sesClient;

    public async Task<SendEmailResponse> SendEmailAsync(SendEmailCommand command)
    {
        var request = new SendEmailRequest
        {
            FromEmailAddress = command.FromAddress,
            Destination = new Destination
            {
                ToAddresses = new List<string> { command.ToAddress }
            },
            Content = new EmailContent
            {
                Simple = new Message
                {
                    Subject = new Content { Data = command.Subject },
                    Body = new Body
                    {
                        Text = new Content { Data = command.Body }
                    }
                }
            }
        };

        var response = await _sesClient.SendEmailAsync(request);
        return new SendEmailResponse(response.MessageId, response.HttpStatusCode);
    }
}

reCAPTCHA Validation

public class RecaptchaService
{
    public async Task<bool> VerifyRecaptchaAsync(string origin, string token)
    {
        var config = GetConfigForOrigin(origin);

        var response = await _httpClient.PostAsync(
            config.RecaptchaUrl,
            new FormUrlEncodedContent(new[]
            {
                new KeyValuePair<string, string>("secret", config.SecretKey),
                new KeyValuePair<string, string>("response", token)
            })
        );

        var result = await response.Content.ReadFromJsonAsync<RecaptchaResponse>();
        return result?.Success ?? false;
    }
}

Rate Limiting

ASP.NET Core has rate limiting built in, so I used it. Although it is less effective on AWS Lambda. I may go with a Redis cache at some point.

builder.Services.AddRateLimiter(options =>
{
    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;

    options.AddFixedWindowLimiter("5Per10SecsPerIp", opt =>
    {
        opt.PermitLimit = 5;
        opt.Window = TimeSpan.FromSeconds(10);
        opt.QueueLimit = 0; // Reject immediately when limit hit
    });
});

// Applied to endpoint
app.MapPost("/api/send-email", handler)
   .RequireRateLimiting("5Per10SecsPerIp");

Performance

Speed

Cold starts take about 1-2 seconds with the native .NET 8 runtime. Once it’s warm, requests finish in 50-200ms. Memory usage sits around 128-256 MB, and the actual processing takes 100-500ms per request.

Scaling

Lambda just handles this. It’ll scale from zero to 1000+ concurrent requests automatically. I don’t configure anything - AWS figures it out. And I only pay for what gets used.

Monitoring

Logs

Everything goes to CloudWatch:

# Tail logs in real-time
aws logs tail /aws/lambda/EmailServerFunction --follow

What I Track

Request counts, error rates, how long things take, cold start frequency, reCAPTCHA pass/fail rates, and email delivery status. The usual stuff.

Things I Learned the Hard Way

Configuration Challenges

First attempt used URLs as dictionary keys in the config. Terrible idea. .NET treats colons as hierarchy separators, so everything broke. Switched to an array-based config with an AllowedOrigin property and life got better.

CORS is Weird with Lambda

Lambda Function URLs need CORS configured separately from your API’s CORS middleware. Spent way too long debugging this. Now the CDK just reads my allowed origins from appsettings.json and handles it.

Cold Starts Matter

Container images had noticeable cold starts. The native .NET 8 runtime is way faster. Not even close.

Environment Variables with Arrays

Managing secrets for multiple origins meant dealing with .NET’s __ (double underscore) convention for nested config. EmailConfig__Origins__0__RecaptchaConfig__SiteKey looks ugly but works.

What’s Next

Might add HTML email templates at some point. Email queueing could be useful if volume picks up. File attachments would be nice. Should probably move secrets to AWS Secrets Manager instead of env vars. OpenTelemetry for tracing would be cool. Maybe some caching too.

Tech Stack Summary

Backend:

  • .NET 8/9 - ASP.NET Core Minimal API
  • AWS Lambda - Serverless compute
  • Amazon SES - Email delivery
  • AWS CDK - Infrastructure as code (C#)

Frontend Integration:

  • Google reCAPTCHA - Bot protection
  • CORS - Multi-origin support

DevOps:

  • CloudFront - CDN and custom domain
  • ACM - SSL certificates
  • CloudWatch - Logging and monitoring

Configuration:

  • JSON-based configuration
  • Environment variable overrides
  • Array-based multi-origin support

The Results

I have a nearly free form submission service for my own websites, and it could easily be configured to support my clients’ websites. Responses are fast when warm. I don’t manage any infrastructure. Adding new domains takes 30 seconds. And spam bots get blocked.

I’m pretty happy with how this turned out. Lambda and .NET work well together, and the configuration system makes it easy to manage multiple sites without touching code.

Test It

Go ahead and give it a try by sending me a message with the contact form here: Contact Me

Q&A

Is AWS Lambda better than traditional hosting for contact forms?

You can absolutely host it on a traditional cloud server. It requires configuring the server manually and keeping up with updates. I wasn’t interested in that extra work, so AWS Lambda was a good fit for me.

Can this handle multiple websites from one deployment?

Yes! The configuration system uses a JSON array to define multiple origins, each with their own email recipients, sender addresses, and reCAPTCHA keys. Adding a new website takes about 30 seconds—just add a new origin configuration and redeploy. The CORS settings and routing are handled automatically based on your configuration, so there’s no code changes needed.

How do you prevent spam without a third-party service?

The API uses multiple layers of spam protection: Google reCAPTCHA v2 validates every submission before processing, built-in rate limiting restricts each IP to 5 requests per 10 seconds, origin validation ensures requests only come from configured domains, and AWS Lambda’s ephemeral nature makes it harder for bots to establish persistent connections. This multi-layered approach blocks the vast majority of spam without relying on external services.

What about cold starts with AWS Lambda?

Cold starts with the native .NET 8 runtime average 1-2 seconds, which is acceptable for a contact form where users don’t expect instant responses. Once warm, requests complete in 50-200ms. For most use cases, the 99%+ cost savings justify the occasional cold start. If cold starts are critical, you can configure Lambda provisioned concurrency, though that adds to the cost.

Can I use this with static site generators like Astro or Next.js?

Absolutely! This is perfect for static sites. Your frontend (whether it’s Astro, Next.js, Hugo, or plain HTML) just needs to make a POST request to the API endpoint with the form data and reCAPTCHA token. The CloudFront distribution provides a custom domain with HTTPS, and CORS is configured to accept requests from your specified origins. Check out the frontend integration example in the repository.


Technologies: .NET 8/9, AWS Lambda, Amazon SES, AWS CDK, CloudFront, C#, ASP.NET Core, reCAPTCHA