An API key.
Created in the dashboard under API keys, shown once. A sending access key can send but cannot read contacts or delete domains, which is the right shape for a key that lives on a server or in a Worker secret.
One Hono app, four entrypoints. The send route is the same on Node, Bun, Deno and Cloudflare Workers; only where the API key comes from changes. You will also add a webhook route that verifies Notix's signature from the raw body, and an idempotency key so a retried request never sends twice. Notix's own public API is a Hono application, so the shapes on both sides of the call match. It all runs on the email API.
Created in the dashboard under API keys, shown once. A sending access key can send but cannot read contacts or delete domains, which is the right shape for a key that lives on a server or in a Worker secret.
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.
hono runs on every runtime here; Node also needs @hono/node-server. notix-js is a typed client over the JSON API, published on npm.
npm install hono notix-js
# Node needs the adapter too:
npm install @hono/node-server
Keep the routes in a factory that takes the API key, so the same file serves every entrypoint. The SDK answers { data, error }: a failed send is a value you check, not an exception you forget to catch. The idempotency key is set here, on the send itself.
// src/app.ts
import { Hono } from "hono";
import { Notix } from "notix-js";
// One app for every runtime. The key comes from the environment the
// runtime provides; see the entrypoints below for where that is.
export function createApp(apiKey: string) {
const notix = new Notix(apiKey);
const app = new Hono();
app.post("/send", async (c) => {
const { to, subject, html, orderId } = await c.req.json();
const { data, error } = await notix.emails.send(
{ from: "receipts@acme.com", to, subject, html },
// The same key on a retried request returns the first message
// instead of sending a second one.
{ idempotencyKey: `order-${orderId}-receipt` },
);
if (error) {
return c.json({ error }, 502);
}
return c.json({ id: data?.emailId });
});
return app;
}
Node serves through the adapter, Bun and Deno serve the app's fetch directly, and Workers export the app as the default module and read the key from a binding on c.env. Nothing in the routes changes.
// src/server.ts
import { serve } from "@hono/node-server";
import { createApp } from "./app";
const app = createApp(process.env.NOTIX_API_KEY!);
serve({ fetch: app.fetch, port: 3000 }, (info) => {
console.log(`listening on http://localhost:${info.port}`);
});
// src/index.ts
import { createApp } from "./app";
// Bun reads .env on its own; Bun.env and process.env both work.
const app = createApp(Bun.env.NOTIX_API_KEY!);
export default {
port: 3000,
fetch: app.fetch,
};
// main.ts
import { createApp } from "./src/app.ts";
// --allow-env for the key, --allow-net for the API call.
const app = createApp(Deno.env.get("NOTIX_API_KEY")!);
Deno.serve(app.fetch);
// src/worker.ts
import { Hono } from "hono";
import { Notix } from "notix-js";
type Bindings = { NOTIX_API_KEY: string };
const app = new Hono<{ Bindings: Bindings }>();
app.post("/send", async (c) => {
// On Workers there is no process.env: secrets arrive as bindings.
// Set it with: wrangler secret put NOTIX_API_KEY
const notix = new Notix(c.env.NOTIX_API_KEY);
const { to, subject, html, orderId } = await c.req.json();
const { data, error } = await notix.emails.send(
{ from: "receipts@acme.com", to, subject, html },
{ idempotencyKey: `order-${orderId}-receipt` },
);
return error ? c.json({ error }, 502) : c.json({ id: data?.emailId });
});
export default app;
// wrangler.toml needs the Node compatibility flag, because the SDK's
// webhook verifier imports node:crypto:
// compatibility_flags = ["nodejs_compat"]
Add a route for email.delivered, email.bounced and email.complained. Read the body as text, hand it and the request headers to constructEvent, and only work with the event it returns. A bad signature throws, and the route answers 400.
// src/webhooks.ts
import { Hono } from "hono";
import { Notix } from "notix-js";
export function webhookRoutes(apiKey: string, webhookSecret: string) {
const webhooks = new Notix(apiKey).webhooks(webhookSecret);
const app = new Hono();
app.post("/webhooks/notix", async (c) => {
// The signature is computed over the raw body. Read it as text;
// parsing it first and re-serialising would change the bytes.
const rawBody = await c.req.text();
let event;
try {
event = webhooks.constructEvent(rawBody, { headers: c.req.raw.headers });
} catch (error) {
return c.text((error as Error).message, 400);
}
switch (event.type) {
case "email.delivered":
case "email.bounced":
case "email.complained":
// Record the outcome against your own order or user id.
break;
}
return c.text("ok");
});
return app;
}
// Mount it next to the send route:
// app.route("/", webhookRoutes(apiKey, process.env.NOTIX_WEBHOOK_SECRET!));
POST to /send with curl, then open the message in the dashboard: the log shows its status and every event as delivery progresses, and your webhook route logs the same events as they arrive.
On an edge runtime where every dependency counts, the send is one request. The idempotency key becomes the Idempotency-Key header and the response body is the same envelope the SDK unwraps.
// The same route with no SDK, for a runtime where you
// would rather not add a dependency.
app.post("/send", async (c) => {
const { to, subject, html, orderId } = await c.req.json();
const response = await fetch("https://app.usenotix.dev/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${c.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.json();
return c.json(body, response.ok ? 200 : 502);
});
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.
Coming from Express or a plain Node server? Start with the Node.js guide for the SDK, fetch and Nodemailer side by side. Deploying to Bun or Deno without Hono? The Bun and Deno guides cover the runtime on its own. The free plan does not ask for a card.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesThe SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
GuidesThe same notix-js call on the Bun runtime, with Bun.serve, bun test and the env-file conventions that differ from Node.
GuidesSend with fetch or the npm package under Deno's permission model, from a script and from Deno Deploy.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
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.