Notix
Guides

Send email from Astro, from an endpoint or an Action.

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.

What you need

Three things before the first send.

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.

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.

An adapter and the SDK.

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.

Install
npm install notix-js
# On-demand rendering needs an adapter. Pick one:
npx astro add node
npx astro add cloudflare
Steps

An endpoint, an Action, then the webhook.

  1. Add an API endpoint.

    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
    // 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" },
      });
    };
    
  2. Or define an Action.

    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
    // 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 };
        },
      }),
    };
    
  3. Call it from a form or a script.

    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.

    src/pages/order.astro, no client JavaScript
    ---
    // 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>}
    
  4. Receive delivery events.

    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
    // 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");
    };
    
Adapters

Where the key comes from on each host.

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 and .env
// 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_...

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.

How do I send email from an Astro site?
Turn on on-demand rendering with an adapter, then either add an API endpoint under src/pages/api that calls notix.emails.send, or define an Astro Action and call it from a form or a client script. Both run on the server, so the API key stays in a server-only environment variable. A static Astro site with no adapter cannot send, because there is no server to hold the key.
Should I use an API endpoint or an Astro Action?
An Action when the sender is your own page: Astro validates the input with the schema you give it, a plain HTML form can post to it with no JavaScript, and the client gets the same { data, error } result the SDK returns. An endpoint when something other than your pages calls it, such as a webhook from Notix or a mobile app, because an endpoint is an ordinary HTTP route with a URL you control.
Where does the API key live?
In an environment variable without the PUBLIC_ prefix, read through import.meta.env on the server. Astro checks the process environment first and falls back to a .env file, so development uses the file and production uses whatever the host sets. On the Cloudflare adapter there is no process.env at request time; the key is a Worker secret set with wrangler secret put and read from the runtime env.
Does notix-js work on the Cloudflare adapter?
Yes, with the nodejs_compat compatibility flag in wrangler.toml. Sending is plain fetch, but the SDK's webhook verifier imports node:crypto at module load, and that import needs the flag. Without it the Worker fails to start. The Node adapter needs nothing extra.
Why does the webhook endpoint read request.text() instead of request.json()?
The signature Notix sends is an HMAC over the exact bytes of the body, prefixed by the timestamp header. Parsing the JSON and serialising it again can change key order and whitespace, so the check would fail. Read the text once, pass it and the request headers to constructEvent, and work with the event it returns.
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.

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.

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.