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.
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.
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.
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.
fetch is built in, and the SDK is one install:
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.
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 - Bun reads this file on its own; no dotenv package.
NOTIX_API_KEY=notix_your_key_here
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).
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");
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.
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
const response = await fetch("https://app.usenotix.dev/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${Bun.env.NOTIX_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
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.",
}),
});
const body = await response.json();
if (!response.ok) {
// Every error is { error: { code, message } }.
console.error(body.error.code, body.error.message);
} else {
console.log("queued", body.emailId);
}
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.
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
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.
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
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.
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.
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 Hono route that sends through Notix on Node, Bun, Deno or Cloudflare Workers, the runtime Notix's own API is written in.
Use casesSend one-time codes by email or SMS with two API calls, with expiry, length and risk scoring handled for you.
LearnWhy a retried request must not send twice, how the Idempotency-Key header works, and how to choose a key.
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, bun run send.ts. The free plan does not ask for a card.