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 contact form that sends through a form action, a +server.ts endpoint other services can call, the API key in a private environment module the browser cannot reach, 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 use SvelteKit 2 and Svelte 5 runes.
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. SvelteKit needs an adapter with a server (node, vercel, netlify or cloudflare); adapter-static cannot run the server code on this page.
npm install notix-js
# or: pnpm add notix-js
Read the key from $env/static/private in a module under src/lib/server. SvelteKit refuses to import either into client code, and a missing variable fails the build instead of failing at the first send.
# .env (never committed). No PUBLIC_ prefix: private stays on the server.
NOTIX_API_KEY=nx_live_...
NOTIX_WEBHOOK_SECRET=whsec_...
// src/lib/server/notix.ts
import { NOTIX_API_KEY } from "$env/static/private";
import { Notix } from "notix-js";
// Anything under src/lib/server can only be imported by server code;
// SvelteKit refuses the import from a component at build time.
export const notix = new Notix(NOTIX_API_KEY);
The action receives the FormData, validates, sends and returns plain data the page renders. fail carries a status and the error back to the form without a redirect, and the form works before JavaScript loads.
// src/routes/contact/+page.server.ts
import { fail } from "@sveltejs/kit";
import { notix } from "$lib/server/notix";
import type { Actions } from "./$types";
export const actions: Actions = {
default: async ({ request }) => {
const form = await request.formData();
const email = String(form.get("email") ?? "");
const message = String(form.get("message") ?? "");
if (!email.includes("@") || message.length < 10) {
return fail(400, { error: "Add a valid email and a message." });
}
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) {
return fail(502, { error: error.message });
}
return { ok: true };
},
};
<!-- src/routes/contact/+page.svelte -->
<script lang="ts">
import { enhance } from "$app/forms";
let { form } = $props();
let pending = $state(false);
</script>
<form method="POST" use:enhance={() => { pending = true; return async ({ update }) => { await update(); pending = false; }; }}>
<input name="email" type="email" required placeholder="you@example.com" />
<textarea name="message" required minlength="10"></textarea>
<button type="submit" disabled={pending}>{pending ? "Sending…" : "Send"}</button>
{#if form?.ok}<p>Thanks, we got it.</p>{/if}
{#if form?.error}<p role="alert">{form.error}</p>{/if}
</form>
A +server.ts file exports a handler per method. The SDK answers { data, error }, so a failed send is a value you check; the fetch tab is the same call without the dependency.
// src/routes/api/send/+server.ts
import { json } from "@sveltejs/kit";
import { notix } from "$lib/server/notix";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request }) => {
const { to, subject, html, orderId } = await request.json();
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) {
return json({ error }, { status: 502 });
}
return json({ id: data?.emailId });
};
// src/routes/api/send/+server.ts
import { json } from "@sveltejs/kit";
import { NOTIX_API_KEY } from "$env/static/private";
import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ request, fetch }) => {
const { to, subject, html, orderId } = await request.json();
const response = await fetch("https://app.usenotix.dev/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${NOTIX_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${orderId}-receipt`,
},
body: JSON.stringify({ from: "receipts@acme.com", to, subject, html }),
});
return json(await response.json(), { status: response.ok ? 200 : 502 });
};
Add an endpoint for email.delivered, email.bounced and email.complained. Read the body as text, verify with constructEvent, and store the outcome against your own record.
// src/routes/api/webhooks/notix/+server.ts
import { NOTIX_WEBHOOK_SECRET } from "$env/static/private";
import { notix } from "$lib/server/notix";
import type { RequestHandler } from "./$types";
const webhooks = notix.webhooks(NOTIX_WEBHOOK_SECRET);
export const POST: RequestHandler = async ({ request }) => {
// The signature covers the exact bytes: read text, not 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 here.
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.
The endpoint above already carries an idempotency key and checks the error envelope. Two SvelteKit-specific choices remain; the rest of the checklist is shared with every framework.
$env/static/private inlines the key at build time. If the same build is promoted from staging to production, read the key at request time with $env/dynamic/private instead.
Set NOTIX_API_KEY and NOTIX_WEBHOOK_SECRET as server variables on the platform. On Cloudflare, enable nodejs_compat for the webhook verifier. Rate limit the form by IP or session: the free plan sends 100 messages a day and a bot should not spend them for you.
// If the key is only known at runtime (one image, many environments),
// use the dynamic module instead. It reads process.env when the request
// arrives rather than inlining the value at build time.
import { env } from "$env/dynamic/private";
import { Notix } from "notix-js";
export const notix = new Notix(env.NOTIX_API_KEY!);
Templates by id, batch sends, 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 magic link use case is the natural next step for a SvelteKit sign-in. 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 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.