Notix
Guides

Send email from a Cloudflare Worker.

A Worker has no process, no filesystem and no Node by default, which suits a send that is one HTTP call. This guide does it with plain fetch and with the SDK, keeps the key in a Worker secret, adds a queue so a retry can never send twice, and receives delivery events with the signature verified. It runs on the email API.

What you need

Three things before the first send.

An API key, as a secret.

Created in the dashboard under API keys, shown once. On Workers it becomes a secret binding: set with wrangler secret put, read from env inside a handler, never written into wrangler.toml.

A verified domain.

Add your domain and publish the DKIM, SPF and DMARC records Notix generates. The from address in every example must be on a verified domain; the quickstart walks through it.

Wrangler, and optionally the SDK.

The fetch-only Worker needs nothing installed. notix-js is a typed client published on npm; on Workers it needs the Node compatibility flag because its webhook verifier imports node:crypto.

wrangler.toml
# wrangler.toml
name = "acme-mail"
main = "src/worker.ts"
compatibility_date = "2026-09-01"

# Only needed when the Worker imports notix-js: its webhook verifier
# imports node:crypto at module load. A fetch-only Worker can skip it.
compatibility_flags = ["nodejs_compat"]

# Optional: a queue for retries with the same idempotency key.
[[queues.producers]]
binding = "MAIL_QUEUE"
queue = "acme-mail"

[[queues.consumers]]
queue = "acme-mail"
max_retries = 5
Secrets
# The key is a secret binding, never a plain var in wrangler.toml.
wrangler secret put NOTIX_API_KEY
wrangler secret put NOTIX_WEBHOOK_SECRET
# Each prompts for the value; it is stored encrypted and read as env.NAME.
Steps

One request, then make the retry safe.

  1. Send from the fetch handler.

    Read the JSON body, POST to the API with the key from env, and pass the response through. The idempotency key is derived from the order so a repeated request cannot produce a second email. The SDK tab does the same with { data, error } unwrapped for you.

    src/worker.ts, no dependencies
    // src/worker.ts
    export interface Env {
      NOTIX_API_KEY: string;
    }
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        if (request.method !== "POST") {
          return new Response("Method not allowed", { status: 405 });
        }
        const { to, subject, html, orderId } = await request.json();
    
        // One HTTP call. The idempotency key is a header; the same key on a
        // retried request returns the first message instead of a second one.
        const response = await fetch("https://app.usenotix.dev/api/v1/emails", {
          method: "POST",
          headers: {
            Authorization: `Bearer ${env.NOTIX_API_KEY}`,
            "Content-Type": "application/json",
            "Idempotency-Key": `order-${orderId}-receipt`,
          },
          body: JSON.stringify({ from: "receipts@acme.com", to, subject, html }),
        });
    
        const body = await response.text();
        return new Response(body, {
          status: response.ok ? 200 : 502,
          headers: { "Content-Type": "application/json" },
        });
      },
    } satisfies ExportedHandler<Env>;
    
  2. Move the send to a queue for retries.

    Enqueue the job and answer 202. The consumer sends with the same key on every attempt, acknowledges on success or on a 409 (already sent under that key), and asks for redelivery on a 429 or a server error. Queues handle the backoff and the retry count.

    src/worker.ts with a queue consumer
    // src/worker.ts: enqueue in the request, send from the consumer.
    export interface Env {
      NOTIX_API_KEY: string;
      MAIL_QUEUE: Queue<MailJob>;
    }
    
    type MailJob = { to: string; subject: string; html: string; orderId: string };
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const job = (await request.json()) as MailJob;
        // Answer the client at once; the consumer below does the send.
        await env.MAIL_QUEUE.send(job);
        return new Response("queued", { status: 202 });
      },
    
      async queue(batch: MessageBatch<MailJob>, env: Env): Promise<void> {
        for (const message of batch.messages) {
          const job = message.body;
          const response = await fetch("https://app.usenotix.dev/api/v1/emails", {
            method: "POST",
            headers: {
              Authorization: `Bearer ${env.NOTIX_API_KEY}`,
              "Content-Type": "application/json",
              // The same key on every attempt: a retry after a timeout can
              // never produce a second email.
              "Idempotency-Key": `order-${job.orderId}-receipt`,
            },
            body: JSON.stringify({
              from: "receipts@acme.com",
              to: job.to,
              subject: job.subject,
              html: job.html,
            }),
          });
    
          if (response.ok || response.status === 409) {
            message.ack(); // sent, or already sent under this key
          } else if (response.status === 429 || response.status >= 500) {
            message.retry(); // Queues redelivers, up to max_retries
          } else {
            message.ack(); // a 4xx is a bad job; do not loop on it
          }
        }
      },
    } satisfies ExportedHandler<Env>;
    
    Or ctx.waitUntil, without redelivery
    // Without a queue: send after responding, still with the same key.
    export default {
      async fetch(request: Request, env: Env, ctx: ExecutionContext) {
        const job = await request.json();
        ctx.waitUntil(sendReceipt(env, job)); // keeps the Worker alive to finish
        return new Response("accepted", { status: 202 });
      },
    };
    
  3. Receive delivery events.

    A route for email.delivered, email.bounced and email.complained. Read the body as text, hand it and the request headers to constructEvent, and act on the event it returns. A bad signature throws, and the route answers 400.

    src/webhook.ts
    // src/webhook.ts: a second route, or a second Worker.
    import { Notix } from "notix-js";
    
    export interface Env {
      NOTIX_API_KEY: string;
      NOTIX_WEBHOOK_SECRET: string;
    }
    
    export default {
      async fetch(request: Request, env: Env): Promise<Response> {
        const webhooks = new Notix(env.NOTIX_API_KEY).webhooks(env.NOTIX_WEBHOOK_SECRET);
    
        // The signature covers the raw bytes: read text, never request.json().
        const rawBody = await request.text();
    
        let event;
        try {
          event = webhooks.constructEvent(rawBody, { headers: request.headers });
        } catch (error) {
          return new Response((error as Error).message, { status: 400 });
        }
    
        if (event.type === "email.bounced" && event.data.bounce.type === "Permanent") {
          // Mark the address invalid in KV, D1 or your own API.
        }
        return new Response("ok");
      },
    } satisfies ExportedHandler<Env>;
    
  4. Deploy and read it back.

    wrangler deploy, POST to the Worker with curl, then open the message in the dashboard: the log shows its status and every event as delivery progresses, and your webhook Worker logs the same events as they arrive.

The production checklist, with batch sends, scheduling, templates by id and the retry strategy, is in the production Node.js guide, and the webhook headers and tolerance window are on the signature verification page.

FAQ

Questions, answered.

How do I send email from a Cloudflare Worker?
With one fetch: POST https://app.usenotix.dev/api/v1/emails with an Authorization header carrying your API key, a JSON body with from, to, subject and html or a template id, and an Idempotency-Key header. The key comes from a Worker secret set with wrangler secret put and read from env in the handler. No dependency is needed; notix-js works too if you enable the Node compatibility flag.
Why does notix-js need the nodejs_compat flag?
Sending is plain fetch and runs anywhere. The SDK's webhook verifier imports createHmac and timingSafeEqual from node:crypto at module load, and a Worker without the nodejs_compat flag in wrangler.toml fails on that import. Add the flag, or use the fetch-only route and verify webhooks with the Web Crypto API yourself.
Where do I put the API key?
In a secret: wrangler secret put NOTIX_API_KEY prompts for the value and stores it encrypted, and the Worker reads it as env.NOTIX_API_KEY inside a handler. There is no process.env on Workers at module load, so construct the client inside fetch rather than at the top of the file. Never put the key in the vars section of wrangler.toml, which is committed and visible in the dashboard.
How do I retry a failed send without sending twice?
Keep the idempotency key fixed across attempts. A Queue consumer that calls message.retry() on a 429 or a 5xx will be handed the same job again, and because the Idempotency-Key header is derived from the order id, a retry after a timeout that actually succeeded the first time returns the original message id with a 409 instead of a duplicate. Treat 409 as success and acknowledge the message.
Should I send inside the request or from a queue?
Inside the request when the caller needs the message id back and one send is all there is. From a queue when the request should return at once, when you fan out to many recipients, or when you want retries with backoff handled for you. ctx.waitUntil is the middle ground: answer the client, finish the send in the background, but with no redelivery if the Worker fails.
Can I point the client at a different Notix base URL?
Yes. The Notix constructor takes a base URL as its second argument for a different Notix base URL (a staging environment, say). Leave it out to use the hosted API.

Using Hono on Workers? The Hono guide has the same routes as a Hono app. Deploying an Astro site to Cloudflare? The Astro guide covers the adapter. What the key does on a retry is on the idempotency page. The free plan does not ask for a card.

Send your first email in five minutes.

Verify a domain, copy an API key, make one call. The free plan does not ask for a card.