Notix
Use cases

Magic link sign-in: your token, our delivery.

A magic link is a one-time token in a URL, sent by email, consumed once to start a session. Your app generates the token, stores its hash with a short expiry and owns the callback route; Notix carries the email through the transactional email API with a template and an idempotency key. The part most teams get wrong is what happens when the link is opened twice, so that is where this page spends its time.

The flow

Generate, send, confirm, consume.

Four steps, two of them on your side of the network, one of them a button the user has to press.

  1. Generate a token and store its hash.

    Thirty-two random bytes, base64url encoded, from the platform’s secure random source. Store the SHA-256 of it with the address, an expiry ten to fifteen minutes out and an empty usedAt. The raw token goes into the link and nowhere else.

  2. Send the link.

    One call to POST /v1/emails with a template that renders the link and the expiry, and an Idempotency-Key that names the sign-in request. A retried request then sends one email, not two.

  3. Render a confirmation page on GET.

    The callback URL, opened, shows “Sign in to Acme” and a button. It changes nothing. Mail scanners and link previews open this page too, and they must find nothing to consume.

  4. Consume on POST.

    The button posts the token back. Hash it, update the row where the hash matches, usedAt is empty and the expiry is in the future, and create the session in the same transaction. Zero rows updated means expired, unknown or already used; offer a fresh link and say nothing more.

Code

Requesting the link.

Token, hash, row, send. The template tmpl_magic_link is one you create in Notix with {{link}} and {{expiresMinutes}} variables.

curl
# Your app has already generated the token, stored sha256(token)
# with an expiry, and built the link. Notix only carries the email.
curl -X POST https://app.usenotix.dev/api/v1/emails \
  -H "Authorization: Bearer $NOTIX_API_KEY" \
  -H "Idempotency-Key: login-$REQUEST_ID" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <sign-in@acme.com>",
    "to": "user@example.com",
    "templateId": "tmpl_magic_link",
    "variables": {
      "link": "https://acme.com/auth/callback?token=Wm9vZC1yYW5kb20tdG9rZW4",
      "expiresMinutes": "15"
    }
  }'

# 201 { "emailId": "em_abc123" }

The callback.

GET renders, POST consumes. The update-where-unused is the whole security model; the single row count is what makes a double open safe.

app/auth/callback/route.ts (Next.js App Router)
import { createHash } from "node:crypto";

// GET only renders a page with a button. Scanners and link previews
// fetch this URL before the user does; nothing may change here.
export async function GET(request: Request) {
  const token = new URL(request.url).searchParams.get("token") ?? "";
  return renderConfirmPage({ token });
}

// The button posts back. This is the only place the token is consumed.
export async function POST(request: Request) {
  const { token } = await request.json();
  const tokenHash = createHash("sha256").update(token).digest("hex");

  const consumed = await db.loginToken.updateMany({
    where: { tokenHash, usedAt: null, expiresAt: { gt: new Date() } },
    data: { usedAt: new Date() },
  });

  if (consumed.count !== 1) {
    // Expired, unknown or already used: offer a fresh link, say nothing else.
    return renderExpired();
  }

  const row = await db.loginToken.findUniqueOrThrow({ where: { tokenHash } });
  const user = await db.user.upsert({
    where: { email: row.email },
    update: {},
    create: { email: row.email },
  });
  return startSession(user);
}
Rules

The values worth writing down.

All but the last row are decisions in your code. The last one is a Notix setting on the sending domain.

RuleValueNotes
Token32 random bytes, base64urlFrom the platform's CSPRNG. Never derived from the email address, the time or a counter.
Stored asSHA-256 of the tokenA database leak then exposes nothing usable. Compare hashes, never raw tokens.
Expiry10 to 15 minutesLong enough to open a mail app on a phone, short enough that a forwarded email is dead by the time it matters.
UsesOneMark the row consumed on the POST that signs the user in, in the same transaction that creates the session.
Requests per address3 in 15 minutes, per IP as wellYour limit, on your endpoint. A stranger must not be able to fill someone else's inbox by asking you to.
TrackingClick tracking off on the sending domainA tracked link is rewritten through a redirect. Click tracking is a per-domain switch in Notix, off by default; do not turn it on for the domain that sends sign-in links.

The send request’s full shape, templates and variables are in the docs.

Design notes

Six habits that keep magic links working.

Never consume on GET.

Corporate mail scanners, Outlook safe links and chat previews open every URL in an email, sometimes within a second of delivery. If the GET signs the user in, the scanner signs in and the user sees an expired link. Render a page with one button and consume on the POST.

Treat a second open as a user, not an attacker.

The same person opens the email on their phone, then on the laptop. Show "this link was already used, request another" with the form pre-filled. Log it, do not lock the account.

Say the same thing whether or not the account exists.

"If that address has an account, a link is on its way" for every request, at the same speed. Anything else lets someone enumerate your users one address at a time.

Keep the link out of click tracking.

Click tracking is a per-domain switch in Notix and off by default. Send sign-in mail from a domain that keeps it off, or a subdomain of its own, so the link the user clicks is yours and not a tracking redirect that scanners and link checkers follow twice.

Handle suppressed addresses honestly.

An address that hard-bounced or complained is on your suppression list and the send is refused. Do not tell the visitor that. Do tell the account owner, on a signed-in settings page, that email to that address is not being delivered and how to change it.

Offer a code for the other device.

A link opened on the phone does not sign in the laptop that asked for it. Put a six-digit code in the same email, or fall back to the OTP flow, so the user can type it where they started.

Example

What the user receives.

Short, one button, the expiry stated, and a code for the case where the email is read somewhere other than the device that asked.

Email

Subject: Your Acme sign-in link

Press the button to sign in to Acme. The link works once and expires in 15 minutes.

[ Sign in to Acme ]

On a different device? Enter the code 482913 instead.

If you did not ask for this, you can ignore it; nobody can sign in without opening this email.

Pair it with a code

The code line uses the OTP flow: send the code with POST /v1/verify/send at the same time as the link, and accept either on the sign-in form. Supabase users get the link from Supabase itself; the Supabase page shows how to deliver it through Notix.

FAQ

Questions, answered.

Does Notix have a magic link endpoint?
No. Notix sends the email and reports delivery; your application generates the token, stores its hash, builds the link and consumes it on the callback. That is deliberate: the token has to be checked against your user table and your session store, which Notix does not hold. The one-time code flow is different: POST /v1/verify/send and /v1/verify/check do generate and check the code for you.
Why does my magic link say "already used" before the user clicks it?
Something opened the URL first. Mail security gateways, Outlook Safe Links, Gmail's link checks and chat apps that render previews all fetch links inside an email, often within seconds. If your callback consumes the token on a GET, that fetch consumes it. Render a confirmation page on GET and consume the token only on the POST from its button.
How long should a magic link last?
Ten to fifteen minutes. That covers the delay between requesting the link and finding it on a phone, and it means a forwarded or archived email stops working before anyone else can use it. Give the session you create afterwards its own, longer life; the link is not the session.
How do I stop the sign-in link being rewritten for click tracking?
Click tracking in Notix is set per sending domain, on the domain's page in the dashboard, and it is off by default. There is no per-message switch on the send request. Send sign-in mail from a domain, or a dedicated subdomain such as sign-in.acme.com, that leaves click tracking off, and keep tracking on the domain your marketing mail uses.
Can I combine a magic link with a one-time code?
Yes, and it is the better experience when the email is read on a different device from the one that asked. Put the link and a short code in the same email: the link signs in the device that opens it, the code is typed on the device that requested it. The OTP page covers the code half with the verify endpoints.
I use Supabase Auth. Do I need any of this?
Supabase generates and checks the magic link token itself; you only need Notix to deliver its emails, through the SMTP relay or the Send Email Hook. The Supabase integration page covers that setup. This page is for apps that own their authentication.

Ship passwordless sign-in this afternoon.

One template, one send call with an idempotency key, and the free plan's 5,000 emails a month to test the double-open case with. No card.