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 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.
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.
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.
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
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
# 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.
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
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>;
// src/worker.ts
import { Notix } from "notix-js";
export interface Env {
NOTIX_API_KEY: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Construct inside the handler: on Workers the secret arrives on env,
// not on a process.env that exists at module load.
const notix = new Notix(env.NOTIX_API_KEY);
const { to, subject, html, orderId } = await request.json();
const { data, error } = await notix.emails.send(
{ from: "receipts@acme.com", to, subject, html },
{ idempotencyKey: `order-${orderId}-receipt` },
);
return error
? Response.json({ error }, { status: 502 })
: Response.json({ id: data?.emailId });
},
} satisfies ExportedHandler<Env>;
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: 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>;
// 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 });
},
};
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: 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>;
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.
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.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesA Hono route that sends through Notix on Node, Bun, Deno or Cloudflare Workers, the runtime Notix's own API is written in.
GuidesAn Astro endpoint or server action that sends with notix-js, on the Node or Cloudflare adapter, with env handling and an idempotency key.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
Verify a domain, copy an API key, make one call. The free plan does not ask for a card.