Integration
Send email from Cloudflare Workers with SNDR.
Send transactional email from Cloudflare Workers — sub-10ms cold start, typed env bindings, KV-backed OTP state, queue-driven sends. The SDK requires nodejs_compat.
Workers is the cleanest Edge environment to send transactional email from. The SDK uses fetch and imports node:crypto, so enable nodejs_compat in wrangler.toml for sending and webhook verification. The recipes below cover plain Workers (Service Worker handler shape), typed env bindings, KV-backed OTP state, and dispatch via Cloudflare Queues.
Setup
Install + configure
pnpm add @rkiza/sndr
# or with the wrangler CLI
wrangler init
wrangler dev# wrangler.toml
name = "sndr-mailer"
main = "src/index.ts"
compatibility_date = "2026-01-01"
compatibility_flags = ["nodejs_compat"] # required for the SDK import
[vars]
# Non-secret only. For secrets:
# wrangler secret put SNDR_API_KEY
# wrangler secret put SNDR_WEBHOOK_SECRET
# [[kv_namespaces]] # optional, for OTP state
# binding = "OTP"
# id = "..."
# [[queues.producers]] # optional, for queue-driven sends
# binding = "EMAIL_QUEUE"
# queue = "transactional-email"Recipes
The patterns you actually need.
Module-Worker fetch handler
The module-Worker handler shape — export default { fetch }. Type your env bindings via the Env generic so process.env-style access stays type-safe.
// src/index.ts import { Sndr, SndrError } from '@rkiza/sndr'; export type Env = { SNDR_API_KEY: string; SNDR_WEBHOOK_SECRET: string; }; export default { async fetch(req: Request, env: Env): Promise<Response> { const url = new URL(req.url); if (req.method === 'POST' && url.pathname === '/api/welcome') { const sndr = new Sndr(env.SNDR_API_KEY); const { email, name } = (await req.json()) as { email: string; name: string }; try { const { id } = await sndr.emails.send({ from: 'hello@yourdomain.com', to: email, subject: `Welcome, ${name}`, html: `<p>Thanks for joining us, ${name}.</p>`, }); return Response.json({ id }); } catch (err) { if (err instanceof SndrError) { return Response.json( { error: { code: err.code, message: err.message } }, { status: (err.status >= 400 && err.status <= 599 ? err.status : 502) }, ); } throw err; } } return new Response('not found', { status: 404 }); }, };Webhook receiver (requires nodejs_compat)
The verifier uses node:crypto for HMAC. Add nodejs_compat to compatibility_flags in wrangler.toml. Workers gives you req.arrayBuffer() for raw bytes — same as Bun, same as Hono on Workers.
import { verifyWebhookSignature } from '@rkiza/sndr'; import type { WebhookEventPayload } from '@rkiza/sndr'; import { Buffer } from 'node:buffer'; export type Env = { SNDR_WEBHOOK_SECRET: string }; export default { async fetch(req: Request, env: Env): Promise<Response> { if (req.method !== 'POST' || new URL(req.url).pathname !== '/webhooks/sndr') { return new Response('not found', { status: 404 }); } const raw = Buffer.from(await req.arrayBuffer()); const ok = verifyWebhookSignature( raw, req.headers.get('x-sndr-signature') ?? '', env.SNDR_WEBHOOK_SECRET, ); if (!ok) return new Response('bad signature', { status: 400 }); const event = JSON.parse(raw.toString('utf8')) as WebhookEventPayload; switch (event.type) { case 'email.delivered': // event.data.email_id, event.data.to: string[] break; case 'email.bounced': // event.data.email_id, event.data.error break; case 'email.unsubscribed': // event.data.addresses: string[] break; } return new Response('ok'); }, };OTP flow with KV storage
Workers KV is the right backend for short-lived state like OTP codes. expirationTtl handles cleanup; the code itself is hashed with SubtleCrypto SHA-256 (no node:crypto required for SHA hashing on Workers when you use SubtleCrypto).
import { Sndr } from '@rkiza/sndr'; export type Env = { SNDR_API_KEY: string; OTP: KVNamespace; }; async function sha256Hex(input: string): Promise<string> { const bytes = new TextEncoder().encode(input); const digest = await crypto.subtle.digest('SHA-256', bytes); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } export default { async fetch(req: Request, env: Env): Promise<Response> { if (req.method !== 'POST') return new Response('not found', { status: 404 }); const url = new URL(req.url); if (url.pathname === '/api/otp/send') { const { email } = (await req.json()) as { email: string }; const code = String(Math.floor(100000 + Math.random() * 900000)); const hashed = await sha256Hex(code); await env.OTP.put(`otp:${email}`, hashed, { expirationTtl: 600 }); const sndr = new Sndr(env.SNDR_API_KEY); await sndr.emails.send({ from: 'auth@yourdomain.com', to: email, subject: `${code} is your sign-in code`, text: `Your code is ${code}. It expires in 10 minutes.`, }); return Response.json({ ok: true }); } if (url.pathname === '/api/otp/verify') { const { email, code } = (await req.json()) as { email: string; code: string }; const stored = await env.OTP.get(`otp:${email}`); if (!stored) return new Response('invalid_or_expired', { status: 400 }); const submitted = await sha256Hex(code); if (submitted !== stored) return new Response('invalid_code', { status: 400 }); await env.OTP.delete(`otp:${email}`); // single-use return Response.json({ ok: true }); } return new Response('not found', { status: 404 }); }, };Queue-driven sends with Cloudflare Queues
Producer pushes the send onto the queue; the consumer Worker processes batches. SNDR's SDK auto-retries within a single attempt; the queue's own retry handles longer outages.
// Producer (in your normal Worker) export default { async fetch(req: Request, env: { EMAIL_QUEUE: Queue }): Promise<Response> { if (req.method !== 'POST') return new Response('not found', { status: 404 }); const body = await req.json(); await env.EMAIL_QUEUE.send(body); return Response.json({ queued: true }); }, }; // Consumer Worker — wrangler.toml [[queues.consumers]] import { Sndr } from '@rkiza/sndr'; export default { async queue(batch: MessageBatch, env: { SNDR_API_KEY: string }): Promise<void> { const sndr = new Sndr(env.SNDR_API_KEY); for (const msg of batch.messages) { try { const job = msg.body as { payload: Parameters<typeof sndr.emails.send>[0]; idempotencyKey: string; }; await sndr.emails.send(job.payload, { idempotencyKey: job.idempotencyKey }); msg.ack(); } catch (err) { msg.retry({ delaySeconds: 60 }); // backed off by the queue } } }, };
Cloudflare Workers FAQ
Why does the SDK need nodejs_compat?
Can I use the HTTP API without nodejs_compat?
How fast is cold start?
Should I share the Sndr instance globally?
Send your first Cloudflare Workers email.
3,000 emails a month on the Free plan. No credit card needed.