How to Build a Serverless Subscriber List with Go and AWS
By Braincuber Team
Published on April 7, 2026
This complete tutorial walks you through building a production-ready email subscription system from scratch. Whether you want to manage your own newsletter, build an independent subscriber base, or collect verified email addresses without third-party services, this step by step guide covers every detail. You will use Go for the Lambda function, AWS DynamoDB for storage, AWS SES for email delivery, and CloudFormation for infrastructure as code.
What You'll Learn:
- How to design a verified double opt-in subscription flow
- How to write Go Lambda functions for subscribe, verify, and unsubscribe operations
- How to use DynamoDB UPSERT operations to handle duplicate subscriptions efficiently
- How to send confirmation emails with SES using UUID tokens for verification
- How to deploy infrastructure with CloudFormation IaC templates
- How to implement security best practices including token expiration and data cleanup
Verified Double Opt-In
Confirmation emails ensure address correctness and user intent, preventing spam and protecting your AWS costs.
Go + Serverless Architecture
Go provides fast cold starts and small deployment packages, making it ideal for AWS Lambda functions.
DynamoDB Storage
Serverless NoSQL database with pay-per-request pricing, perfect for storing subscriber data at scale.
Infrastructure as Code
CloudFormation templates provision all AWS resources reproducibly with version-controlled infrastructure.
Prerequisites
You need an AWS account, Go installed on your machine, AWS CLI configured with credentials, and a verified SES email address or domain for sending confirmation emails.
How to Design the Verified Subscription Flow
A non-verified email sign up process is straightforward: someone enters their email, and it goes into your database. However, this approach invites spam, fake addresses, and inflated AWS costs. Instead, a confirmation email ensures both address correctness and user intent.
To build a subscription flow with email confirmation, create single-responsibility functions that satisfy each logical step:
Accept Email and Record It
Receive an email address via a web form, generate a unique token, and store both in DynamoDB with confirm = false.
Generate and Store Verification Token
Create a UUID token associated with the email address. This acts as a secret that only the email recipient will receive.
Send Confirmation Email with Token Link
Use AWS SES to send an email containing a verification link with the email and token as query parameters.
Accept Verification Request
When the user clicks the link, validate that the email and token match the database record, then set confirm = true.
| Column | Type | Description | Example |
|---|---|---|---|
| String (PK) | Subscriber email address | user@example.com | |
| confirm | Boolean | Whether subscription is verified | false / true |
| id | String | UUID verification token | uuid-xxxxx |
| timestamp | String | Last update timestamp | 2020-11-01 00:27:39 |
How to Set Up the Project Structure
Start by creating your project directory and initializing a Go module. This beginner guide assumes you have Go 1.16 or later installed.
mkdir simple-subscribe
cd simple-subscribe
go mod init simple-subscribe
go get github.com/aws/aws-lambda-go/lambda
go get github.com/aws/aws-sdk-go/aws
go get github.com/aws/aws-sdk-go/aws/session
go get github.com/aws/aws-sdk-go/service/dynamodb
go get github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute
go get github.com/aws/aws-sdk-go/service/ses
go get github.com/google/uuid
go get github.com/stretchr/testify/mock
How to Write the Go Lambda Function
The core of Simple Subscribe is a single Go Lambda function that handles three operations: subscribe, verify, and unsubscribe. Create a file called main.go in your project root.
Imports and Data Structures
package main
import (
"context"
"fmt"
"net/http"
"os"
"time"
"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/dynamodb"
"github.com/aws/aws-sdk-go/service/dynamodb/dynamodbattribute"
"github.com/aws/aws-sdk-go/service/ses"
"github.com/google/uuid"
)
type Subscriber struct {
Email string `json:"email"`
Confirm bool `json:"confirm"`
ID string `json:"id"`
Timestamp string `json:"timestamp"`
}
type Response struct {
StatusCode int `json:"statusCode"`
Headers map[string]string `json:"headers"`
Body string `json:"body"`
}
Subscribe Handler Function
The subscribe handler receives an email address, generates a UUID token, and either inserts a new record or updates an existing one using DynamoDB's UpdateItem operation. This UPSERT pattern avoids duplicate entries and controls costs.
func handleSubscribe(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
email := request.QueryStringParameters["email"]
if email == "" {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusBadRequest,
Body: "Email parameter is required",
}, nil
}
id := uuid.New().String()
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05")
svc := dynamodb.New(session.New())
tableName := os.Getenv("DB_TABLE_NAME")
input := &dynamodb.UpdateItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(email)},
},
UpdateExpression: aws.String("SET #confirm = :confirm, #id = :id, #timestamp = :timestamp"),
ExpressionAttributeNames: map[string]*string{
"#confirm": aws.String("confirm"),
"#id": aws.String("id"),
"#timestamp": aws.String("timestamp"),
},
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":confirm": {BOOL: aws.Bool(false)},
":id": {S: aws.String(id)},
":timestamp": {S: aws.String(timestamp)},
},
}
_, err := svc.UpdateItem(input)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error subscribing: %v", err),
}, nil
}
// Send confirmation email
baseURL := os.Getenv("BASE_URL")
verifyPath := os.Getenv("VERIFY_PATH")
verifyURL := fmt.Sprintf("%s%s/?email=%s&id=%s", baseURL, verifyPath, email, id)
sesSvc := ses.New(session.New())
senderEmail := os.Getenv("SENDER_EMAIL")
senderName := os.Getenv("SENDER_NAME")
subject := "Please confirm your subscription"
body := fmt.Sprintf("Click the link below to confirm your subscription:\n%s", verifyURL)
emailInput := &ses.SendEmailInput{
Destination: &ses.Destination{
ToAddresses: []*string{aws.String(email)},
},
Message: &ses.Message{
Body: &ses.Body{
Text: &ses.Content{
Data: aws.String(body),
},
},
Subject: &ses.Content{
Data: aws.String(subject),
},
},
Source: aws.String(fmt.Sprintf("%s <%s>", senderName, senderEmail)),
}
_, err = sesSvc.SendEmail(emailInput)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error sending email: %v", err),
}, nil
}
confirmPage := os.Getenv("CONFIRM_SUBSCRIBE_PAGE")
redirectURL := fmt.Sprintf("%s%s", baseURL, confirmPage)
return events.APIGatewayProxyResponse{
StatusCode: http.StatusFound,
Headers: map[string]string{"Location": redirectURL},
Body: "",
}, nil
}
DynamoDB Cost Optimization
DynamoDB pricing depends on read and write request volume. Using UpdateItem instead of creating new records for duplicate subscriptions avoids flooding your table and keeps costs predictable.
Verify Handler Function
The verify handler checks that the incoming email and UUID token match a database record, then sets confirm = true. This ensures only the email recipient can complete the subscription.
func handleVerify(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
email := request.QueryStringParameters["email"]
id := request.QueryStringParameters["id"]
if email == "" || id == "" {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusBadRequest,
Body: "Email and id parameters are required",
}, nil
}
svc := dynamodb.New(session.New())
tableName := os.Getenv("DB_TABLE_NAME")
// First, check if the record exists with matching email and id
getInput := &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(email)},
},
}
result, err := svc.GetItem(getInput)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error verifying: %v", err),
}, nil
}
if result.Item == nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusNotFound,
Body: "Subscription not found",
}, nil
}
var subscriber Subscriber
err = dynamodbattribute.UnmarshalMap(result.Item, &subscriber)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error parsing record: %v", err),
}, nil
}
if subscriber.ID != id {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusUnauthorized,
Body: "Invalid verification token",
}, nil
}
// Update the record to set confirm = true
timestamp := time.Now().UTC().Format("2006-01-02 15:04:05")
updateInput := &dynamodb.UpdateItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(email)},
},
UpdateExpression: aws.String("SET #confirm = :confirm, #timestamp = :timestamp"),
ExpressionAttributeNames: map[string]*string{
"#confirm": aws.String("confirm"),
"#timestamp": aws.String("timestamp"),
},
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":confirm": {BOOL: aws.Bool(true)},
":timestamp": {S: aws.String(timestamp)},
},
}
_, err = svc.UpdateItem(updateInput)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error updating record: %v", err),
}, nil
}
successPage := os.Getenv("SUCCESS_PAGE")
baseURL := os.Getenv("BASE_URL")
redirectURL := fmt.Sprintf("%s%s", baseURL, successPage)
return events.APIGatewayProxyResponse{
StatusCode: http.StatusFound,
Headers: map[string]string{"Location": redirectURL},
Body: "",
}, nil
}
Unsubscribe Handler Function
The unsubscribe handler deletes the subscriber record from DynamoDB when the email and token match. Providing an automatic unsubscribe method is essential for ethical data handling and compliance.
func handleUnsubscribe(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
email := request.QueryStringParameters["email"]
id := request.QueryStringParameters["id"]
if email == "" || id == "" {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusBadRequest,
Body: "Email and id parameters are required",
}, nil
}
svc := dynamodb.New(session.New())
tableName := os.Getenv("DB_TABLE_NAME")
// Verify the email and id match before deleting
getInput := &dynamodb.GetItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(email)},
},
}
result, err := svc.GetItem(getInput)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error unsubscribing: %v", err),
}, nil
}
if result.Item != nil {
var subscriber Subscriber
err = dynamodbattribute.UnmarshalMap(result.Item, &subscriber)
if err == nil && subscriber.ID == id {
// Delete the record
deleteInput := &dynamodb.DeleteItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(email)},
},
}
_, err = svc.DeleteItem(deleteInput)
if err != nil {
return events.APIGatewayProxyResponse{
StatusCode: http.StatusInternalServerError,
Body: fmt.Sprintf("Error deleting record: %v", err),
}, nil
}
}
}
confirmUnsubscribePage := os.Getenv("CONFIRM_UNSUBSCRIBE_PAGE")
baseURL := os.Getenv("BASE_URL")
redirectURL := fmt.Sprintf("%s%s", baseURL, confirmUnsubscribePage)
return events.APIGatewayProxyResponse{
StatusCode: http.StatusFound,
Headers: map[string]string{"Location": redirectURL},
Body: "",
}, nil
}
Lambda Handler Router
The main handler function routes incoming requests to the correct operation based on the URL path.
func handler(ctx context.Context, request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {
path := request.Path
subscribePath := "/" + os.Getenv("SUBSCRIBE_PATH")
verifyPath := "/" + os.Getenv("VERIFY_PATH")
unsubscribePath := "/" + os.Getenv("UNSUBSCRIBE_PATH")
switch path {
case subscribePath:
return handleSubscribe(ctx, request)
case verifyPath:
return handleVerify(ctx, request)
case unsubscribePath:
return handleUnsubscribe(ctx, request)
default:
return events.APIGatewayProxyResponse{
StatusCode: http.StatusNotFound,
Body: "Not found",
}, nil
}
}
func main() {
lambda.Start(handler)
}
How to Deploy with CloudFormation
Simple Subscribe includes a CloudFormation template that provisions all necessary AWS resources. Create a file called cloudformation.yaml in your project root.
AWSTemplateFormatVersion: '2010-09-09'
Description: Simple Subscribe - Serverless email subscription management
Resources:
LambdaDeploymentBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: !Sub simple-subscribe-lambda-deployment-${AWS::AccountId}
VersioningConfiguration:
Status: Enabled
LambdaExecutionRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: lambda.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: DynamoDBAndSESAccess
PolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Action:
- dynamodb:GetItem
- dynamodb:UpdateItem
- dynamodb:DeleteItem
- dynamodb:Scan
Resource: !GetAtt SubscriberTable.Arn
- Effect: Allow
Action:
- ses:SendEmail
- ses:SendRawEmail
Resource: '*'
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: '*'
SubscriberTable:
Type: AWS::DynamoDB::Table
Properties:
TableName: SimpleSubscribe
AttributeDefinitions:
- AttributeName: email
AttributeType: S
KeySchema:
- AttributeName: email
KeyType: HASH
BillingMode: PAY_PER_REQUEST
SubscribeFunction:
Type: AWS::Lambda::Function
Properties:
FunctionName: simple-subscribe
Runtime: go1.x
Handler: main
Code:
S3Bucket: !Ref LambdaDeploymentBucket
S3Key: simple-subscribe.zip
Role: !GetAtt LambdaExecutionRole.Arn
Timeout: 30
MemorySize: 128
Environment:
Variables:
DB_TABLE_NAME: !Ref SubscriberTable
BASE_URL: https://example.com/
API_URL: https://api.example.com/
How to Build and Deploy the Lambda Function
Before deploying, you need to compile your Go code for Linux (the Lambda runtime environment) and upload it to S3.
Build for Linux and Zip
Cross-compile your Go application for Linux and create a zip archive. Lambda runs on Amazon Linux, so GOOS must be set to linux.
Deploy CloudFormation Stack
Use the AWS CLI to deploy the CloudFormation template. This creates the S3 bucket, IAM role, DynamoDB table, and Lambda function.
Upload Lambda Code and Configure API Gateway
Upload the zip file to the S3 bucket, then set up an API Gateway trigger with payloadFormatVersion 2.0.
# Build for Linux
GOOS=linux go build -o main main.go
zip main.zip main
# Deploy CloudFormation stack
aws cloudformation deploy \
--template-file cloudformation.yaml \
--stack-name SimpleSubscribeStack \
--capabilities CAPABILITY_NAMED_IAM
# Upload Lambda code to S3
aws s3 cp main.zip s3://simple-subscribe-lambda-deployment-YOUR_ACCOUNT_ID/main.zip
# Update Lambda function code
aws lambda update-function-code \
--function-name simple-subscribe \
--s3-bucket simple-subscribe-lambda-deployment-YOUR_ACCOUNT_ID \
--s3-key main.zip
How to Configure Lambda Environment Variables
The Lambda function requires environment variables to know which table to use, where to redirect users, and how to send emails. Create a .env file in your project root.
NAME="simple-subscribe"
DB_TABLE_NAME="SimpleSubscribe"
LAMBDA_ENV="Variables={\
DB_TABLE_NAME=SimpleSubscribe,\
BASE_URL=https://example.com/,\
API_URL=https://api.example.com/,\
ERROR_PAGE=error,\
SUCCESS_PAGE=success,\
CONFIRM_SUBSCRIBE_PAGE=confirm,\
CONFIRM_UNSUBSCRIBE_PAGE=unsubscribed,\
SUBSCRIBE_PATH=signup,\
UNSUBSCRIBE_PATH=unsubscribe,\
VERIFY_PATH=verify,\
SENDER_EMAIL=no-reply@example.com,\
SENDER_NAME='Your Name'}"
| Variable | Purpose | Example |
|---|---|---|
| DB_TABLE_NAME | DynamoDB table name | SimpleSubscribe |
| BASE_URL | Your website base URL | https://example.com/ |
| API_URL | API Gateway endpoint URL | https://api.example.com/ |
| SUBSCRIBE_PATH | Subscription endpoint path | signup |
| VERIFY_PATH | Email verification endpoint | verify |
| UNSUBSCRIBE_PATH | Unsubscribe endpoint path | unsubscribe |
| SENDER_EMAIL | SES verified sender email | no-reply@example.com |
| SENDER_NAME | Display name for emails | Your Name |
How to Create the Subscription Form
Your visitors need a form to submit their email address. Here is a simple HTML form that sends a GET request to your subscribe endpoint.
Enter your email below to subscribe.
The type="email" attribute provides built-in browser validation, ensuring only valid email formats are submitted. The required attribute prevents empty submissions.
How to Query for Confirmed Subscribers
Once subscribers have confirmed their email addresses, you can query the DynamoDB table to build your mailing list. It is critical to only return emails where confirm = true, since unconfirmed entries represent pending requests.
func getConfirmedSubscribers() ([]Subscriber, error) {
svc := dynamodb.New(session.New())
tableName := os.Getenv("DB_TABLE_NAME")
input := &dynamodb.ScanInput{
TableName: aws.String(tableName),
FilterExpression: aws.String("#confirm = :confirm"),
ExpressionAttributeNames: map[string]*string{
"#confirm": aws.String("confirm"),
},
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":confirm": {BOOL: aws.Bool(true)},
},
}
result, err := svc.Scan(input)
if err != nil {
return nil, err
}
var subscribers []Subscriber
err = dynamodbattribute.UnmarshalListOfMaps(result.Items, &subscribers)
return subscribers, err
}
Security Best Practices
When handling other people's data, security is your responsibility. Here are the key considerations for this complete tutorial on building a secure subscription system.
Least Privilege IAM
Grant your Lambda function only the DynamoDB and SES permissions it needs. Avoid wildcard actions on production resources.
HTTPS Everywhere
Ensure your website and API Gateway use HTTPS. All subscription and verification requests should be encrypted in transit.
Token Expiration
Consider expiring verification tokens after a set period. Run a cleanup Lambda to remove stale unconfirmed entries.
Data Backups
Enable DynamoDB Point-in-Time Recovery or On-Demand Backup to protect against accidental data loss.
Periodic Cleanup Function
To avoid retaining unconfirmed email addresses indefinitely, set up a periodic cleanup process. This can be a scheduled Lambda function or a manual script.
func cleanupUnconfirmed() error {
svc := dynamodb.New(session.New())
tableName := os.Getenv("DB_TABLE_NAME")
cutoffTime := time.Now().UTC().Add(-7 * 24 * time.Hour).Format("2006-01-02 15:04:05")
// Scan for unconfirmed entries older than 7 days
input := &dynamodb.ScanInput{
TableName: aws.String(tableName),
FilterExpression: aws.String("#confirm = :confirm AND #timestamp < :cutoff"),
ExpressionAttributeNames: map[string]*string{
"#confirm": aws.String("confirm"),
"#timestamp": aws.String("timestamp"),
},
ExpressionAttributeValues: map[string]*dynamodb.AttributeValue{
":confirm": {BOOL: aws.Bool(false)},
":cutoff": {S: aws.String(cutoffTime)},
},
}
result, err := svc.Scan(input)
if err != nil {
return err
}
// Delete each unconfirmed entry
for _, item := range result.Items {
email := item["email"].S
deleteInput := &dynamodb.DeleteItemInput{
TableName: aws.String(tableName),
Key: map[string]*dynamodb.AttributeValue{
"email": {S: aws.String(*email)},
},
}
_, err = svc.DeleteItem(deleteInput)
if err != nil {
return err
}
}
return nil
}
How to Run Tests
Simple Subscribe includes unit tests using Go's built-in testing framework and testify/mock for mocking AWS service clients.
# Install dependencies
go mod tidy
# Run tests with verbose output
go test -v
SES Sandbox Limitation
By default, AWS SES operates in sandbox mode, which means you can only send emails to verified addresses. To send to any email address, you must request production access through the SES console.
Architecture Overview
Here is how all the components work together in this step by step guide to the complete architecture:
| Component | Role | AWS Service |
|---|---|---|
| API Gateway | HTTP trigger for Lambda with path-based routing | aws_api_gateway |
| Lambda Function | Handles subscribe, verify, unsubscribe logic in Go | aws_lambda_function |
| DynamoDB Table | Stores subscriber records with email as primary key | aws_dynamodb_table |
| SES | Sends confirmation emails with verification links | aws_ses |
| S3 Bucket | Stores Lambda deployment package | aws_s3_bucket |
| CloudFormation | Infrastructure as Code for reproducible deployments | aws_cloudformation |
Frequently Asked Questions
How to build a serverless email subscription list with Go and AWS?
Use AWS Lambda with Go to handle subscribe, verify, and unsubscribe requests. Store emails in DynamoDB and send confirmation emails via SES for double opt-in verification.
Why use double opt-in for email subscriptions?
Double opt-in ensures email address correctness and user intent. It prevents spam signups, reduces bounce rates, and protects your AWS SES reputation and costs.
How does DynamoDB UPSERT work for duplicate subscriptions?
Use UpdateItem with the email as the key. If the record exists, it updates the id and timestamp. If not, it creates a new record. This avoids duplicate entries and controls costs.
How to deploy Go code to AWS Lambda?
Cross-compile with GOOS=linux, zip the binary, upload to S3, then update the Lambda function code. Use CloudFormation to manage all infrastructure resources as code.
How to clean up unconfirmed email subscriptions?
Run a periodic scan for records where confirm is false and the timestamp is older than your threshold (e.g., 7 days). Delete these entries to keep your table clean.
Need Help with AWS Infrastructure?
Our experts can help you design, deploy, and optimize your serverless applications on AWS, Azure, and GCP.
