Notix
Guides

Send email from Express: a route, a middleware, a webhook.

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.

What you need

A key, a domain, three packages.

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.

A verified domain.

The from address has to be on a domain whose records you have published. The quickstart walks through it.

Express, notix-js, dotenv.

terminal
npm install express notix-js dotenv
# TypeScript: npm install -D typescript tsx @types/express
Steps

From an empty project to a queued message.

  1. One client, created at startup.

    Fail fast when the key is missing; a server that starts without it will only fail later, inside a request.

    src/notix.ts
    // 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);
    
  2. The route that sends.

    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
    // 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 });
    });
    
  3. One middleware for every Notix error.

    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
    // 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);
    };
    
  4. A webhook route with the raw body.

    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
    // 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);
      },
    );
    
  5. Wire it, webhooks first.

    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
    // 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"));
    
In production

What is already covered, and what is not.

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.

Answer the webhook fast.

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.

Keep the id with the order.

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.

FAQ

Questions, answered.

How do I send email from an Express app?
Install notix-js, create one client from NOTIX_API_KEY at startup, and call notix.emails.send inside the route handler with from, to and either subject plus html or text, or a templateId with variables. Pass { idempotencyKey } as the second argument, keyed on the business event, so a retried request cannot send twice. The SDK returns { data, error } instead of throwing; hand error to next() and let one error middleware turn the code into a status.
Why does the webhook route need express.raw instead of express.json?
The signature Notix sends in X-Notix-Signature is an HMAC-SHA256 over the timestamp and the exact bytes of the body. Once express.json has parsed and re-serialised the body, the bytes are different and the check fails. express.raw({ type: "application/json" }) keeps req.body as a Buffer, which is what constructEvent expects. Mount the webhook router before app.use(express.json()) so the raw parser is the one that runs for that path.
How do I map Notix errors to my own HTTP responses?
Every API error is { code, message } with a fixed status: BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, NOT_UNIQUE 409, RISK_REFUSED 422, INSUFFICIENT_BALANCE 402, RATE_LIMITED 429, INTERNAL_SERVER_ERROR 500, SERVICE_UNAVAILABLE 503. In your own API, a 401 or 403 from Notix is a misconfigured key on your side, so answer your caller with a 500 and log it; pass 400, 409, 422 and 429 through. The middleware on this page is that table.
Should I send inside the request or on a queue?
A single send takes well under a second and the API answers as soon as the message is queued on its side, so sending inside the request is fine for receipts and resets. Put a queue in front when one request fans out to many emails, or when you want your own retry policy; either way keep the idempotency key stable across retries. Batch sends of up to 100 messages are one call.
Where does the API key live?
In the environment: dotenv in development, the platform's secret store in production. Never in source or in a client-side bundle. Give the process that only sends a sending-access key, which cannot read contacts or delete domains, and keep a full-access key for the jobs that manage the account.
Can Express serve the SMTP path instead?
If the app already uses Nodemailer, point its transport at smtp.usenotix.dev on port 465 with username notix and the API key as the password, and nothing else changes: the relay makes the same tracked, suppressed send. You give up the idempotency key and the batch endpoint, which only the API exposes. The Node.js guide shows the Nodemailer configuration.

Send the first one now.

Verify a domain, copy an API key, start the server. The free plan does not ask for a card.