DEV Community

Cover image for How to Send Email from Cloudflare Workers
sohom das
sohom das

Posted on

How to Send Email from Cloudflare Workers

There are two real ways to do this: Cloudflare's own native Email Service binding, or calling an external email API like Notify over fetch(). I'll walk through both, but I want to flag something about the native option up front that's easy to miss until you're actually setting it up: it currently requires the Workers Paid plan, not just a Cloudflare account. If you're on the free Workers tier and just want to send a password reset email, that's worth knowing before you spend time on it.

Option 1: Cloudflare's Native Email Service Binding

Cloudflare's Email Service (which covers both sending and receiving) lets a Worker send email through a binding, with no external API key. As of now it's still in beta, and there's a real gate on it: sending to arbitrary recipients requires the Workers Paid plan, and before you've fully onboarded a domain, the binding can only send to destination addresses you've explicitly verified.

Setup looks like this:

  1. In the Cloudflare dashboard, go to Compute > Email Service > Email Sending, click Onboard Domain, and pick the domain you want to send from. Cloudflare adds the DNS records it needs automatically — an SPF record, a DKIM record, a DMARC record, and MX records on a cf-bounce subdomain. This usually finishes in minutes, though Cloudflare says it can take up to 24 hours.

  2. Add the binding to your Wrangler config:

{
  "send_email": [{ "name": "EMAIL" }]
}
Enter fullscreen mode Exit fullscreen mode
  1. Send from your Worker:
export default {
  async fetch(request, env, ctx) {
    await env.EMAIL.send({
      from: "noreply@yourdomain.com",
      to: "user@example.com",
      subject: "Welcome!",
      html: "<h1>Thanks for signing up.</h1>",
    });

    return new Response("Email sent!", { status: 200 });
  },
};
Enter fullscreen mode Exit fullscreen mode

A couple of things worth knowing before you build on this: by default, wrangler dev simulates the binding locally — emails are logged to your console, not actually sent — unless you set remote: true on the binding to send real mail during local development. And you can restrict which senders and recipients the binding is allowed to use (allowedSenderAddresses, allowedDestinationAddresses), which is worth doing regardless of which sending method you pick.

Option 2: Calling an External Email API Over fetch()

This is the more portable pattern, and it's worth noting that it fits the Workers runtime particularly well for a specific reason: Workers run on a V8 isolate, not Node.js, so any library that assumes Node-specific built-ins can quietly break in ways that are annoying to debug. A plain HTTP API you call with fetch() has none of that risk, since there's no package to be incompatible in the first place — which is exactly the shape Notify is, since it has no SDK at all.

  1. Store your API key as a secret:
wrangler secret put NOTIFY_API_KEY
Enter fullscreen mode Exit fullscreen mode
  1. Call the API from your Worker:
export default {
  async fetch(request, env, ctx) {
    const response = await fetch("https://notify.cx/api/email/send", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "x-api-key": env.NOTIFY_API_KEY,
      },
      body: JSON.stringify({
        to: "user@example.com",
        from: "noreply@your-verified-domain.com",
        subject: "Welcome!",
        message: "<h1>Thanks for signing up.</h1>",
      }),
    });

    if (!response.ok) {
      const text = await response.text();
      return new Response(`Email send failed: ${text}`, { status: 500 });
    }

    return new Response("Email sent!", { status: 200 });
  },
};
Enter fullscreen mode Exit fullscreen mode

Environment variables and secrets work exactly the way they do for any other Worker — env.NOTIFY_API_KEY here is no different from any other secret you'd reference, which is part of why this integration doesn't feel like a special case once you've set up a Worker with any external API before.

That's the entire integration — no binding to configure in Wrangler, no domain onboarding through Cloudflare's dashboard specifically (you still verify a domain with Notify directly, via standard SPF/DKIM/DMARC DNS records), and it works identically whether this Worker is the only thing calling Notify or you've also got a Node backend doing the same thing elsewhere.

Reacting to Bounces from a Worker

If you want to know when an email fails without polling, register a webhook once — this isn't something the native Cloudflare binding gives you an equivalent of without building your own event handling:

curl -X POST https://notify.cx/api/webhooks \
  -H "Content-Type: application/json" \
  -H "x-api-key: $NOTIFY_API_KEY" \
  -d '{
    "webhookUrl": "https://yourworker.example.com/webhooks/email",
    "subscribedEvents": ["Bounce", "Delivery"],
    "domainId": "your-domain-id"
  }'
Enter fullscreen mode Exit fullscreen mode

If you want the full request/response shape before wiring this in, the docs cover it in a few minutes.

What About Receiving Email?

Worth being clear about scope here: Cloudflare's Email Service also handles inbound routing — receiving mail sent to your domain and processing it in a Worker's email handler. Notify doesn't do this at all; it's outbound sending only. If your Worker needs to both send and receive email, you'd likely end up using Cloudflare's Email Routing for the inbound piece regardless of which provider handles your outbound sends, since that's specifically a Cloudflare-domain-level feature rather than something any third-party email API replaces.

Testing Before You Ship

For the native binding, remember that wrangler dev won't actually send anything by default — it logs the message to a local file so you can inspect the structure, which is useful for catching formatting mistakes but won't tell you whether a real inbox would have received it. Setting remote: true on the binding sends real mail while your Worker still runs locally, which is the more realistic test if you're close to shipping.

For an external API, testing is simpler in one sense: it's the same HTTP call in production and in wrangler dev, since there's no separate "local simulation" mode to opt out of. Hitting Notify's sandbox from a local Worker works exactly the same as it would from any other client while your domain verification is still pending.

Which Should You Use?

Cloudflare Email Service (native) External API (Notify)
Requires Workers Paid plan Yes — a hard requirement No — works on any Workers plan
Extra pricing $0.35 per 1,000 emails, on top of the Paid plan minimum Free up to 1,000/mo, then $10/mo for 10,000
Product maturity Beta Established API
API key management None — binding-based One API key as a Worker secret
Portable to non-Workers runtimes No — Workers-specific Yes — same fetch() call works from Node, Deno, or anywhere with HTTP
Delivery logs / webhooks Not built in beyond basic sending Included
Local dev behavior Simulated by default; opt into remote: true for real sends Sandbox available, or just call the real API directly

If you're already committed to the Workers Paid plan and want zero external dependencies, the native binding is a reasonable choice, with the understanding that it's still in beta and its email-specific feature set (delivery tracking, bounce handling) is less developed than a dedicated transactional provider's. If you're on the free plan, want your email-sending code to work the same way outside Workers, or want delivery logs and webhooks without building them yourself, an external API is the more practical route — and among those, Notify's lack of an SDK means there's nothing that could be Workers-incompatible in the first place.

Frequently Asked Questions

How do I send email from Cloudflare Workers?

Either through Cloudflare's native Email Service binding (env.EMAIL.send(), currently in beta and requiring the Workers Paid plan), or by calling an external email API like Notify with fetch() — a single POST request to https://notify.cx/api/email/send with an API key stored as a Worker secret.

Does Cloudflare's native Email Service require a paid plan?

Yes — sending to arbitrary recipients requires the Workers Paid plan. Before full domain onboarding, the binding can only send to destination addresses you've explicitly verified in your account.

What is Notify?

Notify is a lightweight transactional email API for developers — one endpoint to send, domain verification, delivery logs, and webhooks, with no SDK required, which makes it work the same way in Cloudflare Workers as it does anywhere else with fetch().

Will an npm-based email SDK work inside a Cloudflare Worker?

It depends — Workers run on a V8 isolate, not Node.js, so packages relying on Node-specific APIs can fail in ways that are hard to debug. A plain HTTP API called via fetch(), like Notify, avoids this entirely since there's no package involved.

Does Notify's local sandbox work the same way inside a Worker?

Yes — Notify's sandbox is just another HTTPS endpoint, so it behaves the same whether you're calling it from wrangler dev, a deployed Worker, or a Node server.

Can I use Notify's webhooks to catch bounces from emails sent by a Worker?

Yes — register a webhook once against your verified domain, subscribed to events like Bounce and Delivery, and it works the same regardless of which runtime originally sent the email.

Does Notify handle receiving email sent to my domain?

No — Notify is outbound-only. If you need to receive and process incoming email in a Worker, that's Cloudflare's Email Routing feature specifically, independent of which provider handles your outbound sending.

Top comments (0)