Notix
Guides

Send email from Bun with one API call.

The same notix.emails.send call as the Node.js guide, with the parts Bun does differently: .env is loaded for you, Bun.serve is the server, and bun test mocks the SDK in four lines. By the end you will have a script that sends, a route that sends on a POST, and a test that proves it without sending anything.

What you need

Three things, none of them a card.

An API key.

Created in the dashboard under API keys, shown once. A sending access key is enough for this guide and cannot read contacts or delete domains if it leaks.

A verified domain.

Add your domain, publish the SPF, DKIM and DMARC records Notix gives you, and the from address on it becomes valid. The quickstart walks through it.

Bun, and the SDK.

fetch is built in, and the SDK is one install:

terminal
bun add notix-js

The free plan covers 5,000 emails a month and 200 a day, and the free plan does not ask for a card.

Steps

From an empty directory to a queued message.

  1. Put the key in .env.

    Bun reads .env at startup without a package, then .env.development, .env.production or .env.test depending on NODE_ENV, then .env.local on top (skipped when NODE_ENV is test). Later files win. Keep .env.local out of git.

    .env
    # .env - Bun reads this file on its own; no dotenv package.
    NOTIX_API_KEY=notix_your_key_here
    
  2. Initialise the client.

    Bun.env is an alias for process.env, so use whichever reads better; the SDK also reads process.env.NOTIX_API_KEY on its own when you pass no key. The optional second argument is a different Notix base URL (a staging environment, say).

    notix.ts
    import { Notix } from "notix-js";
    
    // Bun.env is an alias for process.env; either works. The key comes from .env.
    const notix = new Notix(Bun.env.NOTIX_API_KEY);
    
    // Pointing at a different Notix base URL (a staging environment, say)?
    // Pass it as the second argument.
    // const notix = new Notix(Bun.env.NOTIX_API_KEY, "https://notix.example.com");
    
  3. Send one email.

    notix.emails.send takes from, to, subject and html or text (send both; some clients only show one). The fetch tab is the same call without the SDK: one POST to /api/v1/emails with a Bearer token. Run either with bun run send.ts; top-level await is fine in a Bun script.

    send.ts
    import { Notix } from "notix-js";
    
    const notix = new Notix(Bun.env.NOTIX_API_KEY);
    
    const { data, error } = await notix.emails.send({
      from: "receipts@acme.com",
      to: "customer@example.com",
      subject: "Your receipt for order 4471",
      html: "<p>Thanks for your order.</p>",
      text: "Thanks for your order.",
    });
    
    if (error) {
      console.error(error.code, error.message);
    } else {
      console.log("queued", data.emailId);
    }
    
    // bun run send.ts
    
  4. Send from a Bun.serve route.

    In an app the send sits behind a route. Bun.serve takes a routes object with per-method handlers (Bun 1.2.3 or later), so the POST that creates a receipt is the only thing that sends. Build the idempotency key from your own record id: a client that retries the POST gets the original email back, not a second one.

    server.ts
    import { Notix } from "notix-js";
    
    const notix = new Notix(Bun.env.NOTIX_API_KEY);
    
    Bun.serve({
      port: 3000,
      routes: {
        // A POST from your checkout, your signup form, or a queue worker.
        "/api/receipts": {
          POST: async (request) => {
            const { orderId, to } = await request.json();
    
            const { data, error } = await notix.emails.send(
              {
                from: "receipts@acme.com",
                to,
                subject: `Your receipt for order ${orderId}`,
                html: `<p>Thanks for order ${orderId}.</p>`,
                text: `Thanks for order ${orderId}.`,
              },
              // A retried request with the same key returns the original send.
              { idempotencyKey: `order-${orderId}-receipt` },
            );
    
            if (error) {
              return Response.json({ error }, { status: 502 });
            }
            return Response.json({ id: data.emailId });
          },
        },
      },
      // Anything not matched above.
      fetch() {
        return new Response("Not found", { status: 404 });
      },
    });
    
    // bun run server.ts
    
  5. Test it without sending.

    mock.module from bun:test swaps the SDK for a class whose emails.send is a mock. The factory runs lazily, when the module is first imported, so register the mock before you import the code under test. Assert on the mock’s calls, including the idempotency key in the second argument.

    receipts.test.ts
    import { describe, expect, mock, test } from "bun:test";
    
    // Replace the SDK module before the code under test imports it. The factory
    // runs lazily, when something imports "notix-js".
    const send = mock(async () => ({ data: { emailId: "em_test" }, error: null }));
    mock.module("notix-js", () => ({
      Notix: class {
        emails = { send };
      },
    }));
    
    const { sendReceipt } = await import("./receipts");
    
    describe("sendReceipt", () => {
      test("sends one email with the order's idempotency key", async () => {
        const id = await sendReceipt({ orderId: "4471", to: "customer@example.com" });
    
        expect(id).toBe("em_test");
        expect(send).toHaveBeenCalledTimes(1);
        expect(send.mock.calls[0]?.[1]).toEqual({ idempotencyKey: "order-4471-receipt" });
      });
    });
    
    // bun test
    
In production

Same three lines as Node.

An idempotency key, a look at the error envelope, and a webhook for delivery status. The route above already carries the key; the webhook is one more Bun.serve route that verifies the signature over the raw body. The reasoning behind each is in the Node.js guide and applies unchanged.

webhooks.ts
import { Notix } from "notix-js";

const notix = new Notix(Bun.env.NOTIX_API_KEY);
const webhooks = notix.webhooks(Bun.env.NOTIX_WEBHOOK_SECRET);

Bun.serve({
  routes: {
    "/webhooks/notix": {
      POST: async (request) => {
        // Verify over the raw body, not a parsed object.
        const rawBody = await request.text();
        const event = webhooks.constructEvent(rawBody, { headers: request.headers });

        switch (event.type) {
          case "email.delivered":
          case "email.bounced":
          case "email.complained":
            // event.data.id is the emailId you were given at send time.
            break;
        }
        return new Response("ok");
      },
    },
  },
});

The verifier uses createHmac and timingSafeEqual from Node’s crypto module, which Bun implements, so constructEvent works here as it does on Node. Batch sends of up to 100 messages, scheduling and templates are in the production guide for transactional email; the runtime does not change any of it.

FAQ

Questions, answered.

Does notix-js run on Bun?
Yes, unchanged. The SDK sends with the global fetch, which Bun provides, and its webhook verifier imports createHmac and timingSafeEqual from the Node crypto module, which Bun implements. bun add notix-js installs it like any npm package, and the same code runs under Node and Bun.
Do I need dotenv to read NOTIX_API_KEY?
No. Bun reads .env on its own at startup, plus .env.development, .env.production or .env.test depending on NODE_ENV, and .env.local on top (except when NODE_ENV is test). The values land on process.env, and Bun.env is an alias for the same object, so new Notix(Bun.env.NOTIX_API_KEY) works with nothing else installed.
How do I send from a Bun.serve route?
Create the client once at module scope, then call notix.emails.send inside the route handler and return Response.json with the email id. The routes object in Bun.serve takes per-method handlers, so a POST to /api/receipts can be the only thing that sends. Pass an idempotency key built from your own record id so a retried request never sends a second email.
How do I test code that sends without sending?
Use mock.module from bun:test to replace the notix-js module with a class whose emails.send is a mock, then import the code under test after the mock is registered. The factory runs lazily when the module is first imported. Assert on the mock's calls, including the second argument that carries the idempotency key.
Can I use the SMTP relay from Bun instead?
Yes, with Nodemailer or any SMTP client that runs under Bun: host smtp.usenotix.dev, port 465 with implicit TLS or 587 with STARTTLS, username notix, and an API key as the password. You lose the idempotency key and the batch endpoint, which only the API exposes, but every message still lands in the same log with the same suppression and webhooks. The SMTP relay page has the full configuration.
What is different from the Node.js guide?
Almost nothing in the send itself. The differences are the runtime around it: .env loads without a package, Bun.env exists as an alias, Bun.serve replaces Express or a route handler, and bun test replaces Vitest or Jest. The production steps, an idempotency key, the error envelope and a webhook, are the same and are covered in the Node.js guide.

Send the first one now.

Verify a domain, copy an API key, bun run send.ts. The free plan does not ask for a card.