Back to Blog

How to Send Emails from Cloudflare Workers (2026 Guide)

18 min read

Most "how to send email from Cloudflare Workers" tutorials show a single fetch call and stop. That's fine for a one-off notification. It's not fine when you need to send welcome emails, password resets, payment receipts, and onboarding sequences from the edge.

This guide covers the full picture: picking a provider, using environment bindings, building HTML email templates with React Email, sending emails in the background with ctx.waitUntil, handling Stripe webhooks with Web Crypto, processing bulk sends with Queues, and shipping to production. All code examples use the Workers module format with TypeScript.

Pick an Email Provider

You have three solid options. Each code example below lets you switch between them.

  • Sequenzy is built for SaaS. Transactional emails, marketing campaigns, automated sequences, subscriber management, all from one API. If you're building a SaaS product, this is the simplest path because you won't need to glue together three different tools later. Native Stripe integration and built-in retries.
  • Resend is a developer-friendly transactional email API. Clean DX, good docs, solid deliverability. They have one-off broadcast campaigns but no automations or sequences.
  • SendGrid is the enterprise standard. Feature-rich, sometimes complex. Good if you need high volume and don't mind a bigger API surface.

Workers use the standard fetch API, so you don't need SDKs. Every provider is just an HTTP endpoint. That said, you can use npm packages with the Workers bundler if you prefer.

Create a Worker

npm create cloudflare@latest -- send-email-worker
cd send-email-worker

Choose the "Hello World" TypeScript template when prompted.

Set Your API Key

Workers don't use .env files. Secrets are stored as encrypted environment bindings via wrangler secret:

Terminal
wrangler secret put SEQUENZY_API_KEY
Terminal
wrangler secret put RESEND_API_KEY
Terminal
wrangler secret put SENDGRID_API_KEY

This prompts you to paste your key. It's encrypted at rest and only available to your Worker at runtime. Never put API keys in wrangler.toml — that file gets committed to git.

For local development, create a .dev.vars file (which is gitignored by default):

.dev.vars
SEQUENZY_API_KEY=sq_your_api_key_here
.dev.vars
RESEND_API_KEY=re_your_api_key_here
.dev.vars
SENDGRID_API_KEY=SG.your_api_key_here

Define Your Environment Bindings

Workers access secrets and bindings through a typed Env interface. Define it once and use it everywhere:

// src/types.ts
export interface Env {
  SEQUENZY_API_KEY: string;
  // Add other bindings as needed:
  // MY_KV_NAMESPACE: KVNamespace;
  // MY_QUEUE: Queue<EmailMessage>;
}

This is the Workers equivalent of process.env. The difference is that Env is passed as a parameter to your handler functions, so there's no global state and every binding is type-safe.

Send Your First Email

The simplest possible email. No SDK, just fetch. Workers have the Fetch API built in — zero dependencies needed.

src/index.ts
interface Env {
SEQUENZY_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const url = new URL(request.url);
  if (url.pathname !== "/api/send") {
    return new Response("Not found", { status: 404 });
  }

  const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      to: "user@example.com",
      subject: "Hello from Workers",
      body: "<p>Your Worker is sending emails from the edge.</p>",
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    return Response.json({ error }, { status: 500 });
  }

  return Response.json(await response.json());
},
};
src/index.ts
interface Env {
RESEND_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const url = new URL(request.url);
  if (url.pathname !== "/api/send") {
    return new Response("Not found", { status: 404 });
  }

  const response = await fetch("https://api.resend.com/emails", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.RESEND_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      from: "Your App <noreply@yourdomain.com>",
      to: "user@example.com",
      subject: "Hello from Workers",
      html: "<p>Your Worker is sending emails from the edge.</p>",
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    return Response.json({ error }, { status: 500 });
  }

  return Response.json(await response.json());
},
};
src/index.ts
interface Env {
SENDGRID_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env): Promise<Response> {
  if (request.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const url = new URL(request.url);
  if (url.pathname !== "/api/send") {
    return new Response("Not found", { status: 404 });
  }

  const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${env.SENDGRID_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      personalizations: [{ to: [{ email: "user@example.com" }] }],
      from: { email: "noreply@yourdomain.com" },
      subject: "Hello from Workers",
      content: [{ type: "text/html", value: "<p>Your Worker is sending emails from the edge.</p>" }],
    }),
  });

  if (!response.ok) {
    const error = await response.text();
    return Response.json({ error }, { status: 500 });
  }

  return Response.json({ sent: true });
},
};

Test it locally:

wrangler dev
curl -X POST http://localhost:8787/api/send

Create a Reusable Email Helper

Copy-pasting fetch calls gets old fast. Create a helper function:

src/email.ts
interface Env {
SEQUENZY_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
body: string;
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(params),
});

if (!response.ok) {
  const error = await response.text();
  throw new Error(`Email send failed (${response.status}): ${error}`);
}

return response.json() as Promise<{ jobId: string }>;
}
src/email.ts
interface Env {
RESEND_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.RESEND_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: params.from ?? "Your App <noreply@yourdomain.com>",
    to: params.to,
    subject: params.subject,
    html: params.html,
  }),
});

if (!response.ok) {
  const error = await response.text();
  throw new Error(`Email send failed (${response.status}): ${error}`);
}

return response.json() as Promise<{ id: string }>;
}
src/email.ts
interface Env {
SENDGRID_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SENDGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: params.to }] }],
    from: { email: params.from ?? "noreply@yourdomain.com" },
    subject: params.subject,
    content: [{ type: "text/html", value: params.html }],
  }),
});

if (!response.ok) {
  const error = await response.text();
  throw new Error(`Email send failed (${response.status}): ${error}`);
}

return { sent: true };
}

Notice the pattern: env is always the first parameter. Workers don't have global process.env, so you pass the environment explicitly. This is actually better — it makes dependencies explicit and testing easier.

Build Email Templates with React Email

Raw HTML strings get messy fast. React Email lets you build email templates as React components that compile to email-safe HTML with inline styles.

npm install @react-email/components react react-dom

Here's a welcome email template:

// src/emails/welcome.tsx
import {
  Body,
  Container,
  Head,
  Heading,
  Html,
  Link,
  Preview,
  Text,
} from "@react-email/components";
 
interface WelcomeEmailProps {
  name: string;
  loginUrl: string;
}
 
export default function WelcomeEmail({ name, loginUrl }: WelcomeEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>Welcome to the team, {name}</Preview>
      <Body style={{ backgroundColor: "#f6f9fc", fontFamily: "sans-serif" }}>
        <Container
          style={{
            backgroundColor: "#ffffff",
            padding: "40px",
            borderRadius: "8px",
            margin: "40px auto",
            maxWidth: "480px",
          }}
        >
          <Heading style={{ fontSize: "24px", marginBottom: "16px" }}>
            Welcome, {name}
          </Heading>
          <Text style={{ fontSize: "16px", lineHeight: "1.6", color: "#374151" }}>
            Your account is ready. You can log in and start exploring.
          </Text>
          <Link
            href={loginUrl}
            style={{
              display: "inline-block",
              backgroundColor: "#f97316",
              color: "#ffffff",
              padding: "12px 24px",
              borderRadius: "6px",
              textDecoration: "none",
              fontSize: "14px",
              fontWeight: "600",
              marginTop: "16px",
            }}
          >
            Go to Dashboard
          </Link>
        </Container>
      </Body>
    </Html>
  );
}

Then render and send it:

src/routes/welcome.ts
import { render } from "@react-email/components";
import WelcomeEmail from "./emails/welcome";
import { sendEmail } from "./email";

interface Env {
SEQUENZY_API_KEY: string;
}

export async function handleWelcome(request: Request, env: Env): Promise<Response> {
const { name, email } = await request.json<{ name: string; email: string }>();

if (!name || !email) {
  return Response.json({ error: "name and email required" }, { status: 400 });
}

const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

const result = await sendEmail(env, {
  to: email,
  subject: `Welcome, ${name}`,
  body: html,
});

return Response.json({ jobId: result.jobId });
}
src/routes/welcome.ts
import { render } from "@react-email/components";
import WelcomeEmail from "./emails/welcome";
import { sendEmail } from "./email";

interface Env {
RESEND_API_KEY: string;
}

export async function handleWelcome(request: Request, env: Env): Promise<Response> {
const { name, email } = await request.json<{ name: string; email: string }>();

if (!name || !email) {
  return Response.json({ error: "name and email required" }, { status: 400 });
}

const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

const result = await sendEmail(env, {
  to: email,
  subject: `Welcome, ${name}`,
  html,
});

return Response.json({ id: result.id });
}
src/routes/welcome.ts
import { render } from "@react-email/components";
import WelcomeEmail from "./emails/welcome";
import { sendEmail } from "./email";

interface Env {
SENDGRID_API_KEY: string;
}

export async function handleWelcome(request: Request, env: Env): Promise<Response> {
const { name, email } = await request.json<{ name: string; email: string }>();

if (!name || !email) {
  return Response.json({ error: "name and email required" }, { status: 400 });
}

const html = await render(WelcomeEmail({ name, loginUrl: "https://app.yoursite.com" }));

await sendEmail(env, {
  to: email,
  subject: `Welcome, ${name}`,
  html,
});

return Response.json({ sent: true });
}

More React Email Components

React Email has components for most things you need in emails:

import {
  Button,      // CTA buttons with proper email padding tricks
  Img,         // Images with alt text and sizing
  Section,     // Layout sections
  Row,         // Horizontal rows
  Column,      // Column layout
  Hr,          // Horizontal rules
  CodeBlock,   // Formatted code (for developer-facing emails)
  Tailwind,    // Use Tailwind classes instead of inline styles
} from "@react-email/components";

The Tailwind wrapper is especially useful. It lets you write Tailwind classes that get compiled to inline styles:

import { Html, Tailwind, Text, Button } from "@react-email/components";
 
export function ReceiptEmail({ amount }: { amount: string }) {
  return (
    <Html>
      <Tailwind>
        <Text className="text-2xl font-bold text-gray-900">
          Payment received
        </Text>
        <Text className="text-gray-600">
          We received your payment of {amount}.
        </Text>
        <Button
          href="https://app.yoursite.com/billing"
          className="bg-orange-500 text-white px-6 py-3 rounded-md"
        >
          View Invoice
        </Button>
      </Tailwind>
    </Html>
  );
}

You can preview your templates locally with npx react-email dev which spins up a browser preview at localhost:3000.

Background Sends with ctx.waitUntil

Workers have a 30-second CPU time limit. Email API calls are fast, but you don't want to make the user wait for them. Use ctx.waitUntil to send emails in the background while returning the response immediately:

src/index.ts
import { sendEmail } from "./email";

interface Env {
SEQUENZY_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
  const url = new URL(request.url);

  if (url.pathname === "/api/signup" && request.method === "POST") {
    const { name, email } = await request.json<{ name: string; email: string }>();

    // Save user to database first...

    // Send welcome email in the background
    ctx.waitUntil(
      sendEmail(env, {
        to: email,
        subject: `Welcome, ${name}`,
        body: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
      }).catch((err) => {
        console.error("Welcome email failed:", err);
      })
    );

    // Response returns immediately — user doesn't wait for the email
    return Response.json({ success: true, message: "Account created" });
  }

  return new Response("Not found", { status: 404 });
},
};
src/index.ts
import { sendEmail } from "./email";

interface Env {
RESEND_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
  const url = new URL(request.url);

  if (url.pathname === "/api/signup" && request.method === "POST") {
    const { name, email } = await request.json<{ name: string; email: string }>();

    // Save user to database first...

    // Send welcome email in the background
    ctx.waitUntil(
      sendEmail(env, {
        to: email,
        subject: `Welcome, ${name}`,
        html: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
      }).catch((err) => {
        console.error("Welcome email failed:", err);
      })
    );

    // Response returns immediately
    return Response.json({ success: true, message: "Account created" });
  }

  return new Response("Not found", { status: 404 });
},
};
src/index.ts
import { sendEmail } from "./email";

interface Env {
SENDGRID_API_KEY: string;
}

export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
  const url = new URL(request.url);

  if (url.pathname === "/api/signup" && request.method === "POST") {
    const { name, email } = await request.json<{ name: string; email: string }>();

    // Save user to database first...

    // Send welcome email in the background
    ctx.waitUntil(
      sendEmail(env, {
        to: email,
        subject: `Welcome, ${name}`,
        html: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
      }).catch((err) => {
        console.error("Welcome email failed:", err);
      })
    );

    // Response returns immediately
    return Response.json({ success: true, message: "Account created" });
  }

  return new Response("Not found", { status: 404 });
},
};

Key thing: always .catch() inside ctx.waitUntil. If the promise rejects without a catch, the error is silent — you'll never know the email failed. Log it so you can debug.

Routing with Hono

Once your Worker grows beyond a couple routes, raw if/else on url.pathname gets painful. Hono is a lightweight framework designed for Workers:

npm install hono
src/index.ts
import { Hono } from "hono";
import { sendEmail } from "./email";

interface Env {
SEQUENZY_API_KEY: string;
}

const app = new Hono<{ Bindings: Env }>();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json<{ name: string; email: string }>();

if (!name || !email) {
  return c.json({ error: "name and email required" }, 400);
}

const result = await sendEmail(c.env, {
  to: email,
  subject: `Welcome, ${name}`,
  body: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
});

return c.json({ jobId: result.jobId });
});

app.post("/api/send-reset", async (c) => {
const { email, token } = await c.req.json<{ email: string; token: string }>();
const resetUrl = `https://yourapp.com/reset?token=${token}`;

await sendEmail(c.env, {
  to: email,
  subject: "Reset your password",
  body: `<p>Click <a href="${resetUrl}">here</a> to reset your password. Expires in 1 hour.</p>`,
});

return c.json({ sent: true });
});

export default app;
src/index.ts
import { Hono } from "hono";
import { sendEmail } from "./email";

interface Env {
RESEND_API_KEY: string;
}

const app = new Hono<{ Bindings: Env }>();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json<{ name: string; email: string }>();

if (!name || !email) {
  return c.json({ error: "name and email required" }, 400);
}

const result = await sendEmail(c.env, {
  to: email,
  subject: `Welcome, ${name}`,
  html: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
});

return c.json({ id: result.id });
});

app.post("/api/send-reset", async (c) => {
const { email, token } = await c.req.json<{ email: string; token: string }>();
const resetUrl = `https://yourapp.com/reset?token=${token}`;

await sendEmail(c.env, {
  to: email,
  subject: "Reset your password",
  html: `<p>Click <a href="${resetUrl}">here</a> to reset your password. Expires in 1 hour.</p>`,
});

return c.json({ sent: true });
});

export default app;
src/index.ts
import { Hono } from "hono";
import { sendEmail } from "./email";

interface Env {
SENDGRID_API_KEY: string;
}

const app = new Hono<{ Bindings: Env }>();

app.post("/api/send-welcome", async (c) => {
const { name, email } = await c.req.json<{ name: string; email: string }>();

if (!name || !email) {
  return c.json({ error: "name and email required" }, 400);
}

await sendEmail(c.env, {
  to: email,
  subject: `Welcome, ${name}`,
  html: `<h1>Welcome, ${name}!</h1><p>Your account is ready.</p>`,
});

return c.json({ sent: true });
});

app.post("/api/send-reset", async (c) => {
const { email, token } = await c.req.json<{ email: string; token: string }>();
const resetUrl = `https://yourapp.com/reset?token=${token}`;

await sendEmail(c.env, {
  to: email,
  subject: "Reset your password",
  html: `<p>Click <a href="${resetUrl}">here</a> to reset your password. Expires in 1 hour.</p>`,
});

return c.json({ sent: true });
});

export default app;

Hono gives you routing, middleware, type-safe context, and it runs everywhere Workers run. It's also the framework behind many Workers templates now.

Common Email Patterns for SaaS

Here are the emails almost every SaaS app needs, with production-ready implementations.

Password Reset

src/emails/password-reset.ts
import { sendEmail } from "../email";

interface Env {
SEQUENZY_API_KEY: string;
}

export async function sendPasswordResetEmail(env: Env, email: string, resetToken: string) {
const resetUrl = `https://yourapp.com/reset-password?token=${resetToken}`;

await sendEmail(env, {
  to: email,
  subject: "Reset your password",
  body: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { sendEmail } from "../email";

interface Env {
RESEND_API_KEY: string;
}

export async function sendPasswordResetEmail(env: Env, email: string, resetToken: string) {
const resetUrl = `https://yourapp.com/reset-password?token=${resetToken}`;

await sendEmail(env, {
  to: email,
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}
src/emails/password-reset.ts
import { sendEmail } from "../email";

interface Env {
SENDGRID_API_KEY: string;
}

export async function sendPasswordResetEmail(env: Env, email: string, resetToken: string) {
const resetUrl = `https://yourapp.com/reset-password?token=${resetToken}`;

await sendEmail(env, {
  to: email,
  subject: "Reset your password",
  html: `
    <h2>Password Reset</h2>
    <p>Click the link below to reset your password. This link expires in 1 hour.</p>
    <a href="${resetUrl}"
       style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
      Reset Password
    </a>
    <p style="color:#6b7280;font-size:14px;margin-top:24px;">
      If you didn't request this, ignore this email.
    </p>
  `,
});
}

Payment Receipt

src/emails/receipt.ts
import { sendEmail } from "../email";

interface Env {
SEQUENZY_API_KEY: string;
}

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail(env: Env, { email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await sendEmail(env, {
  to: email,
  subject: `Payment receipt - ${formatted}`,
  body: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}
src/emails/receipt.ts
import { sendEmail } from "../email";

interface Env {
RESEND_API_KEY: string;
}

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail(env: Env, { email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await sendEmail(env, {
  to: email,
  subject: `Payment receipt - ${formatted}`,
  html: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}
src/emails/receipt.ts
import { sendEmail } from "../email";

interface Env {
SENDGRID_API_KEY: string;
}

interface ReceiptParams {
email: string;
amount: number;
plan: string;
invoiceUrl: string;
}

export async function sendReceiptEmail(env: Env, { email, amount, plan, invoiceUrl }: ReceiptParams) {
const formatted = `$${(amount / 100).toFixed(2)}`;

await sendEmail(env, {
  to: email,
  subject: `Payment receipt - ${formatted}`,
  html: `
    <h2>Payment Received</h2>
    <p>Thanks for your payment. Here's your receipt:</p>
    <table style="width:100%;border-collapse:collapse;margin:16px 0;">
      <tr>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;">Plan</td>
        <td style="padding:8px;border-bottom:1px solid #e5e7eb;text-align:right;">${plan}</td>
      </tr>
      <tr>
        <td style="padding:8px;font-weight:600;">Total</td>
        <td style="padding:8px;text-align:right;font-weight:600;">${formatted}</td>
      </tr>
    </table>
    <a href="${invoiceUrl}" style="color:#f97316;">View full invoice</a>
  `,
});
}

Stripe Webhook Handler

Workers don't have Node.js crypto, so you can't use stripe.webhooks.constructEvent() directly. Instead, use the Web Crypto API to verify the signature yourself:

src/routes/stripe-webhook.ts
import { sendEmail } from "../email";
import { sendReceiptEmail } from "../emails/receipt";

interface Env {
SEQUENZY_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}

// Verify Stripe webhook signature using Web Crypto API
async function verifyStripeSignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

// Check timestamp is within 5 minutes
const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) return false;

const payload = `${timestamp}.${body}`;
const key = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

export async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const valid = await verifyStripeSignature(body, signature, env.STRIPE_WEBHOOK_SECRET);
if (!valid) {
  return Response.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;

  await sendReceiptEmail(env, {
    email: session.customer_email,
    amount: session.amount_total,
    plan: session.metadata?.plan ?? "Pro",
    invoiceUrl: session.invoice_url ?? "https://yourapp.com/billing",
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await sendEmail(env, {
    to: invoice.customer_email,
    subject: "Payment failed - action needed",
    body: `
      <h2>Payment Failed</h2>
      <p>We couldn't process your payment. Please update your billing info to keep your account active.</p>
      <a href="https://yourapp.com/billing"
         style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
        Update Billing
      </a>
    `,
  });
}

return Response.json({ received: true });
}
src/routes/stripe-webhook.ts
import { sendEmail } from "../email";
import { sendReceiptEmail } from "../emails/receipt";

interface Env {
RESEND_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}

async function verifyStripeSignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) return false;

const payload = `${timestamp}.${body}`;
const key = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

export async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const valid = await verifyStripeSignature(body, signature, env.STRIPE_WEBHOOK_SECRET);
if (!valid) {
  return Response.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;

  await sendReceiptEmail(env, {
    email: session.customer_email,
    amount: session.amount_total,
    plan: session.metadata?.plan ?? "Pro",
    invoiceUrl: session.invoice_url ?? "https://yourapp.com/billing",
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await sendEmail(env, {
    to: invoice.customer_email,
    subject: "Payment failed - action needed",
    html: `
      <h2>Payment Failed</h2>
      <p>We couldn't process your payment. Please update your billing info to keep your account active.</p>
      <a href="https://yourapp.com/billing"
         style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
        Update Billing
      </a>
    `,
  });
}

return Response.json({ received: true });
}
src/routes/stripe-webhook.ts
import { sendEmail } from "../email";
import { sendReceiptEmail } from "../emails/receipt";

interface Env {
SENDGRID_API_KEY: string;
STRIPE_WEBHOOK_SECRET: string;
}

async function verifyStripeSignature(
body: string,
signature: string,
secret: string
): Promise<boolean> {
const parts = Object.fromEntries(
  signature.split(",").map((part) => {
    const [key, value] = part.split("=");
    return [key, value];
  })
);

const timestamp = parts["t"];
const expectedSig = parts["v1"];
if (!timestamp || !expectedSig) return false;

const age = Math.floor(Date.now() / 1000) - parseInt(timestamp);
if (age > 300) return false;

const payload = `${timestamp}.${body}`;
const key = await crypto.subtle.importKey(
  "raw",
  new TextEncoder().encode(secret),
  { name: "HMAC", hash: "SHA-256" },
  false,
  ["sign"]
);

const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
const computed = Array.from(new Uint8Array(sig))
  .map((b) => b.toString(16).padStart(2, "0"))
  .join("");

return computed === expectedSig;
}

export async function handleStripeWebhook(request: Request, env: Env): Promise<Response> {
const body = await request.text();
const signature = request.headers.get("stripe-signature");

if (!signature) {
  return Response.json({ error: "Missing signature" }, { status: 400 });
}

const valid = await verifyStripeSignature(body, signature, env.STRIPE_WEBHOOK_SECRET);
if (!valid) {
  return Response.json({ error: "Invalid signature" }, { status: 400 });
}

const event = JSON.parse(body);

if (event.type === "checkout.session.completed") {
  const session = event.data.object;

  await sendReceiptEmail(env, {
    email: session.customer_email,
    amount: session.amount_total,
    plan: session.metadata?.plan ?? "Pro",
    invoiceUrl: session.invoice_url ?? "https://yourapp.com/billing",
  });
}

if (event.type === "invoice.payment_failed") {
  const invoice = event.data.object;

  await sendEmail(env, {
    to: invoice.customer_email,
    subject: "Payment failed - action needed",
    html: `
      <h2>Payment Failed</h2>
      <p>We couldn't process your payment. Please update your billing info to keep your account active.</p>
      <a href="https://yourapp.com/billing"
         style="display:inline-block;background:#f97316;color:#fff;padding:12px 24px;border-radius:6px;text-decoration:none;">
        Update Billing
      </a>
    `,
  });
}

return Response.json({ received: true });
}

The verifyStripeSignature function uses crypto.subtle.importKey and crypto.subtle.sign — both available in Workers without any polyfills. This is the Workers-native way to handle Stripe webhooks.

Scheduled Emails with Cron Triggers

Workers Cron Triggers let you send emails on a schedule. Add a scheduled handler alongside your fetch handler:

// src/index.ts
import { sendEmail } from "./email";
 
interface Env {
  SEQUENZY_API_KEY: string;
}
 
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // ... your HTTP routes
    return new Response("OK");
  },
 
  async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
    // Runs on the configured schedule
    ctx.waitUntil(
      sendEmail(env, {
        to: "team@yourcompany.com",
        subject: `Daily Report - ${new Date().toLocaleDateString()}`,
        body: "<h1>Daily Report</h1><p>Here are today's metrics...</p>",
      })
    );
  },
};

Configure the schedule in wrangler.toml:

# wrangler.toml
[triggers]
crons = ["0 9 * * *"]  # Every day at 9am UTC

You can have multiple crons and check event.cron to run different logic:

async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
  switch (event.cron) {
    case "0 9 * * *":
      // Daily digest at 9am
      ctx.waitUntil(sendDailyDigest(env));
      break;
    case "0 9 * * 1":
      // Weekly summary on Mondays
      ctx.waitUntil(sendWeeklySummary(env));
      break;
  }
}
[triggers]
crons = ["0 9 * * *", "0 9 * * 1"]

Bulk Sends with Workers Queues

For sending emails to many recipients (like a batch of weekly digests), use Workers Queues. Queues let you produce messages from one Worker and consume them in another, with automatic retries and batching.

# wrangler.toml
[[queues.producers]]
queue = "email-queue"
binding = "EMAIL_QUEUE"
 
[[queues.consumers]]
queue = "email-queue"
max_batch_size = 10
max_retries = 3
// src/index.ts
import { sendEmail } from "./email";
 
interface EmailMessage {
  to: string;
  subject: string;
  body: string;
}
 
interface Env {
  SEQUENZY_API_KEY: string;
  EMAIL_QUEUE: Queue<EmailMessage>;
}
 
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
 
    if (url.pathname === "/api/send-batch" && request.method === "POST") {
      const { recipients } = await request.json<{
        recipients: Array<{ email: string; name: string }>;
      }>();
 
      // Enqueue each email — they'll be processed in batches
      for (const recipient of recipients) {
        await env.EMAIL_QUEUE.send({
          to: recipient.email,
          subject: "Your weekly digest",
          body: `<h1>Hey ${recipient.name}</h1><p>Here's what happened this week...</p>`,
        });
      }
 
      return Response.json({ queued: recipients.length });
    }
 
    return new Response("Not found", { status: 404 });
  },
 
  // Queue consumer — processes emails in batches
  async queue(batch: MessageBatch<EmailMessage>, env: Env) {
    for (const message of batch.messages) {
      try {
        await sendEmail(env, message.body);
        message.ack();
      } catch (error) {
        console.error(`Failed to send to ${message.body.to}:`, error);
        message.retry();
      }
    }
  },
};

Queues handle retries, dead-letter routing, and back-pressure automatically. Much better than a for loop with fetch calls in a single invocation.

Error Handling

Emails fail. Networks time out. Rate limits hit. Here's how to handle it properly in Workers.

src/email.ts
interface Env {
SEQUENZY_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
body: string;
}

export class EmailSendError extends Error {
constructor(
  message: string,
  public status: number,
  public retryable: boolean
) {
  super(message);
  this.name = "EmailSendError";
}
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.sequenzy.com/v1/transactional/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(params),
});

if (!response.ok) {
  const error = await response.text();

  if (response.status === 429) {
    throw new EmailSendError("Rate limited", 429, true);
  }
  if (response.status === 401) {
    throw new EmailSendError("Invalid API key", 401, false);
  }
  if (response.status >= 500) {
    throw new EmailSendError(`Server error: ${error}`, response.status, true);
  }

  throw new EmailSendError(`Send failed: ${error}`, response.status, false);
}

return response.json() as Promise<{ jobId: string }>;
}
src/email.ts
interface Env {
RESEND_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

export class EmailSendError extends Error {
constructor(
  message: string,
  public status: number,
  public retryable: boolean
) {
  super(message);
  this.name = "EmailSendError";
}
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.resend.com/emails", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.RESEND_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: params.from ?? "Your App <noreply@yourdomain.com>",
    to: params.to,
    subject: params.subject,
    html: params.html,
  }),
});

if (!response.ok) {
  const error = await response.text();

  if (response.status === 429) {
    throw new EmailSendError("Rate limited", 429, true);
  }
  if (response.status === 401 || response.status === 403) {
    throw new EmailSendError("Invalid API key", response.status, false);
  }
  if (response.status >= 500) {
    throw new EmailSendError(`Server error: ${error}`, response.status, true);
  }

  throw new EmailSendError(`Send failed: ${error}`, response.status, false);
}

return response.json() as Promise<{ id: string }>;
}
src/email.ts
interface Env {
SENDGRID_API_KEY: string;
}

interface SendEmailParams {
to: string;
subject: string;
html: string;
from?: string;
}

export class EmailSendError extends Error {
constructor(
  message: string,
  public status: number,
  public retryable: boolean
) {
  super(message);
  this.name = "EmailSendError";
}
}

export async function sendEmail(env: Env, params: SendEmailParams) {
const response = await fetch("https://api.sendgrid.com/v3/mail/send", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SENDGRID_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    personalizations: [{ to: [{ email: params.to }] }],
    from: { email: params.from ?? "noreply@yourdomain.com" },
    subject: params.subject,
    content: [{ type: "text/html", value: params.html }],
  }),
});

if (!response.ok) {
  const error = await response.text();

  if (response.status === 429) {
    throw new EmailSendError("Rate limited", 429, true);
  }
  if (response.status === 401 || response.status === 403) {
    throw new EmailSendError("Invalid API key", response.status, false);
  }
  if (response.status >= 500) {
    throw new EmailSendError(`Server error: ${error}`, response.status, true);
  }

  throw new EmailSendError(`Send failed: ${error}`, response.status, false);
}

return { sent: true };
}

For critical emails, add a retry wrapper that respects the retryable flag:

// src/retry.ts
import { EmailSendError } from "./email";
 
export async function withRetry<T>(
  fn: () => Promise<T>,
  maxRetries = 3
): Promise<T> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (error instanceof EmailSendError && !error.retryable) {
        throw error; // Don't retry auth errors or validation errors
      }
      if (attempt === maxRetries) throw error;
      const delay = Math.pow(2, attempt) * 1000;
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
  throw new Error("Unreachable");
}
 
// Usage
await withRetry(() => sendEmail(env, { to, subject, body }));

Rate Limiting with KV

Workers KV gives you a fast, globally distributed key-value store. Use it to rate-limit email sends per recipient:

interface Env {
  SEQUENZY_API_KEY: string;
  RATE_LIMIT: KVNamespace;
}
 
async function canSendEmail(env: Env, to: string, maxPerHour = 5): Promise<boolean> {
  const key = `rate:${to}`;
  const current = await env.RATE_LIMIT.get(key);
  const count = current ? parseInt(current) : 0;
 
  if (count >= maxPerHour) return false;
 
  // Increment counter, auto-expire after 1 hour
  await env.RATE_LIMIT.put(key, String(count + 1), { expirationTtl: 3600 });
  return true;
}

Add the KV namespace to your wrangler.toml:

[[kv_namespaces]]
binding = "RATE_LIMIT"
id = "your-kv-namespace-id"

Create the namespace:

wrangler kv namespace create RATE_LIMIT

Going to Production

Before you start sending real emails, handle these things.

1. Verify Your Domain

Every email provider requires domain verification. This means adding DNS records (SPF, DKIM, and usually DMARC) to prove you own the domain you're sending from. See our SPF, DKIM, and DMARC setup guide for the full walkthrough.

Without this, your emails go straight to spam. No exceptions.

Each provider has a dashboard where you add your domain and get the DNS records to configure. Do this first.

2. Use a Dedicated Sending Domain

Send from something like mail.yourapp.com instead of your root domain. If your email reputation takes a hit, your main domain stays clean.

3. Keep Secrets in Wrangler Secrets

Never put API keys in wrangler.toml — it gets committed to git. Always use wrangler secret put.

wrangler secret put SEQUENZY_API_KEY
wrangler secret put STRIPE_WEBHOOK_SECRET

For local development, use .dev.vars (already gitignored by the Workers template).

4. Use ctx.waitUntil for Non-Critical Sends

For emails that aren't part of the response flow (welcome emails, notifications), always use ctx.waitUntil so users don't wait:

// Good: user gets response immediately
ctx.waitUntil(sendWelcomeEmail(env, email).catch(console.error));
return Response.json({ success: true });
 
// Bad: user waits for email to send
await sendWelcomeEmail(env, email);
return Response.json({ success: true });

5. Set Up Error Monitoring

Workers logs are available in the Cloudflare dashboard and via wrangler tail. For production, consider adding structured logging:

function logEmailEvent(event: string, data: Record<string, unknown>) {
  console.log(JSON.stringify({ event, timestamp: Date.now(), ...data }));
}
 
// In your email helper
logEmailEvent("email.sent", { to: params.to, subject: params.subject });
logEmailEvent("email.failed", { to: params.to, error: error.message });

6. Rate Limit Your Endpoints

Add basic rate limiting to public-facing endpoints so bots can't spam email sends through your Worker. Use KV (shown above) or a simple in-memory check for low-traffic Workers.

Beyond Transactional: Marketing Emails and Sequences

At some point, you'll want more than one-off transactional emails. You'll want to:

  • Send onboarding sequences that guide new users through your product over several days
  • Run marketing campaigns to announce features or share updates
  • Automate lifecycle emails based on what users do (or don't do) in your app
  • Track engagement to see which emails get opened and clicked

Most teams stitch together a transactional provider (Resend, SendGrid) with a separate marketing tool (Mailchimp, ConvertKit). That means two dashboards, two billing systems, and keeping subscriber lists in sync.

Sequenzy handles both from one platform. Same API, same dashboard. You get transactional sends, marketing campaigns, automated sequences, subscriber segments, and native Stripe integration for SaaS-specific automations like trial conversion and churn prevention.

Here's what subscriber management looks like from a Worker:

// Add a subscriber when they sign up
await fetch("https://api.sequenzy.com/v1/subscribers", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    firstName: "Jane",
    tags: ["signed-up"],
    customAttributes: { plan: "free", source: "organic" },
  }),
});
 
// Tag them when they upgrade
await fetch("https://api.sequenzy.com/v1/subscribers/tags", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    tag: "customer",
  }),
});
 
// Track events to trigger automated sequences
await fetch("https://api.sequenzy.com/v1/subscribers/events", {
  method: "POST",
  headers: {
    "Authorization": `Bearer ${env.SEQUENZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "user@example.com",
    event: "onboarding.completed",
    properties: { completedSteps: 5 },
  }),
});

You set up sequences in the Sequenzy dashboard (onboarding drip, trial conversion, churn prevention), and the API triggers them based on what happens in your app.

FAQ

Can I use npm packages (SDKs) in Workers?

Yes. Workers supports npm packages through the bundler. Run npm install sequenzy or npm install resend and import them normally. The Workers bundler will tree-shake and bundle everything. However, since every email provider is just an HTTP API, you can also use fetch directly with zero dependencies.

Workers have a 128 MB memory limit. Is that enough for email?

Yes. Sending emails is just a fetch call — it uses almost no memory. Even rendering React Email templates is lightweight. You'd have to be generating extremely large email bodies (think megabytes of HTML) to hit the memory limit.

What about the 30-second CPU time limit?

For individual email sends, this is a non-issue. A single fetch to an email API takes milliseconds of CPU time. The 30-second limit is for CPU-bound work, not I/O wait. If you need to send thousands of emails, use Workers Queues to process them in batches across multiple invocations.

How do I test email sending locally?

Use wrangler dev to run your Worker locally. Create a .dev.vars file with your API key. You can also use a tool like Mailpit as a local SMTP server for catching emails during development, though you'd need to adapt the provider API calls.

Can I use Nodemailer in Workers?

No. Nodemailer uses Node.js-specific APIs (TCP sockets, net module) that aren't available in the Workers runtime. Workers use the Fetch API for all network requests. Every major email provider has an HTTP API, so you don't need Nodemailer.

How do I handle email bounces and delivery status?

Workers don't receive bounce notifications directly. Set up webhooks with your email provider to handle delivery events. Create a separate Worker route to receive these webhooks, and update your database or notify your team when emails bounce.

What's the difference between wrangler secret and environment variables in wrangler.toml?

wrangler secret stores values encrypted at rest and they're only decrypted at runtime. wrangler.toml variables are in plaintext and committed to git. Always use wrangler secret for API keys and sensitive data. Use wrangler.toml [vars] for non-sensitive configuration like feature flags.

Can I send emails from Cloudflare Pages Functions?

Yes. Pages Functions use the same Workers runtime. The env bindings and ctx.waitUntil patterns from this guide work identically in Pages Functions. You'd define your function in functions/api/send.ts and configure secrets in the Pages dashboard.

Do I need to worry about cold starts?

Workers don't have cold starts in the traditional sense. They start up in under 5 milliseconds. Your email sends will always be fast.

How do I send attachments from Workers?

Email provider APIs support attachments as base64-encoded data in the JSON body. With Sequenzy or Resend, add an attachments array to your send request. Keep in mind the Workers request size limit (100 MB for paid plans) and that most email providers limit attachment size to 25 MB.

Wrapping Up

Here's what we covered:

  1. Fetch API for zero-dependency email sending from the edge
  2. Environment bindings for type-safe, secure API key access
  3. React Email for maintainable HTML email templates
  4. ctx.waitUntil for background sends that don't block responses
  5. Hono for clean routing when your Worker grows
  6. Web Crypto for verifying Stripe webhooks without Node.js
  7. Cron Triggers for scheduled emails
  8. Workers Queues for bulk sends with automatic retries
  9. KV for rate limiting per recipient
  10. Production checklist: domain verification, secrets management, error handling

The code in this guide is production-ready. Copy the patterns that fit your app, swap in your provider of choice, and start sending.

Frequently Asked Questions

Can I use Nodemailer or SMTP in Cloudflare Workers?

No. Workers run on the V8 runtime without TCP socket support, so SMTP-based libraries like Nodemailer won't work. You must use HTTP-based email APIs (Sequenzy, Resend, SendGrid) that send emails via fetch() requests.

How do I store email API keys in Cloudflare Workers?

Use Wrangler secrets: wrangler secret put SEQUENZY_API_KEY. Access them via env.SEQUENZY_API_KEY in your Worker handler. Never put API keys in wrangler.toml—that file is committed to version control. Secrets are encrypted at rest.

What's the execution time limit for Cloudflare Workers?

Workers have a 30-second CPU time limit on the paid plan (10ms on the free plan). Sending a single email takes well under a second since it's just an HTTP API call. For bulk sends, use Cloudflare Queues or Durable Objects to process emails across multiple invocations.

How do I send bulk emails from Cloudflare Workers?

Use Cloudflare Queues to dequeue and process emails in batches. Push email jobs to a Queue from your Worker, then process them in a Queue consumer with controlled concurrency. This avoids hitting execution time limits and handles retries automatically.

Can I use React Email templates in Cloudflare Workers?

React Email's render() function depends on React's server-side rendering, which may not work in the Workers runtime due to missing Node.js APIs. Pre-render templates at build time and store the HTML, or use simple HTML template strings for Workers.

How do I handle email webhook callbacks in Cloudflare Workers?

Create a Worker route that handles POST requests, verify the webhook signature using the crypto.subtle API (available natively in Workers), parse the payload, and process the event. Workers are ideal for webhooks because of their fast cold start and global distribution.

How do I rate limit email sends from Cloudflare Workers?

Use Cloudflare's Rate Limiting rules at the infrastructure level, or implement application-level rate limiting with Workers KV or Durable Objects to track request counts per IP or user. This prevents abuse of your email-sending endpoints.

Can I schedule emails to be sent later from Cloudflare Workers?

Use Cron Triggers to run Workers on a schedule. Store scheduled email data in Workers KV or D1, and process pending sends when the cron fires. For more precise scheduling, use Cloudflare Queues with delayed delivery.

How do I test Cloudflare Workers email code locally?

Use wrangler dev to run your Worker locally. Mock the email SDK or use a sandbox API key to avoid sending real emails during development. Wrangler simulates the Workers runtime locally, including access to bindings like KV and secrets.

What happens if the email API call fails in a Worker?

Return an appropriate error response to the caller. For critical emails, use Cloudflare Queues with automatic retry to ensure delivery. For non-critical emails, log the failure and move on. Workers don't have built-in retry for fetch calls, so you need to implement retry logic yourself or use Queues.