Summit Themes
Blog

How to send an email campaign with a payment provider, Resend, and Cloudflare Workers

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 validateEvent helper from @polar-sh/sdk/webhooks does the HMAC-SHA256 check in one line.
  • Store secrets as secrets, not vars. Anything put in wrangler.toml under [vars] is visible in the dashboard and in source control. API keys belong in wrangler 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.