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

Installbash
pnpm add @rkiza/sndr
# or with the wrangler CLI
wrangler init
wrangler dev
Environmentenv
# 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.

  1. 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 });
      },
    };
  2. 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');
      },
    };
  3. 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 });
      },
    };
  4. 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?
The package imports node:crypto for idempotency keys and webhook signatures. Without nodejs_compat the SDK import fails. Enable the compatibility flag for every Worker importing the SDK.
Can I use the HTTP API without nodejs_compat?
Yes: use fetch directly for the HTTP API instead of importing the SDK. The SDK itself requires nodejs_compat, even if you do not call its webhook verifier.
How fast is cold start?
Sub-10ms typically. The SDK is small (~6 KB gzipped) and adds minimal cold-start overhead. Per-request Sndr instantiation is fine; the constructor is cheap.
Should I share the Sndr instance globally?
On Workers, each request gets its own env, so instantiate per-request. The constructor is so cheap that the global-vs-per-request difference is unmeasurable.

Send your first Cloudflare Workers email.

3,000 emails a month on the Free plan. No credit card needed.