An API key.
Created in the dashboard under API keys, shown once. A sending access key can send but cannot read contacts or delete domains, which is the right shape for a key on a web server.
By the end you will have a server route any client can POST to, a contact page that calls it, the API key in private runtime config where the browser cannot see it, and the two things production needs: an idempotency key so a retried request never sends twice, and a webhook route that records delivery. It runs on the email API; the examples are for Nuxt 3 and its Nitro server.
Created in the dashboard under API keys, shown once. A sending access key can send but cannot read contacts or delete domains, which is the right shape for a key on a web server.
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. The fetch tab on this page shows the same call through Nuxt's $fetch if you would rather not add it.
npm install notix-js
# or: pnpm add notix-js
Declare the key in runtimeConfig with an empty default and set the real value through the environment. Keys at the top level are server-only; only the public block reaches the browser, and the key never goes there.
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
// Private: available on the server only. Overridden at runtime by
// the NUXT_NOTIX_API_KEY environment variable.
notixApiKey: "",
notixWebhookSecret: "",
// Anything under public reaches the browser. The key never goes here.
public: {},
},
});
# .env (never committed)
NUXT_NOTIX_API_KEY=nx_live_...
NUXT_NOTIX_WEBHOOK_SECRET=whsec_...
A file under server/api is an endpoint; the .post.ts suffix limits it to POST. Pass the event to useRuntimeConfig so per-request overrides apply. The SDK answers { data, error }, so a failed send is a value you check and turn into a proper HTTP error.
// server/api/send.post.ts
import { Notix } from "notix-js";
export default defineEventHandler(async (event) => {
// Pass the event so runtime overrides apply per request.
const { notixApiKey } = useRuntimeConfig(event);
const notix = new Notix(notixApiKey);
const { to, subject, html, orderId } = await readBody(event);
const { data, error } = await notix.emails.send(
{ from: "receipts@acme.com", to, subject, html },
// A retried request with the same key returns the first message.
{ idempotencyKey: `order-${orderId}-receipt` },
);
if (error) {
throw createError({ statusCode: 502, statusMessage: error.message, data: error });
}
return { id: data?.emailId };
});
// server/api/send.post.ts
export default defineEventHandler(async (event) => {
const { notixApiKey } = useRuntimeConfig(event);
const { to, subject, html, orderId } = await readBody(event);
// $fetch throws on a non-2xx response; the catch below keeps the
// error envelope Notix returns.
return await $fetch("https://app.usenotix.dev/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${notixApiKey}`,
"Idempotency-Key": `order-${orderId}-receipt`,
},
body: { from: "receipts@acme.com", to, subject, html },
}).catch((err) => {
throw createError({ statusCode: 502, data: err.data });
});
});
The page only collects the form and shows the state. The route validates, sends and answers; the key stays on the server by construction.
// server/api/contact.post.ts
import { Notix } from "notix-js";
export default defineEventHandler(async (event) => {
const { notixApiKey } = useRuntimeConfig(event);
const { email, message } = await readBody<{ email: string; message: string }>(event);
if (!email?.includes("@") || !message || message.length < 10) {
throw createError({ statusCode: 400, statusMessage: "Add a valid email and a message." });
}
const notix = new Notix(notixApiKey);
const { error } = await notix.emails.send({
from: "contact@acme.com",
to: "hello@acme.com",
replyTo: email,
subject: `New message from ${email}`,
text: message,
});
if (error) {
throw createError({ statusCode: 502, statusMessage: error.message });
}
return { ok: true };
});
<!-- pages/contact.vue -->
<script setup lang="ts">
const email = ref("");
const message = ref("");
const status = ref<"idle" | "sending" | "sent" | "error">("idle");
async function submit() {
status.value = "sending";
try {
await $fetch("/api/contact", {
method: "POST",
body: { email: email.value, message: message.value },
});
status.value = "sent";
} catch {
status.value = "error";
}
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="email" type="email" required placeholder="you@example.com" />
<textarea v-model="message" required minlength="10" />
<button type="submit" :disabled="status === 'sending'">
{{ status === "sending" ? "Sending…" : "Send" }}
</button>
<p v-if="status === 'sent'">Thanks, we got it.</p>
<p v-if="status === 'error'" role="alert">Something went wrong. Try again.</p>
</form>
</template>
Add a route for email.delivered, email.bounced and email.complained. Read the raw body, verify with constructEvent, and store the outcome against your own record.
// server/api/webhooks/notix.post.ts
import { Notix } from "notix-js";
export default defineEventHandler(async (event) => {
const { notixApiKey, notixWebhookSecret } = useRuntimeConfig(event);
const webhooks = new Notix(notixApiKey).webhooks(notixWebhookSecret);
// readRawBody, not readBody: the signature covers the exact bytes.
const rawBody = (await readRawBody(event, "utf8")) ?? "";
let notixEvent;
try {
notixEvent = webhooks.constructEvent(rawBody, { headers: getHeaders(event) });
} catch (err) {
throw createError({ statusCode: 400, statusMessage: (err as Error).message });
}
switch (notixEvent.type) {
case "email.delivered":
case "email.bounced":
case "email.complained":
// Record the outcome against your own order or user id here.
break;
}
return "ok";
});
Submit the form, then open the message in the dashboard: the log shows its status and every event as delivery progresses.
The route above already carries an idempotency key and checks the error envelope. The rest of the production checklist is shared with every framework and lives in one place.
Set NUXT_NOTIX_API_KEY and NUXT_NOTIX_WEBHOOK_SECRET as server variables on Vercel, Netlify or wherever Nitro deploys. Rate limit the contact route by IP or session too: the free plan sends 100 messages a day and a bot should not be able to spend them for you.
Templates by id, batch sends of up to 100 messages, scheduling and the retry strategy are in the production Node.js guide; the webhook headers and tolerance window are on the signature verification page.
The Next.js guide is the same flow with server actions, and the Node.js guide shows the SDK, fetch and Nodemailer side by side. The free plan does not ask for a card.
One JSON API for transactional and marketing email: send, batch, schedule, webhooks, typed SDKs.
GuidesA route handler, a server action and a contact form on the App Router, with a React Email template.
GuidesThe SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
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.