Building a $0 Multi-Language Newsletter System: Cloudflare Workers, D1, Resend & HMAC Auth
Every indie maker and blogger faces the same problem: how do you let visitors subscribe to your work without paying $20/month for a newsletter service and introducing another vendor lock-in?
When I decided to add a newsletter to my portfolio, I had three non-negotiable requirements:
- Zero cost — everything on free tiers or self-hosted
- Full control — no third-party data silos; subscriber data lives in my own database
- Production-grade security — HMAC-signed unsubscribe links so visitors can't unsubscribe each other
- Multi-language support — localized welcome emails and unsubscribe pages in 7 languages
I built this entirely on the Cloudflare platform and want to show you exactly how.
The Architecture: Free Tier, No Compromises
The complete stack:
- Cloudflare Workers (
support-api.elvisli.ca): Stateless backend for subscribe, unsubscribe, and admin endpoints - Cloudflare D1: SQLite database storing subscribers with geo-location metadata
- Cloudflare Pages: Frontend for admin dashboard and unsubscribe pages (7 locales)
- Resend: Free tier email API (280 emails/day) for transactional sends
- Web Crypto API: Browser + Workers native HMAC-SHA256 for token signing
Total cost: $0 per month. (If you exceed Resend's free tier, upgrade to paid; no operational burden at any scale.)
Database Schema: Subscribers + Geo
The heart is a single subscribers table in D1:
CREATE TABLE IF NOT EXISTS subscribers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
source TEXT DEFAULT 'home', -- where they signed up
lang TEXT, -- user's language preference
country TEXT, region TEXT, city TEXT, -- Cloudflare edge-injected geo
timezone TEXT, -- auto-populated by CF
ip TEXT, user_agent TEXT, -- for audit
status TEXT DEFAULT 'active', -- 'active' or 'unsubscribed'
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_email ON subscribers(email);
CREATE INDEX IF NOT EXISTS idx_status ON subscribers(status);
Why this schema?
- Email UNIQUE: Prevents duplicate signups; resubscribe attempts silently fail with fake success (anti-spam)
- Geo columns: Cloudflare Workers sees
request.cfat the edge, so you get city/country/timezone for free — no third-party IP lookup API needed - Status enum: Soft-delete pattern; unsubscribe sets
status='unsubscribed', doesn't delete (audit trail) source: Track where signups came from (homepage, email forwarding, etc.)
Backend: Workers API
The backend is a single Worker file handling three endpoints:
1. POST /api/subscribe — New Subscriber
Request:
{
"email": "user@example.com",
"lang": "zh",
"source": "home",
"website": "", // honeypot (hidden from UI)
"elapsedMs": 5000 // form load → submit timing
}
Flow:
async function handleSubscribe(request, env) {
const body = await request.json();
const { email, lang, source, website, elapsedMs } = body;
// 1. Anti-spam: silent bot rejection (no visible challenge)
if (website || elapsedMs < 1200) {
return fakeSuccess(); // bots get fake 200, don't know they're blocked
}
// 2. Validate email
if (!isValidEmail(email)) return error400("Invalid email");
// 3. Capture geo from Cloudflare edge
const cf = request.cf || {};
const country = cf.country || request.headers.get("CF-IPCountry");
const timezone = cf.timezone;
const ip = request.headers.get("CF-Connecting-IP");
// 4. Insert into D1 (UNIQUE email prevents duplicates)
const result = await env.DB.prepare(
`INSERT OR IGNORE INTO subscribers (email, lang, source, country, timezone, ip)
VALUES (?, ?, ?, ?, ?, ?)`
).bind(email, lang, source, country, timezone, ip).run();
const isNew = result.meta.changes > 0;
// 5. Send welcome email (localized, only for new subscribers)
if (isNew) {
const unsubToken = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
const unsubUrl = `https://elvisli.ca/${localePrefix(lang)}/unsubscribe?email=${email}&token=${unsubToken}`;
await sendResendEmail({
to: email,
subject: welcomeCopy(lang).subject,
html: welcomeEmailHtml(lang, unsubUrl),
});
// Also notify the site owner
await sendResendEmail({
to: env.NOTIFY_EMAIL,
subject: `📨 New subscriber: ${email}`,
html: adminNotificationHtml(email, country, timezone, lang),
});
}
return ok({ success: true, isNew, subscribed: true });
}
Key insights:
- Honeypot + timing guard: Bots either fill the hidden
websitefield or submit in < 1.2 seconds. Both get fake success (200 response but no DB insert) so they don't probe again. - Geo from
request.cf: Cloudflare automatically populates this at the edge; no third-party API call needed. - HMAC token generation: Happens at subscribe time and baked into the unsubscribe URL, sent in the email. This is the security crux—see below.
2. POST /api/unsubscribe — Self-Service Opt-Out
Request:
{
"email": "user@example.com",
"token": "a3f8d2c6..." // HMAC-SHA256(email, UNSUBSCRIBE_SECRET)
}
Flow:
async function handleUnsubscribe(request, env) {
const { email, token } = await request.json();
// Verify HMAC signature (prevents third-party unsubscribe)
const expected = await hmacToken(email, env.UNSUBSCRIBE_SECRET);
if (token !== expected) {
return error403("Invalid or expired unsubscribe link");
}
// Mark as unsubscribed (don't delete—audit trail)
await env.DB.prepare(
`UPDATE subscribers SET status = 'unsubscribed' WHERE email = ?`
).bind(email).run();
return ok({ success: true });
}
Why HMAC, not a one-time token?
- Stateless: No need to store a DB table of "unsubscribe tokens" that expires after 7 days. The signature itself is the proof.
- Bound to email:
HMAC(email, secret)always produces the same signature for that email. You can't unsubscribeattacker@malicious.comwith the signature forvictim@example.com. - Constant-time comparison: Compare signatures via bitwise XOR to prevent timing attacks.
Flow:
// Constant-time HMAC verification
async function verifyUnsubToken(email, token, secret) {
const expected = await hmacToken(email, secret);
if (expected.length !== token.length) return false;
let diff = 0;
for (let i = 0; i < expected.length; i++) {
diff |= expected.charCodeAt(i) ^ token.charCodeAt(i);
}
return diff === 0; // timing-safe comparison
}
3. GET /api/admin/subscribers — Admin Report
Protected by Authorization: Bearer ${ADMIN_TOKEN}:
const { results } = await env.DB.prepare(
`SELECT id, email, source, lang, country, city, timezone, status, created_at
FROM subscribers WHERE status = 'active' ORDER BY created_at DESC`
).all();
return json({ total: results.length, subscribers: results });
Admin can then delete a record via DELETE /api/admin/subscribers/:id with the same token.
Multi-Language Welcome Email
The welcome email is critical for onboarding and contains the unsubscribe link. It must:
- Build trust — branded, professional, honest
- Set expectations — what will they receive and how often
- Offer escape — prominent unsubscribe link so they never feel trapped
Here's the template structure:
const WELCOME_I18N = {
en: {
subject: "You're subscribed — elvisli.ca",
heading: "You're in.",
body: "Thanks for subscribing. You'll get new writing on AI, building things, and the occasional experiment — straight to your inbox. No spam, no noise.",
cta: "Read the latest →",
footer: "You're receiving this because someone subscribed this email at elvisli.ca.",
notyou: "Didn't sign up?",
unsub: "Unsubscribe instantly",
},
zh: {
subject: "订阅成功 — elvisli.ca",
heading: "订阅成功 👋",
body: "感谢订阅。你将收到关于 AI、动手构建以及一些实验的新文章,直接送达收件箱。不发垃圾邮件,不打扰。",
cta: "看看最新文章 →",
footer: "你收到这封邮件,是因为有人用此邮箱在 elvisli.ca 订阅了更新。",
notyou: "不是你本人订阅的?",
unsub: "立即取消订阅",
},
// ... fr, es, ja, ko, zh-TW
};
function welcomeEmailHtml(lang, unsubUrl) {
const t = welcomeCopy(lang);
const blog = "https://elvisli.ca/blog";
return `
<div style="padding:32px 16px;background:#f5f5f5;font-family:system-ui;">
<div style="max-width:480px;margin:0 auto;background:#fff;border:1px solid #e5e5e5;border-radius:12px;">
<div style="padding:36px;">
<p style="font-size:11px;letter-spacing:0.12em;color:#888;text-transform:uppercase;">
${t.eyebrow}
</p>
<h1 style="font-size:26px;font-weight:700;margin:14px 0;">${t.heading}</h1>
<p style="font-size:15px;line-height:1.65;color:#555;margin:0 0 28px;">
${t.body}
</p>
<a href="${blog}" style="display:inline-block;background:#A3E635;color:#000;font-weight:600;padding:11px 22px;border-radius:8px;text-decoration:none;">
${t.cta}
</a>
</div>
<div style="border-top:1px solid #eee;padding:18px 36px;background:#fafafa;font-size:12px;">
<p style="color:#999;margin:0 0 8px;">${t.footer}</p>
<p style="color:#999;">
${t.notyou}
<a href="${unsubUrl}" style="color:#c0392b;font-weight:600;text-decoration:underline;">
${t.unsub}
</a>
</p>
</div>
</div>
</div>`;
}
Design notes:
- Light theme: Dark emails get stuck in spam folders. Light background, black text, good contrast.
- Lime accent (
#A3E635): Matches your site's design language. - Prominent "Didn't sign up?" line: Red, bold, at the bottom—makes it clear the door is always open. If someone maliciously subscribed a visitor's address, the visitor sees this immediately and can opt out.
- Mono eyebrow: Technical credibility; appeals to hackers/builders.
Frontend: Unsubscribe Page (7 Locales)
Each unsubscribe page (/[locale]/unsubscribe) is read-only:
export default function UnsubscribePage() {
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
const [status, setStatus] = useState('init');
useEffect(() => {
// Email + token are read ONLY from the signed link
const params = new URLSearchParams(window.location.search);
setEmail(params.get('email'));
setToken(params.get('token'));
}, []);
const handleUnsubscribe = async () => {
const res = await fetch('https://support-api.elvisli.ca/api/unsubscribe', {
method: 'POST',
body: JSON.stringify({ email, token }),
});
if (res.ok) setStatus('done');
else if (res.status === 403) setStatus('invalid');
};
return (
<div>
<p>You're about to unsubscribe this address:</p>
{/* Email is locked and read-only */}
<div style={{ background: '#f5f5f5', padding: '11px', borderRadius: '8px', fontFamily: 'monospace' }}>
{email}
</div>
<button onClick={handleUnsubscribe}>Confirm Unsubscribe</button>
</div>
);
}
Why read-only email?
The HMAC token is bound to this exact email address. If the user tries to change it to attacker@evil.com, the signature no longer matches, and the backend returns 403. So the editable field was:
- Confusing (users thought they could unsub a different address)
- Pointless (the backend would reject it anyway)
Lock it and reduce cognitive load.
Admin Dashboard
The frontend admin has a "Subscribers" tab where you can:
- View all: Email, location (city/region/country), timezone, language, source, signup date
- Search & filter: By email, location, language
- Edit mode: Click "Edit" to reveal a delete button (two-button unlock to prevent accidental deletion)
- Delete: Single subscribers via the "Delete" button
<table>
<thead>
<tr>
<th>Email</th>
<th>Location</th>
<th>Language</th>
<th>Source</th>
<th>Subscribed At</th>
<th></th>
</tr>
</thead>
<tbody>
<tr id="sub-row-5">
<td>elvis@example.com</td>
<td>Toronto, Ontario, CA</td>
<td><span class="badge">en</span></td>
<td>home</td>
<td>2026-06-18 12:34:56</td>
<td>
<button onclick="toggleSubEdit(5)">Edit</button>
<span id="sub-actions-5" style="display:none;">
<button onclick="deleteSubscriber(5, 'elvis@example.com')">Delete</button>
<button onclick="toggleSubEdit(5)">Cancel</button>
</span>
</td>
</tr>
</tbody>
</table>
The delete button is hidden by default (only shows after clicking Edit), preventing fat-finger accidents.
Secrets & Environment
Store all sensitive data in Cloudflare Secrets, never in wrangler.toml:
npx wrangler secret put ADMIN_TOKEN
npx wrangler secret put UNSUBSCRIBE_SECRET
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put NOTIFY_EMAIL
npx wrangler secret put NOTIFY_FROM
In wrangler.jsonc (NOT secrets, public-ish):
[vars]
ALLOWED_ORIGIN = "*"
[[d1_databases]]
binding = "DB"
database_name = "newsletter-db"
database_id = "your-d1-uuid"
Deployment Checklist
-
Create D1 database:
npx wrangler d1 create newsletter-db
npx wrangler d1 execute newsletter-db --local --file=schema.sql
npx wrangler d1 execute newsletter-db --remote --file=schema.sql
Set secrets:
npx wrangler secret put ADMIN_TOKEN # random string, e.g., openssl rand -hex 32
npx wrangler secret put UNSUBSCRIBE_SECRET # same
npx wrangler secret put RESEND_API_KEY # from https://resend.com
npx wrangler secret put NOTIFY_EMAIL # your email
Deploy Worker:
npx wrangler deploy
Deploy Unsubscribe Pages (Next.js):
npm run deploy
Test:
curl -X POST https://support-api.elvisli.ca/api/subscribe \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","lang":"en","source":"test"}'
Security Checklist
- [ ] Email addresses in unsubscribe URLs are URL-encoded (
encodeURIComponent) - [ ] HMAC tokens use constant-time comparison (prevent timing attacks)
- [ ] All user input in HTML emails is escaped (prevent XSS in email clients)
- [ ] Honeypot + timing guard reject bots silently (no error messages that leak info)
- [ ]
UNSUBSCRIBE_SECRETis at least 32 random bytes (strong HMAC key) - [ ] D1 schema uses
UNIQUEonemail(prevent duplicates at DB level) - [ ] Unsubscribe links include the locale prefix so they don't 404
- [ ] Welcome emails only send on the first signup, not on re-subscription
The Full Picture
When a visitor subscribes:
- Frontend form captures email + language + honeypot/timing
- POST /api/subscribe validates, anti-spam checks, geo capture
- D1 insert (UNIQUE constraint dedupes; resubscribe rejects silently)
- HMAC token generation for this specific email
- Welcome email sent to subscriber (localized) with signed unsubscribe URL
- Admin notification email sent to you with subscriber geo + transient unsub link
When the subscriber clicks unsubscribe:
- Unsubscribe page (
/[locale]/unsubscribe?email=...&token=...) displays their email (read-only) - Click confirm → POST
/api/unsubscribewith email + token - Backend verifies HMAC — if token ≠ HMAC(email, secret), return 403
- D1 update sets
status='unsubscribed' - Confirmation page shows "You've been unsubscribed"
The entire system is stateless, scalable, and costs $0 because everything runs on Cloudflare's free tier.
Next Steps
- Integrate with your newsletter sender (e.g., loop over
SELECT * FROM subscribers WHERE status='active'to build your mailing list each week) - Add a
campaignstable to track opens/clicks (use redirect URLs likehttps://support-api.elvisli.ca/click?campaign_id=...&subscriber_id=...) - Sync with a CRM via API (e.g., pull into Mailchimp for segmentation)
- A/B test welcome email copy and measure unsubscribe rates
The foundation you've built is rock-solid. From here, you own your audience.
Live demo: Subscribe at elvisli.ca/en and check your inbox for the welcome email.

