After the payment funnel works, the next dependency most likely to break in an indie site is email. Stripe webhook failures nobody notices, users not receiving password resets, no confirmation after subscribing — these aren’t payment issues, they’re email issues.
Why Resend
Three candidates:
- AWS SES — cheap ($0.10 / 1k), but cards, IAM, SNS, signature versions. Half a day to set up, one wrong parameter and you get a 403.
- Mailgun — old, API still works, free tier 100/day is too small.
- Resend — newer, modern API, React Email / template inspector / console logs all polished. Free tier 100/day + 3,000/month, enough for an indie site.
I picked Resend. Reason: for an indie maker, API experience is 10× more important than per-email cost. The money you’d save on SES in a year doesn’t cover a half-day of misconfiguration.
Domain verification (SPF + DKIM + DMARC)
Before sending, verify the from-domain or 99% of your mail lands in spam. Resend’s console asks for laowe.club and auto-generates three records:
- SPF: TXT for
send.resend.com - DKIM: CNAME for
resend._domainkey - DMARC: TXT for
_dmarc(start withp=none, watch for two weeks)
After DNS updates, wait 5-30 minutes — Resend’s “Verify” button goes green. Resend returns 422 for unverified from addresses — that’s a feature. It back-proves the API key, network, and domain all work.
Sending from a Worker
No SDK. Plain fetch against the REST endpoint. An SDK adds a dependency, its Node transport is heavier than workerd needs, and we only POST:
const RESEND_ENDPOINT = "https://api.resend.com/emails";
await fetch(RESEND_ENDPOINT, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
from: "hello@laowe.club",
to: [input.email],
subject: "Thanks for subscribing",
html: "<p>...</p>",
text: "Thanks!...",
}),
});
The nodejs_compat flag has to be on in wrangler.toml (workerd doesn’t have node:crypto etc. by default).
Two-email pattern
For subscribe / order / signup, send two:
- Admin notification — to me, telling me “someone just X’d.” Set
reply_toto the user’s address so I can reply directly. - User confirmation — to the user, welcome / order details / reset link.
Resend counts both as two sends; on the free 3,000/month tier that’s fine.
Soft-fail fallback (the critical bit)
When a Worker handles a Stripe webhook, an email failure must not throw a 5xx — Stripe retries forever, the bill explodes. Right pattern:
try {
await sendOrderConfirmation(env, input);
} catch (error) {
// failure lands in KV, founder recovers manually
await env.ORDERS.put(`order:${eventId}-email-failed`, JSON.stringify({...}));
console.error("[order-email] Resend failure:", error);
}
// 200 to Stripe regardless of email outcome
return new Response(null, { status: 200 });
KV is the last line. The ORDERS namespace stores both the order itself and the email-failed flag — the founder can sweep KV later to recover customers whose mail was lost.
User privacy (don’t log plaintext emails)
console.log("[subscribe] captured: ${email}") looks harmless, but:
- Cloudflare worker logs feed into third-party log systems (Datadog / Logflare / your own)
- Email is PII, compliance issue (GDPR / CCPA)
- If the log platform gets popped, every subscriber’s address is leaked
Fix: node:crypto sha256[:12] fingerprint. Log only fp=01a57457d588 locale=zh — enough to dedupe, nothing leaks.
import { createHash } from "node:crypto";
const fp = createHash("sha256").update(email.toLowerCase()).digest("hex").slice(0, 12);
console.log(`[subscribe] captured: fp=${fp} locale=${locale}`);
Debug lessons (0 → production)
- Send a test from the Resend console first — confirms from / API key / network. Failures are explicit (404 / 401 / 422), fix one at a time.
- Trigger with
smoke-verify@example.com— Resend returns 422 (example.comnot allowed), but that back-proves the API call, auth, and JSON shape are correct. - Before going live, walk through the full flow with a real email (subscribe / forgot / verify). Confirm it lands in the inbox, not spam.
- Watch the Resend console / Emails page — send status, spam complaints, bounces all live there. 80% of production issues are one look-up.
After 30 days, all three flows (subscribe / forgot-password / email-verification) verified in production. Zero missed, zero mis-sent.
Closing
Email looks like “infrastructure” in an indie site, but it’s the last mile of the payment funnel. Resend + workerd fetch + KV soft-fail + fingerprinted logs turns “user complains, then you find out” into “console shows it, KV auto-catches it.”
If you’re building an indie site, half a day on this buys you months of peace.
— Lao Wei