Email is still where the money is. A customer buys a theme, downloads a product, or renews a subscription — and the first thing they expect is an email. The second thing they expect is that the email actually arrives, looks right, and doesn't end up in spam. Getting all three right without paying enterprise SaaS prices is the trick, and the combination of a modern payment provider, Resend, and Cloudflare Workers gets you there with code you own and infrastructure that scales to zero.
This tutorial walks through two complementary flows: a transactional webhook flow (payment happens → email fires immediately) and a scheduled campaign flow (a cron-driven Worker sends a batch to an audience list on a schedule). Both run on Cloudflare Workers and call the Resend API. The examples use Polar.sh as the payment provider, but the webhook shape is close enough to any Standard Webhooks-compliant provider that the pattern transfers.
What you need
- A Resend account with a verified sending domain
- A Polar.sh account (or another payment provider that fires webhooks)
- A Cloudflare account with Workers enabled
- Wrangler CLI installed:
npm install -g wrangler
Project setup
Scaffold a new Worker project and install the Resend SDK.
npm create cloudflare@latest email-worker -- --type=hello-world
cd email-worker
npm install resend @polar-sh/sdk
Create a .dev.vars file for local development. This file is never committed.
RESEND_API_KEY=re_your_key_here
POLAR_WEBHOOK_SECRET=whsec_your_secret_here
For production, store both values as encrypted secrets rather than plain environment variables:
npx wrangler secret put RESEND_API_KEY
npx wrangler secret put POLAR_WEBHOOK_SECRET
Part 1: Transactional emails triggered by a payment webhook
When a customer pays, Polar fires a order.paid webhook to a URL you specify in the Polar dashboard. Your Worker receives the request, verifies the signature, and fires a confirmation email via Resend.
Configure the wrangler.toml
name = "email-worker"
main = "src/index.js"
compatibility_date = "2025-01-01"
No bindings are needed for the transactional flow — the Worker reads secrets from env at runtime.
Write the Worker
import { Resend } from 'resend';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
// Only handle POST requests to /webhook
if (request.method !== 'POST' || url.pathname !== '/webhook') {
return new Response('Not found', { status: 404 });
}
// Polar requires the raw body for signature verification
const rawBody = await request.arrayBuffer();
const bodyText = new TextDecoder().decode(rawBody);
let event;
try {
event = validateEvent(
bodyText,
Object.fromEntries(request.headers),
env.POLAR_WEBHOOK_SECRET,
);
} catch (err) {
if (err instanceof WebhookVerificationError) {
return new Response('Forbidden', { status: 403 });
}
throw err;
}
// Only act on paid orders
if (event.type !== 'order.paid') {
return new Response('OK', { status: 202 });
}
const resend = new Resend(env.RESEND_API_KEY);
const order = event.data;
const customerEmail = order.customer?.email;
const productName = order.product?.name ?? 'your purchase';
if (!customerEmail) {
console.error('No customer email on order', order.id);
return new Response('OK', { status: 202 });
}
const { error } = await resend.emails.send({
from: 'Your Store <[email protected]>',
to: [customerEmail],
subject: `You're all set — ${productName}`,
html: `
<h1>Thanks for your order!</h1>
<p>Your download link will arrive in a separate email from our delivery system.</p>
<p>Order ID: <strong>${order.id}</strong></p>
<p>If you have any questions, reply to this email.</p>
`,
});
if (error) {
console.error('Resend error', error);
// Return 202 so Polar doesn't retry — log the failure instead
}
return new Response('OK', { status: 202 });
},
};
A few things worth explaining in that code. The signature check is first and non-negotiable — returning 403 on failure means Polar logs the event as rejected and you can audit it in the dashboard. The raw body must go to validateEvent as-is; parsing it through request.json() first breaks HMAC verification because the byte representation changes. And always return 202 (Accepted) even when you decide not to act — the HTTP status code signals receipt, not whether you did anything with it.
Register the webhook in Polar
In the Polar dashboard, go to Settings → Webhooks → Add Endpoint. Set the URL to https://email-worker.your-subdomain.workers.dev/webhook and subscribe to the order.paid event. Copy the webhook secret into your Worker environment.
Part 2: Scheduled campaign emails to an audience
Resend has an Audiences and Broadcasts API that lets you manage a contact list and send bulk emails to it. For a scheduled campaign — say, a monthly newsletter or a weekly digest — a Cloudflare cron trigger is a clean fit. The Worker fires on a schedule, creates a broadcast, and sends it to a named audience.
Create an audience in Resend
Do this once from the Resend dashboard (or via their API). Note the audience ID — it is a UUID that looks like 78261eea-8f8b-4381-83c6-79fa7120f1cf.
Add contacts to the audience via the API or through the CSV import in the dashboard. The contact fields are email, first_name, last_name, and unsubscribed.
// One-time: add a subscriber programmatically
import { Resend } from 'resend';
const resend = new Resend('re_your_key');
await resend.contacts.create({
audienceId: '78261eea-8f8b-4381-83c6-79fa7120f1cf',
email: '[email protected]',
firstName: 'Alex',
unsubscribed: false,
});
Update wrangler.toml for the cron trigger
name = "email-worker"
main = "src/index.js"
compatibility_date = "2025-01-01"
[triggers]
# Fire at 09:00 UTC every Monday
crons = ["0 9 * * 1"]
Cloudflare cron expressions use POSIX five-field format. All schedules run in UTC. The minimum interval is once per minute.
Add the scheduled handler
A Worker can export both a fetch handler and a scheduled handler from the same file. The scheduled handler receives a controller object with a cron property that tells you which expression fired, useful if you configure multiple schedules.
import { Resend } from 'resend';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
const AUDIENCE_ID = '78261eea-8f8b-4381-83c6-79fa7120f1cf';
export default {
// Webhook handler from Part 1 stays here unchanged
async fetch(request, env, ctx) { /* ... */ },
// Runs on the cron schedule
async scheduled(controller, env, ctx) {
const resend = new Resend(env.RESEND_API_KEY);
// Step 1: create a draft broadcast
const { data: broadcast, error: createError } = await resend.broadcasts.create({
audienceId: AUDIENCE_ID,
from: 'Your Store <[email protected]>',
subject: 'What shipped this week',
html: `
<h1>This week at Your Store</h1>
<p>Hi {{{FIRST_NAME|there}}},</p>
<p>Here's what's new...</p>
<p><a href="{{{RESEND_UNSUBSCRIBE_URL}}}">Unsubscribe</a></p>
`,
});
if (createError || !broadcast) {
console.error('Failed to create broadcast', createError);
return;
}
// Step 2: send it
const { error: sendError } = await resend.broadcasts.send(broadcast.id);
if (sendError) {
console.error('Failed to send broadcast', sendError);
} else {
console.log('Broadcast sent', broadcast.id);
}
},
};
Notice the {{{FIRST_NAME|there}}} placeholder — Resend replaces this with the contact's first name, falling back to "there" if the field is empty. The {{{RESEND_UNSUBSCRIBE_URL}}} placeholder is required for CAN-SPAM and GDPR compliance; Resend injects a working unsubscribe link automatically.
Test the cron locally
You can trigger the scheduled handler during local development without waiting for the clock:
npx wrangler dev --test-scheduled
Then in a second terminal:
curl "http://localhost:8787/__scheduled?cron=0+9+*+*+1"
Wrangler exposes the /__scheduled route only when --test-scheduled is active — it is never exposed in production.
Sending transactional batch emails
Sometimes you need to fire individual personalized emails to a list without using the broadcast system — for example, sending a different download link to each customer who purchased a specific product version. The resend.batch.send() method handles up to 100 emails per call:
const recipients = [
{ email: '[email protected]', firstName: 'Alice', downloadUrl: 'https://...' },
{ email: '[email protected]', firstName: 'Bob', downloadUrl: 'https://...' },
];
const { data, error } = await resend.batch.send(
recipients.map((r) => ({
from: 'Your Store <[email protected]>',
to: [r.email],
subject: 'Your download is ready',
html: `<p>Hi ${r.firstName}, <a href="${r.downloadUrl}">click here</a> to download.</p>`,
})),
);
If your list is larger than 100, chunk it and send one batch per cron interval, or use Workers Queues to spread the load without hitting rate limits.
Deploy
npx wrangler deploy
Wrangler registers the cron trigger automatically from wrangler.toml. You can verify it in the Cloudflare dashboard under Workers → your Worker → Triggers. The webhook endpoint is live at the Workers URL the moment deploy finishes.
A few things to keep in mind
- Cloudflare does not retry failed cron invocations. If your scheduled handler throws, the next run is the next scheduled time. Build any critical retry logic inside the handler itself, or use Cloudflare Workflows for durable execution.
- Always verify webhook signatures. An unverified endpoint will process any POST request that hits it. The
validateEventhelper from@polar-sh/sdk/webhooksdoes the HMAC-SHA256 check in one line. - Store secrets as secrets, not vars. Anything put in
wrangler.tomlunder[vars]is visible in the dashboard and in source control. API keys belong inwrangler secret put. - Include an unsubscribe link. For any email going to a list — even a small one — use the
{{{RESEND_UNSUBSCRIBE_URL}}}placeholder. It is required by law in most jurisdictions and Resend handles the mechanics for you.
The full pattern — payment provider fires webhook, Worker verifies and acts, Resend delivers the email — takes maybe an afternoon to wire up and costs almost nothing to run at the volumes most small businesses operate at. Once it's in place, you have one less category of SaaS subscription to maintain and full visibility into what's being sent and when.