Skip to content
FLAVIO COPES
flaviocopes.com
2026

Transactional email from Workers with Resend

By Flavio Copes

Send transactional email from Cloudflare Workers with Resend over fetch, no Node SDK. Graceful no-op without an API key, plus KV deduped alerts.

~~~

Your Worker handles signups, password resets, or “something went wrong” alerts. You need email. Resend is a good fit.

The catch: most email SDKs assume Node. Cloudflare Workers don’t have Node. They have fetch.

Skip the SDK. POST to Resend’s REST API directly. It works everywhere.

I wired this up for StackPlan — admin alerts when someone creates a stack plan, plus deduped error emails so a bug loop can’t flood my inbox.

The send function

Resend’s endpoint is POST https://api.resend.com/emails. One fetch call:

async function sendEmail(env, message) {
  if (!env.RESEND_API_KEY) {
    console.log('[email] RESEND_API_KEY unset — would send:', {
      to: message.to,
      subject: message.subject,
      text: message.text,
    })
    return { sent: false, error: 'not-configured' }
  }

  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: env.EMAIL_FROM || 'My App <onboarding@resend.dev>',
      to: [message.to],
      subject: message.subject,
      html: message.html,
      text: message.text,
    }),
  })

  if (!response.ok) {
    const body = await response.text()
    console.error('[email] resend error:', response.status, body)
    return { sent: false, error: `resend-${response.status}` }
  }

  return { sent: true }
}

Three things matter here.

Graceful no-op. When RESEND_API_KEY is unset, log the message and return { sent: false }. Local dev keeps working. You can copy password-reset links from the console.

Never throw. Email failure must not break auth or checkout. Catch network errors, log them, return a result object.

Plain text + HTML. Always send both. Some clients strip HTML. Some users prefer text.

A practical use case: notify yourself

When something interesting happens in your app, email yourself. New signup. New order. New report.

async function notifyAdminNewReport(env, input) {
  if (!env.ADMIN_EMAIL) return

  const reportUrl = `https://stackplan.dev/report/${input.reportId}`

  await sendEmail(env, {
    to: env.ADMIN_EMAIL,
    subject: `New stack plan: ${input.appType}`,
    html: `<p>Someone planned a ${input.appType} stack.</p>
           <p><a href="${reportUrl}">Open report</a></p>`,
    text: `New stack plan: ${input.appType}\n\n${reportUrl}`,
  })
}

Fire-and-forget from your API route. Don’t await in the critical path if latency matters — but do catch errors inside sendEmail so nothing bubbles up.

ADMIN_EMAIL unset? Same pattern as the API key. Silent no-op. Self-hosted installs don’t need alerts configured.

Dedupe error alerts with KV

Unhandled 500s in a loop can send hundreds of identical emails. Fix that with a KV key and a TTL.

async function notifyAdminError(env, cache, input) {
  if (!env.ADMIN_EMAIL) return

  const dedupeKey = `err-alert:${input.path}:${input.message.slice(0, 80)}`

  if (cache) {
    if (await cache.get(dedupeKey)) return
    await cache.put(dedupeKey, '1', { expirationTtl: 3600 })
  }

  await sendEmail(env, {
    to: env.ADMIN_EMAIL,
    subject: `500: ${input.path}`,
    html: `<p>Path: ${input.path}</p><p>Error: ${input.message}</p>`,
    text: `Path: ${input.path}\nError: ${input.message}`,
  })
}

First occurrence sends the email and sets a KV flag for one hour. Same path + same error message within that hour? Muted.

No cache binding? Still send the first alert. Dedup is a nice-to-have, not a requirement.

HTML without a template engine

For simple alerts, inline HTML strings are fine. Escape user content before inserting it:

function esc(s) {
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}

Wrap it in a minimal layout with inline styles. Email clients ignore external CSS anyway.

Secrets setup

Production:

npx wrangler secret put RESEND_API_KEY
npx wrangler secret put ADMIN_EMAIL
npx wrangler secret put EMAIL_FROM

Local dev: add them to .dev.vars (never commit this file).

Resend’s test sender (onboarding@resend.dev) works for development. Verify your domain before sending from a custom address in production.

The pattern in one sentence

Fetch to Resend, no-op when unconfigured, never throw, dedupe noisy alerts with KV.

No Node. No SDK. Runs on Workers today.

~~~

Related posts about cloudflare: