An API key.
Created in the dashboard under API keys, shown once. Put it in .env as NOTIX_API_KEY with no PUBLIC_ prefix, so Astro keeps it out of the browser bundle.
Astro renders on demand once you add an adapter, and that is all a send needs: an API endpoint under src/pages/api or an Astro Action your pages call, with the key in a server-only environment variable. This guide builds both, adds a webhook endpoint that verifies Notix's signature, and shows what changes on the Node and Cloudflare adapters. It runs on the email API.
Created in the dashboard under API keys, shown once. Put it in .env as NOTIX_API_KEY with no PUBLIC_ prefix, so Astro keeps it out of the browser bundle.
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.
A static build has no server to send from. Add the Node or Cloudflare adapter for on-demand rendering, then notix-js, a typed client over the JSON API, published on npm.
npm install notix-js
# On-demand rendering needs an adapter. Pick one:
npx astro add node
npx astro add cloudflare
A file under src/pages/api that exports POST is a server route. It reads the JSON body, sends with an idempotency key derived from the order, and answers with the message id or the SDK's error envelope. The prerender = false line keeps it dynamic when the site is otherwise static.
// src/pages/api/send.ts
export const prerender = false; // not needed in 'server' output mode
import type { APIRoute } from "astro";
import { Notix } from "notix-js";
// import.meta.env is Astro's environment object. A variable without the
// PUBLIC_ prefix is server-only and never reaches the browser bundle.
const notix = new Notix(import.meta.env.NOTIX_API_KEY);
export const POST: APIRoute = async ({ request }) => {
const { to, subject, html, orderId } = await request.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 new Response(JSON.stringify({ error }), {
status: 502,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ id: data?.emailId }), {
headers: { "Content-Type": "application/json" },
});
};
An Action is a server function your pages can call by name. Astro validates the input with the schema before the handler runs, and a thrown ActionError becomes the error the caller reads.
// src/actions/index.ts
import { defineAction, ActionError } from "astro:actions";
import { z } from "astro/zod";
import { Notix } from "notix-js";
const notix = new Notix(import.meta.env.NOTIX_API_KEY);
export const server = {
sendReceipt: defineAction({
// Astro validates the input with this schema before the handler runs.
input: z.object({
to: z.string().email(),
orderId: z.string(),
}),
handler: async ({ to, orderId }) => {
const { data, error } = await notix.emails.send(
{
from: "receipts@acme.com",
to,
templateId: "tpl_receipt_v4",
variables: { orderId },
},
{ idempotencyKey: `order-${orderId}-receipt` },
);
if (error) {
throw new ActionError({ code: "BAD_GATEWAY", message: error.message });
}
return { id: data?.emailId };
},
}),
};
A plain HTML form posts to the Action with no client JavaScript and the page re-renders with the result. A client script gets the same { data, error } shape back from a function call.
---
// A zero-JS form posts straight to the action; the page reloads with
// the result available through Astro.getActionResult.
import { actions } from "astro:actions";
const result = Astro.getActionResult(actions.sendReceipt);
---
<form method="POST" action={actions.sendReceipt}>
<input name="to" type="email" required />
<input name="orderId" type="hidden" value="48213" />
<button>Email my receipt</button>
</form>
{result?.data && <p>Sent: {result.data.id}</p>}
{result?.error && <p>Could not send: {result.error.message}</p>}
<script>
import { actions } from "astro:actions";
const button = document.querySelector("button");
button?.addEventListener("click", async () => {
// Same { data, error } shape as the SDK, so the branches read alike.
const { data, error } = await actions.sendReceipt({
to: "chidi@example.com",
orderId: "48213",
});
if (error) {
console.error(error.message);
return;
}
console.log("sent", data.id);
});
</script>
A second endpoint for email.delivered, email.bounced and email.complained. Read the body as text, hand it and the request headers to constructEvent, and act on the event it returns. A bad signature throws, and the route answers 400.
// src/pages/api/webhooks/notix.ts
export const prerender = false;
import type { APIRoute } from "astro";
import { Notix } from "notix-js";
const webhooks = new Notix(import.meta.env.NOTIX_API_KEY).webhooks(
import.meta.env.NOTIX_WEBHOOK_SECRET,
);
export const POST: APIRoute = async ({ request }) => {
// The signature covers the raw bytes: read text, never request.json().
const rawBody = await request.text();
let event;
try {
event = webhooks.constructEvent(rawBody, { headers: request.headers });
} catch (error) {
return new Response((error as Error).message, { status: 400 });
}
if (event.type === "email.bounced" && event.data.bounce.type === "Permanent") {
// Mark the address invalid in your own store.
}
return new Response("ok");
};
The routes do not change between adapters. What changes is how the environment reaches them: the Node adapter reads the process environment and your .env file; the Cloudflare adapter reads Worker bindings and needs the Node compatibility flag for the SDK's webhook verifier.
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
});
// .env (never committed; Astro reads it in development, and the process
// environment wins over it in production)
// NOTIX_API_KEY=notix_...
// astro.config.mjs
import { defineConfig } from "astro/config";
import cloudflare from "@astrojs/cloudflare";
export default defineConfig({
output: "server",
adapter: cloudflare(),
});
// wrangler.toml: the SDK's webhook verifier imports node:crypto.
// compatibility_flags = ["nodejs_compat"]
// Set the key as a secret, not a plain var:
// wrangler secret put NOTIX_API_KEY
// Then read it in an endpoint from the runtime env rather than
// import.meta.env, which is not populated by Workers at request time:
import { env } from "cloudflare:workers";
const notix = new Notix(env.NOTIX_API_KEY as string);
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.
Deploying the whole site to Workers without Astro? The Cloudflare Workers guide covers a bare Worker. Sending magic links from the site? The magic link page has the token flow. The free plan does not ask for a card.
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 Worker that sends through the JSON API with fetch, wrangler secrets, the nodejs_compat flag for the SDK's webhook verifier, and safe retries.
Use casesPasswordless sign-in by email: the token, the link, the send, and what to do when the link is opened twice.
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, make one call. The free plan does not ask for a card.