AWS Messaging Blog

Building a Scalable Messaging API with AWS End User Messaging and SES

Modern applications often need to send notifications across multiple channels either through email and/or SMS. However, building a reliable messaging system that manages templates, handles failures gracefully, scales, and maintains security can be challenging. Following this guide, you’ll learn how to build a template manager and messaging API using API Gateway with JWT authentication for secure access, Amazon SQS for reliable message queuing, AWS Lambda for serverless processing, AWS End User Messaging for SMS, and Amazon Simple Email Service (SES) for email.

Architecture overview

You deploy a decoupled architecture that separates message ingestion from processing, providing resilience and scalability.

Fig. 1 Message Template Manager Architecture

Fig. 1 Message Template Manager Architecture

Architecture flow

  1. Client Application sends authenticated requests with JWT tokens
  2. API Gateway validates requests using a Lambda Authorizer
  3. Lambda Authorizer retrieves the JWT secret from AWS Secrets Manager and validates the token
  4. API Gateway sends validated messages to the SQS Queue
  5. Lambda Processor polls messages from SQS in batches
  6. Lambda Processor retrieves message templates from DynamoDB (if needed)
  7. Lambda Processor sends emails through Amazon SES and SMS through AWS End User Messaging
  8. Failed messages (after 3 retries) move to the Dead Letter Queue
  9. CloudWatch Alarm triggers when messages arrive in the DLQ

If a message fails processing after three attempts, it moves to a Dead Letter Queue (DLQ) where it’s preserved for 14 days, and a CloudWatch alarm notifies you of the failure.

Key features

With this architecture, you get several important capabilities:

  • JWT Authentication: Secure API access using JSON Web Tokens stored in AWS Secrets Manager
  • Automatic Retries: Failed messages retry up to three times before moving to the DLQ
  • Partial Batch Failures: Only failed messages retry, not the entire batch
  • Template Management: Store reusable message templates in Amazon DynamoDB
  • Multi-Channel Support: Send email through Amazon SES and SMS through AWS End User Messaging
  • Configuration Set Support: Track delivery metrics and route events with per-message or deployment-level configuration sets
  • Monitoring: CloudWatch alarms alert you when messages fail

Implementation details

1. API Gateway with JWT authorization

The API Gateway uses a Lambda authorizer to validate JWT tokens before allowing requests through:

def lambda_handler(event, context):
    token = event.get('authorizationToken', '').replace('Bearer ', '')
    try:
        # Retrieve secret from AWS Secrets Manager
        jwt_secret = get_jwt_secret()
        
        # Validate JWT token
        payload = jwt.decode(
            token,
            jwt_secret,
            algorithms=['HS256'],
            issuer='messaging-api'
        )
        
        # Generate IAM policy to allow request
        return generate_policy(payload.get('sub'), 'Allow', event['methodArn'])
    except jwt.ExpiredSignatureError:
        raise Exception('Unauthorized: Token expired')
    except jwt.InvalidTokenError:
        raise Exception('Unauthorized: Invalid token')

The JWT secret is stored securely in AWS Secrets Manager and cached in the Lambda execution environment for performance.

2. SQS queue configuration

The SAM template defines two queues with appropriate settings:

MessagesQueue:
  Type: AWS::SQS::Queue
  Properties:
    QueueName: MessagesQueue
    VisibilityTimeout: 300  # 5 minutes
    MessageRetentionPeriod: 345600  # 4 days
    RedrivePolicy:
      deadLetterTargetArn: !GetAtt MessagesDeadLetterQueue.Arn
      maxReceiveCount: 3

MessagesDeadLetterQueue:
  Type: AWS::SQS::Queue
  Properties:
    QueueName: MessagesDeadLetterQueue
    MessageRetentionPeriod: 1209600  # 14 days

The visibility timeout of 5 minutes prevents duplicate processing while giving the Lambda function enough time to complete. Messages that fail three times automatically move to the DLQ.

3. Lambda message processor

The Lambda function processes messages from SQS and sends them through the appropriate channel:

def lambda_handler(event, context):
    failed_messages = []
    
    for record in event['Records']:
        message_id = record['messageId']
        try:
            message = json.loads(record['body'])
            
            # Process email if configured
            if 'EmailMessage' in message:
                send_emails(message)
            
            # Process SMS if configured
            if 'SMSMessage' in message:
                send_sms_messages(message)
                
        except Exception as e:
            print(f"Error processing message {message_id}: {str(e)}")
            failed_messages.append({"itemIdentifier": message_id})
    
    # Return failed messages for automatic retry
    return {"batchItemFailures": failed_messages}

Configuration Sets for tracking and analytics:

Configuration Sets enable you to track delivery metrics, monitor costs, and route events to analytics pipelines. You can set defaults at deployment time and override them per-message:

  • Deployment-level defaults: Set SMSConfigurationSet parameter during deployment to apply to all messages
  • Per-message override: Include ConfigurationSetName in the SMSMessage payload to use different tracking for specific messages

This flexibility lets you separate analytics by message type, campaign, or priority without requiring redeployment.

4. Template management with DynamoDB

Message templates are stored in DynamoDB for reusability:

def get_email_template_from_dynamodb(template_name, substitutions):
    response = templates_table.get_item(Key={'TemplateName': template_name})
    
    if 'Item' not in response:
        return build_default_email(), "Account Alert"
    
    template_body = response['Item']['MessageBody']
    subject = response['Item'].get('Subject', 'Notification')
    
    # Replace {variable} placeholders with actual values
    rendered_body = replace_variables(template_body, substitutions)
    rendered_subject = replace_variables(subject, substitutions)
    
    return rendered_body, rendered_subject

You can update message content without redeploying code.

Template size considerations:

DynamoDB has a 400 KB limit per item, which includes all attribute names and values. For message templates, this means:

  • Typical email templates (5-20 KB) fit comfortably
  • SMS templates (< 1 KB) have no practical constraints

If you need to store templates larger than 400 KB, consider storing them in Amazon S3 and referencing the S3 object key in DynamoDB. This hybrid approach provides unlimited template size while maintaining fast lookups.

Prerequisites

Before deploying this solution, ensure you have the following:

  • AWS End User Messaging SMS configured with a phone pool or origination identity for SMS sending
  • Amazon SES configured with verified email identities (sender and recipient for sandbox mode)
  • IAM permissions to create Lambda functions, API Gateway, SQS queues, DynamoDB tables, and Secrets Manager secrets
  • Python 3.9 or later installed locally
  • AWS SAM CLI installed (version 1.0 or later)
  • AWS CLI installed and configured with your credentials
  • An active AWS account with appropriate permissions

Deployment

You use AWS SAM for infrastructure as code. Deploy with these commands:

sam build
sam deploy --guided

During deployment, you’ll set a JWT secret that’s stored in AWS Secrets Manager. Use a strong, random secret for production:

python -c "import secrets; print(secrets.token_urlsafe(32))"

Usage example

Once deployed, send messages by making authenticated API requests:

curl -X POST "https://your-api-endpoint/dev/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer YOUR_JWT_TOKEN" \
  -d '{
    "TraceId": "12345",
    "EmailMessage": {
      "FromAddress": "alerts@example.com",
      "Subject": "Low Balance Alert",
      "ConfigurationSetName": "email-analytics",
      "Substitutions": {
        "productName": "CHEQUING",
        "membershipNumber": "****5493",
        "accountBalance": "100.00"
      }
    },
    "SMSMessage": {
      "MessageType": "TRANSACTIONAL",
      "OriginationNumber": "your-pool-id",
      "TemplateName": "alert-template",
      "ConfigurationSetName": "sms-analytics"
    },
    "Addresses": {
      "user@example.com": {
        "ChannelType": "EMAIL"
      },
      "+16048621234": {
        "ChannelType": "SMS",
        "Substitutions": {
          "productName": "CHEQUING",
          "membershipNumber": "****7303",
          "accountBalance": "100.00"
        }
      }
    }
  }'

Using Configuration Sets:

The example above shows optional ConfigurationSetName parameters for both email and SMS. These enable:

  • Delivery tracking: Monitor delivery rates, failures, and bounce metrics
  • Cost monitoring: Track spending per campaign or message type
  • Event routing: Send delivery events to CloudWatch, Kinesis, or SNS for analytics
  • Segmented metrics: Separate analytics by use case, priority, or customer segment

If you don’t specify a ConfigurationSetName in the request, the system uses the deployment-level default (if configured).

Monitoring and operations

CloudWatch alarms

The solution includes a CloudWatch alarm that triggers when messages arrive in the DLQ:

DLQAlarm:
  Type: AWS::CloudWatch::Alarm
  Properties:
    AlarmName: !Sub ${AWS::StackName}-DLQ-Messages
    MetricName: ApproximateNumberOfMessagesVisible
    Namespace: AWS/SQS
    Statistic: Sum
    Period: 300
    EvaluationPeriods: 1
    Threshold: 1
    ComparisonOperator: GreaterThanOrEqualToThreshold

Handling failed messages

When messages fail, you can inspect them in the DLQ and redrive them back to the main queue after fixing the issue:

# Check DLQ depth
aws sqs get-queue-attributes \
  --queue-url YOUR_DLQ_URL \
  --attribute-names ApproximateNumberOfMessages

# Redrive messages from DLQ to main queue
aws sqs start-message-move-task \
  --source-arn YOUR_DLQ_ARN \
  --destination-arn YOUR_MAIN_QUEUE_ARN

Viewing logs

Lambda automatically logs to CloudWatch Logs:

aws logs tail /aws/lambda/MessageProcessor --follow

Cost considerations

For 1 million messages per month estimated costs are:

Service Usage Cost
API Gateway 1M requests $3.50
Amazon SQS 1M messages $0.40
AWS Lambda 1M invocations (128MB, 1s avg) $2.50
Amazon SES 1M emails $100.00
AWS End User Messaging SMS 1M SMS $ Varies based on destination

Note: The serverless architecture means you only pay for what you use, with no minimum fees or upfront costs.

Security best practices

This solution follows several security best practices:

  1. JWT Authentication: All API requests require valid JWT tokens
  2. Secrets Manager: JWT secrets are stored encrypted in AWS Secrets Manager
  3. IAM Least Privilege: Each Lambda function has only the permissions it needs
  4. HTTPS Only: API Gateway enforces HTTPS for all requests
  5. Token Caching: Authorization decisions are cached for 5 minutes to reduce latency

Clean up

To avoid incurring ongoing charges, delete the resources created by this solution when you no longer need them:

  1. If you configured AWS End User Messaging phone pools or origination identities for this solution, remove them from the End User Messaging console
  2. If you created Amazon SES email identities specifically for this solution, remove them from the SES console
  3. Verify that all resources (Lambda functions, API Gateway, SQS queues, DynamoDB table, Secrets Manager secret) have been removed in the AWS Management Console
  4. Delete the CloudFormation stack by running: sam delete --stack-name <your-stack-name>

Conclusion

With this architecture, you can build a production-ready messaging API using AWS serverless services. The decoupled design gives you resilience through automatic retries and dead letter queues, while the serverless approach eliminates infrastructure management and scales automatically.

The complete solution is deployable through AWS SAM and includes:

  • JWT authentication with AWS Secrets Manager
  • Multi-channel messaging (email and SMS)
  • Template management with DynamoDB
  • Configuration Set support for tracking and analytics
  • Comprehensive monitoring and alerting
  • Automatic retry and failure handling

You can extend this architecture by adding more channels (push notifications, webhooks), implementing message scheduling, or integrating with Amazon EventBridge for event-driven workflows.

Additional resources

The complete source code for this solution is available in the accompanying GitHub repository, including SAM templates, Lambda functions, and deployment scripts.


About the authors

Tyler Holmes

Tyler Holmes

Tyler is a Senior Specialist Solutions Architect. He has a wealth of experience in the communications space as a consultant, an SA, a practitioner, and leader at all levels from Startup to Fortune 500. He has spent over 14 years in sales, marketing, and service operations, working for agencies, consulting firms, and brands, building teams and increasing revenue.

Bruno Giorgini

Bruno Giorgini

Bruno Giorgini is a Sr Solutions Architect specialized in AWS Communication Developer Services. When he is not crafting innovative solutions for clients, Bruno enjoys spending quality time with his family, exploring the scenic hiking trails. His passion for technology and its potential to drive business transformation keeps him motivated to deliver impactful solutions for organizations around the world.