An API key.
Created in the dashboard under API keys, shown once, read from NOTIX_API_KEY. A sending access key is enough for a service that only sends.
An Express app needs three pieces to send through the email API properly: a route that sends with an idempotency key, one error middleware that turns the API’s envelope into the status your own clients should see, and a webhook route that keeps the raw body so the signature can be checked. This guide is those three files plus the app that wires them.
Created in the dashboard under API keys, shown once, read from NOTIX_API_KEY. A sending access key is enough for a service that only sends.
The from address has to be on a domain whose records you have published. The quickstart walks through it.
npm install express notix-js dotenv
# TypeScript: npm install -D typescript tsx @types/express
Fail fast when the key is missing; a server that starts without it will only fail later, inside a request.
// src/notix.ts
import "dotenv/config";
import { Notix } from "notix-js";
if (!process.env.NOTIX_API_KEY) {
throw new Error("NOTIX_API_KEY is not set");
}
// Pointing at a different Notix base URL (a staging environment, say)?
// Pass it as the second argument.
export const notix = new Notix(process.env.NOTIX_API_KEY);
Validate the two fields you need, send with an idempotency key built from the order id, and hand any error to next(). Answer 202: the message is queued on Notix’s side and delivery arrives on the webhook.
// src/routes/send.ts
import { Router } from "express";
import { notix } from "../notix.js";
export const send = Router();
// express.json() has already parsed the body by the time this runs.
send.post("/api/send-receipt", async (req, res, next) => {
const { orderId, to } = req.body as { orderId?: string; to?: string };
if (!orderId || !to) {
return res.status(400).json({ error: "orderId and to are required" });
}
const { data, error } = await notix.emails.send(
{
from: "receipts@acme.com",
to,
templateId: "tpl_receipt_v4",
variables: { orderId },
},
// One key per business event, so a retried request can never send twice.
{ idempotencyKey: `order-${orderId}-receipt` },
);
if (error) return next(error); // handled by the middleware below
res.status(202).json({ emailId: data.emailId });
});
The SDK never throws; it returns the API’s { code, message } as error. One table maps each code to the status your caller should get, and a 5xx is logged with its code rather than forwarded.
// src/errors.ts
import type { ErrorRequestHandler } from "express";
// The API answers every failure as { code, message }; the SDK hands it back as
// `error` instead of throwing. This maps the code to the status your own
// client should see, and never forwards the raw message for a 5xx.
const STATUS: Record<string, number> = {
BAD_REQUEST: 400,
UNAUTHORIZED: 500, // our key is wrong, not the caller's problem
FORBIDDEN: 500,
NOT_FOUND: 404,
NOT_UNIQUE: 409,
RISK_REFUSED: 422,
INSUFFICIENT_BALANCE: 402,
RATE_LIMITED: 429,
INTERNAL_SERVER_ERROR: 502,
SERVICE_UNAVAILABLE: 503,
};
export const notixErrors: ErrorRequestHandler = (err, _req, res, next) => {
if (err && typeof err === "object" && "code" in err && "message" in err) {
const code = String(err.code);
const status = STATUS[code] ?? 500;
if (status >= 500) console.error("notix", code, err.message);
return res.status(status).json({
error: status >= 500 ? "Email could not be sent" : String(err.message),
});
}
next(err);
};
constructEvent verifies the HMAC-SHA256 signature over the timestamp and the exact bytes, so this route uses express.raw, not express.json. The SDK’s own documentation shows this form: req.body as a Buffer and req.headers as they are.
// src/routes/webhooks.ts
import express, { Router } from "express";
import { notix } from "../notix.js";
export const webhooks = Router();
const verifier = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
// The signature is computed over the raw bytes, so this route must not go
// through express.json(): express.raw keeps req.body as a Buffer.
webhooks.post(
"/webhooks/notix",
express.raw({ type: "application/json" }),
(req, res) => {
let event;
try {
event = verifier.constructEvent(req.body, { headers: req.headers });
} catch {
return res.status(400).send("invalid signature");
}
switch (event.type) {
case "email.delivered":
// event.data.id is the emailId the send returned
break;
case "email.bounced":
// event.data.bounce.type === "Permanent" means the address is now suppressed
break;
}
// Answer fast; do real work on a queue. Notix retries a non-2xx six times.
res.sendStatus(200);
},
);
The order matters: the webhook router is mounted before express.json() so its raw parser handles that path, and the error middleware goes last.
// src/app.ts
import express from "express";
import { send } from "./routes/send.js";
import { webhooks } from "./routes/webhooks.js";
import { notixErrors } from "./errors.js";
const app = express();
// Mount the webhook router BEFORE express.json(), so its raw parser wins.
app.use(webhooks);
app.use(express.json());
app.use(send);
app.use(notixErrors);
app.listen(3000, () => console.log("listening on :3000"));
The route above already carries the three things a production send needs: the idempotency key, the error envelope, and a signed webhook. The reasoning behind each, and the Nodemailer path through the SMTP relay, is on the Node.js guide; batch sends, scheduling and templates are on the production guide for transactional email. What Express adds is only the two ordering rules on this page: raw body before JSON, error middleware last.
Notix retries a non-2xx answer six times with backoff and disables an endpoint after 30 consecutive failures. Acknowledge with a 200 and do the database work on a queue; the signature verification page has the headers, the tolerance and replay protection.
Store data.emailId on the order row. When email.bounced arrives with bounce.type of Permanent, the address is already on the suppression list; mark it invalid in your app and ask for a new one. The bounce handling page shows the payload.
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.
GuidesA provider wrapping notix-js, a controller that sends, a raw-body webhook controller, and configuration through ConfigService.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
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, start the server. The free plan does not ask for a card.