Notix
Guides

Send email from SvelteKit with a form action or an endpoint.

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.

What you need

Three things before the first send.

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.

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.

The SDK and a server adapter.

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.

Install
npm install notix-js
# or: pnpm add notix-js
Steps

From an empty app to a delivered message.

  1. Create one server-side client.

    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
    # .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
    // 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);
    
  2. Add a form action for your own forms.

    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
    // 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
    <!-- 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>
    
  3. Add an endpoint for everything else.

    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
    // 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 });
    };
    
  4. Receive delivery events.

    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
    // 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");
    };
    
  5. Send one and read it back.

    Submit the form, then open the message in the dashboard: the log shows its status and every event as delivery progresses.

In production

Before real users depend on it.

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.

One build, several environments.

$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.

Adapters and variables.

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.

src/lib/server/notix.ts, runtime variant
// 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.

FAQ

Questions, answered.

$env/static/private or $env/dynamic/private for the API key?
Static, unless one build has to run in several environments. $env/static/private inlines the value at build time, fails the build if the variable is missing, and lets the bundler drop unused code. $env/dynamic/private reads process.env at request time, which is what you want for one container image promoted from staging to production. Neither module can be imported by client code, and a variable with the PUBLIC_ prefix is excluded from both.
Form action or +server endpoint?
A form action when the send is the result of a form in your own app: it receives the FormData, returns data the page renders, and works before JavaScript loads. A +server.ts endpoint when something else calls it, such as a mobile app, a cron job or another service, or when the caller sends JSON. Both run on the server and both keep the key there.
How do I keep the key out of the browser?
Name it NOTIX_API_KEY with no PUBLIC_ prefix and read it only through $env/static/private or $env/dynamic/private, from files under src/lib/server or in +page.server.ts and +server.ts. SvelteKit refuses to bundle those imports into client code, so a mistake fails the build rather than shipping the key.
Which adapter do I need?
Any adapter with a server: adapter-node for a Node process or container, adapter-vercel, adapter-netlify or adapter-cloudflare for those platforms. adapter-static has no server, so it cannot run a form action or an endpoint. On Cloudflare the SDK's webhook verifier imports node:crypto, so enable the nodejs_compat flag; the send itself is plain fetch.
What happens if the form is submitted twice?
For a send with a natural id, such as an order, pass an idempotency key derived from it: a retry with the same key within 24 hours returns the first message instead of sending again, and a different body under the same key is refused with a 409. A contact form has no natural id, so disable the button while the request is in flight, as the enhance callback in the example does.
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.

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.

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.