Serverless SMS Service with Rate Limiting & Fraud Prevention

Villa SMS Service - Developer Guide

A serverless SMS sending service built on AWS Lambda with progressive rate limiting, fraud prevention, and dual-provider support (ClickSend + AWS SNS fallback).

Table of Contents


Overview

Villa SMS is a Lambda function designed for direct invocation via boto3.client('lambda').invoke(). It provides:


Quick Start

Sending an SMS

import boto3
import json

# Initialize Lambda client
lambda_client = boto3.client('lambda', region_name='ap-southeast-1')

# Send SMS
response = lambda_client.invoke(
    FunctionName='villa-sms-dev',  # or 'villa-sms-prod'
    InvocationType='RequestResponse',
    Payload=json.dumps({
        'phone': '+66812345678',
        'message': 'Your verification code is: 123456'
    })
)

# Parse response
result = json.loads(response['Payload'].read())

if result['success']:
    print('SMS sent successfully!')
else:
    print(f"Failed: {result['error']} - {result['message']}")

Async Invocation (Fire-and-Forget)

response = lambda_client.invoke(
    FunctionName='villa-sms-prod',
    InvocationType='Event',  # Async - returns immediately
    Payload=json.dumps({
        'phone': '+66812345678',
        'message': 'Your order has been shipped!'
    })
)
# Returns 202 status if queued successfully

API Reference

Request Payload

Field Type Required Description
phone string Yes Phone number in E.164 format (e.g., +66812345678)
message string Yes SMS message content (max 160 chars for single SMS)
source string No Source identifier for ClickSend tracking (default: "villa-sms")
skipRateLimit boolean No Skip rate limiting check (use with caution, default: false)

Response Payload

Success Response

{
    "success": true,
    "message": "SMS sent successfully",
    "rateLimited": false
}

Error Response

{
    "success": false,
    "error": "RATE_LIMITED",
    "message": "Please wait 1 minute before requesting another code.",
    "rateLimited": true,
    "retryAfterSeconds": 45
}

Error Codes

Error Code Description Retry?
MISSING_PHONE Phone number not provided No
MISSING_MESSAGE Message content not provided No
INVALID_PHONE Phone number format invalid or suspicious No
BLOCKED_COUNTRY Destination country is blocked No
RATE_LIMITED Too many requests - wait and retry Yes (after retryAfterSeconds)
SEND_FAILED SMS delivery failed Yes (with backoff)

Configuration

Environment Variables

Variable Default Description
TABLE_NAME Required DynamoDB table name for rate limiting
CLICKSEND_SECRET_ARN Required ARN of ClickSend credentials secret
BASE_COOLDOWN_SECONDS 60 Initial cooldown between SMS (1 minute)
ESCALATED_COOLDOWN_SECONDS 300 Escalated cooldown (5 minutes)
ESCALATION_THRESHOLD 3 Attempts before escalation
ATTEMPT_WINDOW_SECONDS 600 Time window for counting attempts (10 minutes)
BLOCKED_COUNTRY_CODES Default list Comma-separated country codes to block

ClickSend Credentials

Credentials are stored in AWS Secrets Manager. The secret should contain:

{
    "username": "your-clicksend-username",
    "api_key": "your-clicksend-api-key"
}

To update credentials:

aws secretsmanager put-secret-value \
    --secret-id villa-sms/clicksend-prod \
    --secret-string '{"username": "new-user", "api_key": "new-key"}'

Rate Limiting

How It Works

  1. First request: Allowed, starts 1-minute cooldown
  2. Second request within 1 minute: Blocked with RATE_LIMITED error
  3. After 1 minute: Allowed again
  4. 3+ requests in 10 minutes: Cooldown escalates to 5 minutes

Rate Limit Response

{
    "success": false,
    "error": "RATE_LIMITED",
    "message": "Please wait 5 minutes before requesting another code.",
    "rateLimited": true,
    "retryAfterSeconds": 287
}

Bypassing Rate Limits

For admin or system-initiated messages (e.g., order confirmations), you can skip rate limiting:

response = lambda_client.invoke(
    FunctionName='villa-sms-prod',
    Payload=json.dumps({
        'phone': '+66812345678',
        'message': 'Your order has been shipped!',
        'skipRateLimit': True  # Use with caution!
    })
)

⚠️ Warning: Only use skipRateLimit for system-initiated messages, never for user-triggered actions like OTP requests.


Fraud Prevention

Country Blocklist

The following countries are blocked by default due to high SMS pump fraud rates:

Country Code Country Reason
+228 Togo High fraud volume
+996 Kyrgyzstan High fraud volume
+992 Tajikistan SMS pump destination
+998 Uzbekistan SMS pump destination
+993 Turkmenistan SMS pump destination
+994 Azerbaijan SMS pump destination
+880 Bangladesh SMS pump destination
+92 Pakistan High fraud region
+234 Nigeria High fraud region
+233 Ghana High fraud region
+255 Tanzania SMS pump destination
+256 Uganda SMS pump destination
+254 Kenya SMS pump destination
+63 Philippines High fraud region

Customizing Blocklist

Override via environment variable:

Environment:
  Variables:
    BLOCKED_COUNTRY_CODES: "228,996,992,998"  # Custom list

Or via SAM parameter:

sam deploy --parameter-overrides BlockedCountryCodes="228,996,992,880"

Phone Number Validation

The service validates phone numbers against:

  1. E.164 format: Must start with + and contain 7-15 digits
  2. Country-specific rules: Length validation for US, UK, Thailand, etc.
  3. Typo detection: Catches double country codes (e.g., +6666...)
  4. Suspicious patterns: Blocks repeated digits, sequential numbers

Error Handling

Best Practices

import boto3
import json
from botocore.exceptions import ClientError

def send_sms(phone: str, message: str) -> dict:
    lambda_client = boto3.client('lambda')

    try:
        response = lambda_client.invoke(
            FunctionName='villa-sms-prod',
            InvocationType='RequestResponse',
            Payload=json.dumps({
                'phone': phone,
                'message': message
            })
        )

        result = json.loads(response['Payload'].read())

        if result['success']:
            return {'sent': True}

        # Handle specific errors
        error = result.get('error')

        if error == 'RATE_LIMITED':
            retry_after = result.get('retryAfterSeconds', 60)
            return {'sent': False, 'retry_after': retry_after}

        if error == 'INVALID_PHONE':
            return {'sent': False, 'invalid_phone': True, 'reason': result['message']}

        if error == 'BLOCKED_COUNTRY':
            return {'sent': False, 'blocked': True}

        # Generic failure
        return {'sent': False, 'error': result['message']}

    except ClientError as e:
        # Lambda invocation failed
        return {'sent': False, 'error': str(e)}

Retry Strategy

import time

def send_with_retry(phone: str, message: str, max_retries: int = 3) -> bool:
    for attempt in range(max_retries):
        result = send_sms(phone, message)

        if result.get('sent'):
            return True

        if result.get('invalid_phone') or result.get('blocked'):
            # Don't retry for permanent failures
            return False

        if result.get('retry_after'):
            # Rate limited - respect the cooldown
            time.sleep(result['retry_after'])
            continue

        # Exponential backoff for other failures
        time.sleep(2 ** attempt)

    return False

Deployment

Prerequisites

Deploy to AWS

# Build the application
sam build

# Deploy (first time - guided)
sam deploy --guided

# Deploy (subsequent times)
sam deploy

# Deploy to specific environment
sam deploy --parameter-overrides Environment=prod

Update ClickSend Credentials

After deployment, update the secret with real credentials:

# Get the secret ARN from stack outputs
SECRET_ARN=$(aws cloudformation describe-stacks \
    --stack-name villa-sms \
    --query 'Stacks[0].Outputs[?OutputKey==`ClickSendSecretArn`].OutputValue' \
    --output text)

# Update with real credentials
aws secretsmanager put-secret-value \
    --secret-id $SECRET_ARN \
    --secret-string '{"username": "your-username", "api_key": "your-api-key"}'

Local Testing

# Start local DynamoDB
docker run -d -p 8000:8000 amazon/dynamodb-local

# Create test table
aws dynamodb create-table \
    --endpoint-url http://localhost:8000 \
    --table-name villa-sms-rate-limit-dev \
    --attribute-definitions AttributeName=pk,AttributeType=S \
    --key-schema AttributeName=pk,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST

# Invoke locally (requires SAM CLI)
sam local invoke VillaSmsFunction -e events/send_sms.json

CI/CD Pipeline

The project uses GitHub Actions with OIDC authentication for secure, secretless deployments.

Repository

Workflow

Trigger Action
Push to master Run tests → Deploy to dev → Deploy to prod
Pull request Run tests only
Manual dispatch Deploy specific environment

Environments

Environment Function Name Auto-deploy
dev villa-sms-dev ✅ Yes
production villa-sms-prod ✅ Yes (after dev)

OIDC Authentication

The workflow uses OpenID Connect (OIDC) to authenticate with AWS - no secrets stored in GitHub.

- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: arn:aws:iam::394922924679:role/villa-sms-github-actions
    aws-region: ap-southeast-1

IAM Role

The villa-sms-github-actions role has least-privilege permissions: - CloudFormation stack management (villa-sms only) - Lambda function updates (villa-sms only) - DynamoDB table management (villa-sms* only) - S3 access for SAM deployments - Secrets Manager read access

Making Changes

  1. Create a feature branch
  2. Make changes and push
  3. Open a pull request (tests run automatically)
  4. Merge to master (auto-deploys to dev, then prod)

Manual Deployment

Trigger a manual deployment from the Actions tab:

gh workflow run deploy.yml -f environment=prod

Or via the GitHub UI: Actions → Deploy Villa SMS → Run workflow


Monitoring

CloudWatch Metrics

The function publishes standard Lambda metrics:

CloudWatch Logs

Log entries include:

{
    "level": "INFO",
    "message": "SMS sent via ClickSend",
    "phone_hash": 1234,
    "message_id": "abc123"
}

Note: Phone numbers are hashed for privacy. Full numbers are never logged.

Alarms

The stack includes built-in alarms:

Custom Dashboards

Create a CloudWatch dashboard to monitor:

aws cloudwatch put-dashboard \
    --dashboard-name villa-sms-monitoring \
    --dashboard-body file://dashboards/villa-sms.json

Troubleshooting

Common Issues

"INVALID_PHONE" for Valid Numbers

Check if the number matches country-specific validation rules. Thai numbers must: - Start with +66 - Have exactly 9 digits after country code - First digit after +66 must be 2-9 (not 0 or 1)

Wrong: +66081234567 (has leading 0)
Correct: +6681234567

Rate Limiting Not Working

  1. Check DynamoDB table exists and has correct permissions
  2. Verify TABLE_NAME environment variable is set
  3. Check CloudWatch logs for DynamoDB errors

SMS Not Delivered

  1. Check ClickSend credentials are valid
  2. Verify phone number is in allowed country
  3. Check ClickSend account balance/credits
  4. Review CloudWatch logs for error messages

ClickSend Fallback to SNS

If you see "Falling back to SNS" in logs: - ClickSend credentials may be invalid - ClickSend API may be down - Network issues from Lambda

Debug Mode

Enable debug logging by setting log level:

import logging
logger = logging.getLogger()
logger.setLevel(logging.DEBUG)

Support

For issues or questions: 1. Check CloudWatch logs for error details 2. Verify ClickSend dashboard for delivery status 3. Review DynamoDB for rate limit state


Appendix

E.164 Phone Number Format

E.164 is the international standard for phone numbers:

Examples: - USA: +14155551234 - UK: +447911123456 - Thailand: +66812345678 - Singapore: +6591234567

Country Code Quick Reference

Country Code Example
USA/Canada +1 +14155551234
UK +44 +447911123456
Germany +49 +4915121234567
Thailand +66 +66812345678
Singapore +65 +6591234567
Malaysia +60 +60123456789
Australia +61 +61412345678
Japan +81 +819012345678
Hong Kong +852 +85291234567
Taiwan +886 +886912345678