We Built a Referral System with AWS SES and SNS (And Learned Why Email Is Harder Than SMS)
We Built a Referral System with AWS SES and SNS (And Learned Why Email Is Harder Than SMS)
TL;DR: We built a complete referral system using AWS SES (email) and SNS (SMS). Users can invite others via email or text, track referrals, and earn rewards. Cost: $0.10 per 1,000 emails, $0.00645 per SMS. Saved $500/month vs SendGrid/Twilio. Email deliverability is 10x harder than SMS.
The Problem: Growth Requires Referrals
We needed users to invite other businesses. But how?
Traditional approaches:
- Third-party services: SendGrid ($15/month + $0.50/1K emails), Twilio ($1/month + $0.0079/SMS)
- DIY SMTP: Deliverability nightmare, IP reputation issues
- No referrals: Slow organic growth
What we wanted:
- Low cost (we’re bootstrapped)
- High deliverability (emails actually reach inboxes)
- SMS support (some users prefer texting)
- Tracking (who invited whom, conversion rates)
The insight: AWS SES + SNS gives us everything for 1/10th the cost.
How It Works: The Technical Architecture
1. Referral Link Generation
Every user gets a unique referral link:
async function generateReferralLink(userId: string): Promise<string> {
// Generate unique referral code
const referralCode = crypto.randomBytes(8).toString('hex');
// Store in database
await db.createReferral({
userId,
referralCode,
createdAt: new Date(),
clicks: 0,
conversions: 0
});
// Return shareable link
return `https://webzum.com?ref=${referralCode}`;
}
Tracking clicks:
// Middleware to track referral clicks
export async function middleware(req: NextRequest) {
const refCode = req.nextUrl.searchParams.get('ref');
if (refCode) {
// Store in cookie (lasts 30 days)
const response = NextResponse.next();
response.cookies.set('ref_code', refCode, {
maxAge: 30 * 24 * 60 * 60, // 30 days
httpOnly: true,
secure: true
});
// Track click
await db.incrementReferralClicks(refCode);
return response;
}
return NextResponse.next();
}
2. Email Invitations with AWS SES
Setup AWS SES:
import { SESClient, SendEmailCommand } from '@aws-sdk/client-ses';
const sesClient = new SESClient({
region: 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
async function sendReferralEmail(params: {
fromEmail: string;
fromName: string;
toEmail: string;
referralLink: string;
}) {
const command = new SendEmailCommand({
Source: `${params.fromName} via WebZum <noreply@webzum.com>`,
Destination: {
ToAddresses: [params.toEmail]
},
Message: {
Subject: {
Data: `${params.fromName} invited you to try WebZum`,
Charset: 'UTF-8'
},
Body: {
Html: {
Data: generateEmailHTML(params),
Charset: 'UTF-8'
},
Text: {
Data: generateEmailText(params),
Charset: 'UTF-8'
}
}
},
// Track opens and clicks
ConfigurationSetName: 'webzum-tracking'
});
try {
const response = await sesClient.send(command);
return {
success: true,
messageId: response.MessageId
};
} catch (error) {
console.error('SES send failed:', error);
return {
success: false,
error: error.message
};
}
}
Email template:
function generateEmailHTML(params: {
fromName: string;
toEmail: string;
referralLink: string;
}): string {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're Invited to WebZum</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, sans-serif; background-color: #f4f4f4;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f4; padding: 20px;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden;">
<!-- Header -->
<tr>
<td style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 40px 20px; text-align: center;">
<h1 style="color: #ffffff; margin: 0; font-size: 28px;">You're Invited! 🎉</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding: 40px 30px;">
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin: 0 0 20px;">
Hi there!
</p>
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin: 0 0 20px;">
<strong>${params.fromName}</strong> thinks you'd love WebZum—the AI-powered website builder that creates professional websites in minutes.
</p>
<p style="font-size: 16px; line-height: 1.6; color: #333333; margin: 0 0 30px;">
✨ No coding required<br>
🚀 Live in 5 minutes<br>
💰 Free 7-day preview
</p>
<!-- CTA Button -->
<table width="100%" cellpadding="0" cellspacing="0">
<tr>
<td align="center">
<a href="${params.referralLink}" style="display: inline-block; padding: 16px 32px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold; font-size: 16px;">
Create Your Website
</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color: #f8f9fa; padding: 20px 30px; text-align: center;">
<p style="font-size: 12px; color: #666666; margin: 0;">
This invitation was sent by ${params.fromName} (${params.toEmail})
</p>
<p style="font-size: 12px; color: #666666; margin: 10px 0 0;">
<a href="https://webzum.com/unsubscribe" style="color: #667eea; text-decoration: none;">Unsubscribe</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
}
function generateEmailText(params: {
fromName: string;
referralLink: string;
}): string {
return `
Hi there!
${params.fromName} thinks you'd love WebZum—the AI-powered website builder that creates professional websites in minutes.
✨ No coding required
🚀 Live in 5 minutes
💰 Free 7-day preview
Create your website: ${params.referralLink}
---
This invitation was sent by ${params.fromName}
Unsubscribe: https://webzum.com/unsubscribe
`.trim();
}
3. SMS Invitations with AWS SNS
Setup AWS SNS:
import { SNSClient, PublishCommand } from '@aws-sdk/client-sns';
const snsClient = new SNSClient({
region: 'us-east-1',
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
}
});
async function sendReferralSMS(params: {
fromName: string;
toPhone: string;
referralLink: string;
}) {
// Shorten URL (SMS has 160 char limit)
const shortLink = await shortenUrl(params.referralLink);
const message = `${params.fromName} invited you to try WebZum! Create your website in 5 minutes: ${shortLink}`;
const command = new PublishCommand({
PhoneNumber: params.toPhone,
Message: message,
MessageAttributes: {
'AWS.SNS.SMS.SenderID': {
DataType: 'String',
StringValue: 'WebZum'
},
'AWS.SNS.SMS.SMSType': {
DataType: 'String',
StringValue: 'Transactional' // Higher deliverability
}
}
});
try {
const response = await snsClient.send(command);
return {
success: true,
messageId: response.MessageId
};
} catch (error) {
console.error('SNS send failed:', error);
return {
success: false,
error: error.message
};
}
}
URL shortening (for SMS):
async function shortenUrl(longUrl: string): Promise<string> {
// Generate short code
const shortCode = crypto.randomBytes(4).toString('base64url');
// Store mapping
await db.createShortUrl({
shortCode,
longUrl,
createdAt: new Date()
});
return `https://webzum.com/s/${shortCode}`;
}
// Redirect handler
export async function GET(req: Request, { params }: { params: { code: string } }) {
const { code } = params;
const shortUrl = await db.getShortUrl(code);
if (!shortUrl) {
return NextResponse.redirect('https://webzum.com');
}
// Track click
await db.incrementShortUrlClicks(code);
return NextResponse.redirect(shortUrl.longUrl);
}
4. Referral Tracking
Track conversions:
// When user signs up, check for referral code
export async function POST(req: Request) {
const { email, password } = await req.json();
// Create user account
const user = await createUser({ email, password });
// Check for referral code in cookie
const refCode = req.cookies.get('ref_code')?.value;
if (refCode) {
// Find referrer
const referral = await db.getReferralByCode(refCode);
if (referral) {
// Record conversion
await db.recordReferralConversion({
referralId: referral.id,
referrerId: referral.userId,
referredUserId: user.id,
convertedAt: new Date()
});
// Award referrer (e.g., free month, credits)
await awardReferralBonus(referral.userId);
// Send notification to referrer
await notifyReferrer(referral.userId, user.email);
}
}
return NextResponse.json({ user });
}
Referral dashboard:
async function getReferralStats(userId: string) {
const referrals = await db.getReferralsByUser(userId);
return {
totalInvites: referrals.length,
clicks: referrals.reduce((sum, r) => sum + r.clicks, 0),
conversions: referrals.reduce((sum, r) => sum + r.conversions, 0),
conversionRate: (referrals.reduce((sum, r) => sum + r.conversions, 0) /
referrals.reduce((sum, r) => sum + r.clicks, 0)) * 100,
earnings: referrals.reduce((sum, r) => sum + r.conversions, 0) * 10 // $10 per conversion
};
}
The Challenges We Solved
Challenge 1: Email Deliverability
Problem: Emails going to spam
Solution: SPF, DKIM, DMARC configuration
# DNS records for webzum.com
# SPF (Sender Policy Framework)
TXT @ "v=spf1 include:amazonses.com ~all"
# DKIM (DomainKeys Identified Mail)
CNAME <selector>._domainkey.<domain> <selector>._domainkey.amazonses.com
# DMARC (Domain-based Message Authentication)
TXT _dmarc "v=DMARC1; p=quarantine; rua=mailto:dmarc@webzum.com"
Warm-up process:
// Gradually increase sending volume
const WARMUP_SCHEDULE = [
{ day: 1, limit: 50 },
{ day: 2, limit: 100 },
{ day: 3, limit: 200 },
{ day: 7, limit: 500 },
{ day: 14, limit: 1000 },
{ day: 30, limit: 5000 }
];
async function sendWithWarmup(email: EmailParams) {
const daysSinceLaunch = getDaysSince(LAUNCH_DATE);
const schedule = WARMUP_SCHEDULE.find(s => daysSinceLaunch <= s.day);
const todayCount = await db.getEmailsSentToday();
if (todayCount >= schedule.limit) {
// Queue for tomorrow
await db.queueEmail(email);
return { queued: true };
}
return await sendEmail(email);
}
Challenge 2: Bounce and Complaint Handling
Problem: AWS SES suspends accounts with high bounce rates
Solution: Automatic bounce handling
// SNS topic for SES notifications
export async function POST(req: Request) {
const notification = await req.json();
if (notification.Type === 'SubscriptionConfirmation') {
// Confirm SNS subscription
await fetch(notification.SubscribeURL);
return NextResponse.json({ confirmed: true });
}
const message = JSON.parse(notification.Message);
if (message.notificationType === 'Bounce') {
// Hard bounce: remove email from list
if (message.bounce.bounceType === 'Permanent') {
await db.markEmailAsBounced(message.mail.destination[0]);
}
}
if (message.notificationType === 'Complaint') {
// User marked as spam: unsubscribe immediately
await db.unsubscribeEmail(message.mail.destination[0]);
}
return NextResponse.json({ processed: true });
}
Challenge 3: SMS Deliverability (Easier!)
Problem: None! SMS just works.
Why SMS is easier:
- No spam filters (carrier filtering is minimal)
- No authentication required (no SPF/DKIM/DMARC)
- No warm-up period
- Higher open rates (98% vs 20% for email)
The catch:
- More expensive ($0.00645/SMS vs $0.0001/email)
- Character limit (160 chars)
- Opt-in required (legal requirement)
Challenge 4: Cost Optimization
Problem: SMS costs add up fast
Solution: Smart channel selection
async function sendReferralInvite(params: {
fromUserId: string;
toContact: string; // email or phone
preferredChannel?: 'email' | 'sms';
}) {
// Detect contact type
const isPhone = /^\+?[1-9]\d{1,14}$/.test(params.toContact);
const isEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(params.toContact);
// Default to email (cheaper)
let channel = params.preferredChannel || 'email';
// If phone number provided and no preference, check user's SMS budget
if (isPhone && !params.preferredChannel) {
const smsRemaining = await getUserSMSCredits(params.fromUserId);
if (smsRemaining > 0) {
channel = 'sms';
} else {
// Fall back to email (if we have it)
if (!isEmail) {
return { error: 'No SMS credits remaining' };
}
channel = 'email';
}
}
if (channel === 'email') {
return await sendReferralEmail({
fromUserId: params.fromUserId,
toEmail: params.toContact,
referralLink: await generateReferralLink(params.fromUserId)
});
} else {
return await sendReferralSMS({
fromUserId: params.fromUserId,
toPhone: params.toContact,
referralLink: await generateReferralLink(params.fromUserId)
});
}
}
The Results: $500/Month Saved
Cost comparison (1,000 invites):
Third-party services:
- SendGrid: $15/month + $0.50 = $15.50
- Twilio: $1/month + $7.90 = $8.90
- Total: $24.40/month
AWS (our solution):
- SES: $0.10 (email)
- SNS: $6.45 (SMS)
- Total: $6.55/month
Savings: $17.85/month per 1,000 invites
At 30,000 invites/month:
- Third-party: $732/month
- AWS: $196.50/month
- Savings: $535.50/month
Additional benefits:
- Full control over deliverability
- No vendor lock-in
- Detailed tracking and analytics
- Custom email templates
Why This Matters for Startups
Most startups use expensive third-party services for email/SMS. We learned:
Bad: Pay $500/month for SendGrid/Twilio before you have revenue Good: Pay $50/month for AWS SES/SNS, scale as you grow
The startup lesson: Infrastructure costs matter. AWS SES/SNS are 10x cheaper than third-party services, with the same (or better) deliverability.
Key Insights
- Email is hard: SPF/DKIM/DMARC, warm-up, bounce handling
- SMS is easy: Just works, but expensive
- AWS is cheap: 10x cheaper than alternatives
- Tracking matters: Know your conversion rates
What’s Next
We’re exploring:
- WhatsApp integration: AWS SNS supports WhatsApp Business API
- Email personalization: Dynamic content based on user data
- A/B testing: Test different email templates
- Referral rewards: Gamification (leaderboards, bonuses)
But the core insight remains: Own your infrastructure. Don’t rent it.
Try it yourself: Create a WebZum account, go to Settings → Referrals, invite a friend. Watch the email arrive in seconds.
Building a referral system? Key takeaway: AWS SES + SNS = powerful, cheap email/SMS. Don’t pay $500/month for SendGrid when AWS costs $50.
The future of startup infrastructure isn’t third-party services—it’s AWS primitives.