An API key.
Created in the dashboard under API keys, shown once. Set it as NOTIX_API_KEY in the server environment; a .server module reads it and the bundler keeps that module off the client.
A route action is the natural place to send: it runs on the server, it receives the form, and the component reads the result back. This guide builds one, adds a resource route that verifies Notix's webhook signature from the raw body, and notes the one place Remix 2 and React Router 7 differ. It runs on the email API.
Created in the dashboard under API keys, shown once. Set it as NOTIX_API_KEY in the server environment; a .server module reads it and the bundler keeps that module off the client.
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.
notix-js is a typed client over the JSON API, published on npm. It sends with fetch, so it runs wherever your routes run.
npm install notix-js
# React Router 7 framework mode (Remix's successor) or Remix 2: the route
# module shapes below are the same in both.
One instance, constructed from the environment, in a file whose name ends in .server. Every route imports it from there.
// app/lib/notix.server.ts
import { Notix } from "notix-js";
// The .server suffix keeps this module out of the browser bundle, so the
// key can never end up on the client. On Node the key comes from
// process.env; a .env file loaded by your process manager is the usual
// source in development.
export const notix = new Notix(process.env.NOTIX_API_KEY);
The action reads the form, sends with an idempotency key derived from the order, and returns a plain object. The component renders the outcome with useActionData. With JavaScript disabled the same form still posts and the same action still runs.
// app/routes/order.tsx
import { Form, useActionData } from "react-router";
import type { Route } from "./+types/order";
import { notix } from "~/lib/notix.server";
// The action runs on the server for a POST to this route. React Router
// serialises the <Form> and calls it; with JavaScript off the browser
// posts the form itself and the same function runs.
export async function action({ request }: Route.ActionArgs) {
const form = await request.formData();
const to = String(form.get("to"));
const orderId = String(form.get("orderId"));
const { data, error } = await notix.emails.send(
{
from: "receipts@acme.com",
to,
templateId: "tpl_receipt_v4",
variables: { orderId },
},
// The same key on a retried submit returns the first message
// instead of sending a second one.
{ idempotencyKey: `order-${orderId}-receipt` },
);
if (error) {
return { ok: false as const, message: error.message };
}
return { ok: true as const, id: data?.emailId };
}
export default function Order() {
const result = useActionData<typeof action>();
return (
<Form method="post">
<input name="to" type="email" required />
<input name="orderId" type="hidden" value="48213" />
<button>Email my receipt</button>
{result?.ok === true && <p>Sent: {result.id}</p>}
{result?.ok === false && <p>Could not send: {result.message}</p>}
</Form>
);
}
React Router 7 lists routes in app/routes.ts; Remix 2 maps them from file names. Either way the webhook route is a resource route: a module with an action and no component.
// app/routes.ts (React Router 7 framework mode)
import { type RouteConfig, route } from "@react-router/dev/routes";
export default [
route("/order", "routes/order.tsx"),
// A resource route: a module with an action and no default component.
route("/webhooks/notix", "routes/webhooks.notix.ts"),
] satisfies RouteConfig;
// Remix 2 uses file routes instead: app/routes/webhooks.notix.ts maps to
// /webhooks/notix on its own, with no config entry.
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.
// app/routes/webhooks.notix.ts
import type { Route } from "./+types/webhooks.notix";
import { notix } from "~/lib/notix.server";
const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
// No default export, so this route answers HTTP directly instead of
// rendering a page. The action receives the standard Request.
export async function action({ request }: Route.ActionArgs) {
// 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 });
}
switch (event.type) {
case "email.delivered":
case "email.bounced":
case "email.complained":
// Record the outcome against your own order or user id.
break;
}
return new Response("ok");
}
Submit the form, then open the message in the dashboard: the log shows its status and every event as delivery progresses, and your resource route logs the same events as they arrive.
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.
On Express instead? The Express guide has the raw-body middleware. Sending password resets from the action? The reset page covers 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 route that sends with notix-js, the raw-body webhook receiver, and the same idempotency and error handling as the Node guide.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
LearnHMAC-SHA256 over the timestamp and raw body, a five-minute tolerance, replay protection, and the check in four languages.
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.