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
- Quick Start
- API Reference
- Configuration
- Rate Limiting
- Fraud Prevention
- Error Handling
- Deployment
- CI/CD Pipeline
- Monitoring
- Troubleshooting
Overview
Villa SMS is a Lambda function designed for direct invocation via boto3.client('lambda').invoke(). It provides:
- Progressive Rate Limiting: Starts at 1 minute, escalates to 5 minutes after repeated attempts
- Country Blocklist: Prevents SMS pump fraud by blocking high-risk countries
- Phone Validation: Detects typos, invalid formats, and suspicious patterns
- Dual Provider: ClickSend as primary, AWS SNS as fallback
- Secure Credentials: API keys stored in AWS Secrets Manager
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
- First request: Allowed, starts 1-minute cooldown
- Second request within 1 minute: Blocked with
RATE_LIMITEDerror - After 1 minute: Allowed again
- 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:
- E.164 format: Must start with
+and contain 7-15 digits - Country-specific rules: Length validation for US, UK, Thailand, etc.
- Typo detection: Catches double country codes (e.g.,
+6666...) - 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
- AWS CLI configured
- SAM CLI installed (
brew install aws-sam-cli) - Python 3.12+
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
- GitHub: https://github.com/villa-market/villa-sms
- Actions: https://github.com/villa-market/villa-sms/actions
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
- Create a feature branch
- Make changes and push
- Open a pull request (tests run automatically)
- 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:
- Invocations: Total SMS requests
- Errors: Failed invocations
- Duration: Processing time
- Throttles: Rate limiting at Lambda level
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:
- Error Alarm: Triggers when >5 errors in 5 minutes
- Throttle Alarm: Triggers on any Lambda throttling
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
- Check DynamoDB table exists and has correct permissions
- Verify
TABLE_NAMEenvironment variable is set - Check CloudWatch logs for DynamoDB errors
SMS Not Delivered
- Check ClickSend credentials are valid
- Verify phone number is in allowed country
- Check ClickSend account balance/credits
- 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:
- Starts with
+ - Followed by country code (1-3 digits)
- Followed by subscriber number
- Maximum 15 digits total
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 |