Notix
Guides

Send email from Hono on any runtime.

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.

What you need

Three things before the first send.

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.

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.

Hono and the SDK.

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.

Install
npm install hono notix-js
# Node needs the adapter too:
npm install @hono/node-server
Steps

One app, then pick the runtime.

  1. Write the app once.

    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
    // 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;
    }
    
  2. Add the entrypoint for your runtime.

    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 with @hono/node-server
    // 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}`);
    });
    
  3. Receive delivery events.

    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
    // 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!));
    
  4. Send one and read it back.

    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.

Without the SDK

The same route as a single fetch.

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.

src/worker.ts, fetch only
// 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.

FAQ

Questions, answered.

Does notix-js run on Cloudflare Workers?
Yes, with one setting. The send call is plain fetch, but the SDK's webhook verifier imports node:crypto, so the Worker needs the nodejs_compat compatibility flag in wrangler.toml. Without it the import fails at startup. If you would rather not enable it, the fetch-only route on this page sends without the SDK and you can verify webhooks with the Web Crypto API instead.
Where does the API key come from on each runtime?
Node reads process.env, usually from a .env file loaded by your process manager or by node --env-file. Bun loads .env by itself and exposes Bun.env. Deno needs --allow-env and reads Deno.env.get. Workers have no process.env at all: the key is a secret binding set with wrangler secret put and read from c.env inside a handler. The one rule that holds everywhere is that the key is read on the server and never sent to a browser.
Why read the webhook body with c.req.text() and not c.req.json()?
The signature is an HMAC over the exact bytes Notix sent, prefixed by the timestamp header. If Hono parses the JSON and you serialise it again, key order and whitespace can change and the signature no longer matches. Read the text once, hand it to constructEvent, and only then work with the parsed event it returns.
Is Notix itself written in Hono?
Yes. The public API that these examples call is a Hono application, which is one reason the request and response shapes map so directly onto a Hono handler on your side. It does not change anything about how you call it.
What happens if the client retries the POST?
Nothing bad, if the request carries an idempotency key. A retry with the same key within 24 hours returns the original message id instead of sending again, and a request with the same key but a different body is refused with a 409. Derive the key from the thing you are sending, such as an order id, not from a random value generated per request.
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.

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.

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.