Send Email from Cloudflare Workers with Notify
Call Notify from a Cloudflare Worker: secrets, Bearer auth, sandbox vs production, and patterns for webhooks and thin BFFs.
3 min readDevon Marsh
Cloudflare Workers have global fetch and secret bindings — a natural place for thin transactional email: signed webhooks, edge BFFs, cron digests that are still user-triggered product messages (not newsletters).
This guide calls Notify from a Worker: POST https://notify.cx/api/email/send, header x-api-key, body to / from / subject / message. Store NOTIFY_API_KEY as a Worker secret.
More detail in Notify’s docs: Workers guide and edge send recipe.
Prerequisites
- Cloudflare account + Wrangler
- Notify API key
- Verified domain for production
from(domain verification)
Plans: Free 1,000 emails/mo, Pro $10 / 10,000, Scale $50 / 100,000 — pricing.
npm create cloudflare@latest notify-email-worker -- --type hello-world
cd notify-email-worker
npx wrangler secret put NOTIFY_API_KEY
# paste your key when prompted
npx wrangler secret put INTERNAL_API_TOKEN
# shared secret your app uses to call the Worker
Until DNS is ready, point the Worker at https://notify.cx/api/email/send/test (sandbox vs production).
wrangler.toml
name = "notify-email-worker"
main = "src/index.ts"
compatibility_date = "2024-11-01"
# Secrets (NOTIFY_API_KEY, INTERNAL_API_TOKEN) are set via `wrangler secret put`
# — do not put them in this file.
Complete Worker
// src/index.ts
export interface Env {
NOTIFY_API_KEY: string;
INTERNAL_API_TOKEN: string;
}
type SendBody = {
to?: string;
subject?: string;
message?: string;
from?: string;
/** When true, use Notify's non-delivering test endpoint */
sandbox?: boolean;
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
const auth = request.headers.get('authorization') ?? '';
const expected = `Bearer ${env.INTERNAL_API_TOKEN}`;
if (!env.INTERNAL_API_TOKEN || auth !== expected) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
let body: SendBody;
try {
body = await request.json<SendBody>();
} catch {
return Response.json({ error: 'Invalid JSON' }, { status: 400 });
}
if (!body.to || !body.subject || !body.message) {
return Response.json(
{ error: 'to, subject, and message are required' },
{ status: 400 }
);
}
const endpoint = body.sandbox
? 'https://notify.cx/api/email/send/test'
: 'https://notify.cx/api/email/send';
const notifyRes = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': env.NOTIFY_API_KEY
},
body: JSON.stringify({
from: body.from ?? 'noreply@your-verified-domain.com',
to: body.to,
subject: body.subject,
message: body.message
})
});
return new Response(await notifyRes.text(), {
status: notifyRes.status,
headers: { 'Content-Type': 'application/json' }
});
}
};
Deploy and smoke-test:
npx wrangler deploy
curl -X POST https://notify-email-worker.<your-subdomain>.workers.dev \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_INTERNAL_TOKEN" \
-d '{
"to": "you@example.com",
"subject": "Welcome",
"message": "<h1>Welcome</h1><p>Thanks for joining.</p>",
"sandbox": true
}'
Patterns that fit Workers
- Signed Stripe / GitHub webhooks that send receipts or alerts (verify signatures in the Worker, then call Notify)
- Thin BFF in front of your app’s email helper so browser code never sees
NOTIFY_API_KEY - Cron Triggers for opted-in transactional summaries (billing reminders, digest of your product events) — not marketing newsletters
For Stripe receipt HTML + idempotency, see Stripe receipt recipe and the blog post Billing emails with Stripe + Notify.
Welcome helper used by your origin
Prefer hard-coding templates on the Worker or origin rather than accepting free-form HTML from the public internet. Example origin call after signup:
await fetch('https://notify-email-worker.example.workers.dev', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.INTERNAL_API_TOKEN}`
},
body: JSON.stringify({
to: user.email,
subject: 'Welcome',
message: `<h1>Welcome</h1><p><a href="${appUrl}/dashboard">Dashboard</a></p>`
})
});
Keep INTERNAL_API_TOKEN and NOTIFY_API_KEY only in Worker secrets and your origin’s server env — never in a Client Component or public repo. After the first production send, confirm the message in Notify’s dashboard logs before wiring more flows.
Hardening options
The Bearer token gate is a minimum. Production setups often add:
- Cloudflare Access in front of the Worker for admin-only tools
- Stripe/GitHub signature verification when the Worker is the webhook endpoint
- Per-template routes (
/welcome,/reset) that ignore client HTML and only accept IDs
Example: accept { userId } only, look up email server-side (Durable Object / KV / fetch to your origin), then send a fixed template. That removes the open-relay class of bugs entirely.
Rate limiting note
Workers can be hammered. Pair auth with a simple limit — Cloudflare rate limiting rules, or an in-memory/KV counter keyed by IP. A practical starting policy for forgotten-password style endpoints: five requests per fifteen minutes per IP. Document the policy even if you enforce it at the CDN layer rather than in Worker code.
Transactional volume on Notify’s Free plan is 1,000 emails/mo; Pro is $10 / 10,000 with webhooks — enough headroom for most early SaaS billing and auth mail without a marketing ESP.
Common pitfalls
- Open Worker URL without auth = spam relay (the Bearer gate above is the minimum)
- Putting the API key in
wrangler.tomlplaintext instead of secrets - Assuming edge = no need for domain verification
- Mixing newsletter blasts onto the same verified transactional domain
- Ignoring Notify error bodies when status ≥ 400
- Letting clients supply arbitrary HTML for high-trust messages (resets, receipts)
Next steps
- Verify your domain and stop using
sandbox: true - Reuse the Worker for password resets and receipts with hashed tokens in your DB (password resets)
- Add Notify webhooks when bounce suppression matters
- Skim the quick start for the core API
- If migrating from another ESP, use the migration checklist
Resources
More in web-development
Cubed
Write about the technologies shaping the future.
For developers, founders, and curious minds exploring AI, crypto, Web3, and emerging tech—signal over noise.
One free account across In Plain English, Stackademic, Venture, and Cubed.
How it works- AI, crypto & Web3
- Software & emerging technologies
- Analysis & practical resources
- Thoughtful voices, not hype
Sign in
Google or GitHub
Complete profile
Takes a few minutes
Get approved & publish
Start sharing
Why write for Cubed?
The future deserves thoughtful voices, not just louder headlines.

Comments
Loading comments…