Notix
Use cases

Password reset emails that work once and expire soon.

A password reset is one email and one rule: the thing in the email must open the account exactly once, and only for a little while. Your app mints the token and owns the reset page; Notix delivers the email through the transactional email API with an idempotency key so a retry never sends twice, and drops addresses that have bounced or complained. If you would rather send a six-digit code than a link, the verification API generates, delivers and checks it for you.

The flow

Mint, send, spend.

Three steps, one table of your own, and one send. The token never touches your logs and never lives in the database in the clear.

  1. Mint a token.

    When the form arrives, look the account up. Answer the same way whether or not it exists. If it does, generate 32 random bytes, store their SHA-256 hash with an expiry of thirty minutes and an empty usedAt, and build the reset URL from the raw token.

  2. Send the link.

    One call to POST /v1/emails with a template or inline HTML and text, and an idempotency key of reset-{userId}-{tokenId}. Keep the emailId on the token row so support can see what happened to it.

  3. Spend it once.

    The reset endpoint hashes the token it receives, finds a row that is unused and unexpired, and in one transaction marks it used, changes the password and ends every other session. A second submit of the same link finds nothing and says so.

Code

Mint and send.

curl, or the TypeScript and Python SDKs. The samples use a template with two variables; inline html and text work the same way, as the curl example shows.

curl
# 1. In your app: token = 32 random bytes, store sha256(token) + expiry
# 2. Send the link. The idempotency key names this token, so a retry
#    cannot produce a second email.
curl -X POST https://app.usenotix.dev/api/v1/emails \
  -H "Authorization: Bearer $NOTIX_API_KEY" \
  -H "Idempotency-Key: reset-usr_123-tok_9f2a" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme <no-reply@acme.com>",
    "to": "user@example.com",
    "subject": "Reset your Acme password",
    "text": "Someone asked to reset the password for this Acme account.\n\nReset it here (the link works once and expires in 30 minutes):\nhttps://acme.com/reset?token=3b1f...\n\nIf that was not you, ignore this email. Your password has not changed.",
    "html": "<p>Someone asked to reset the password for this Acme account.</p><p><a href=\"https://acme.com/reset?token=3b1f...\">Reset your password</a> (works once, expires in 30 minutes).</p><p>If that was not you, ignore this email. Your password has not changed.</p>"
  }'

# 200 { "emailId": "eml_abc123" }

Consume the link.

Find, spend and change in one transaction, then sign every other session out.

TypeScript
// POST /reset  { token, newPassword }
export async function completePasswordReset(token: string, newPassword: string) {
  const tokenHash = createHash("sha256").update(token).digest("hex");

  // One transaction: find a live token, spend it, change the password.
  const changed = await db.$transaction(async (tx) => {
    const reset = await tx.passwordReset.findFirst({
      where: { tokenHash, usedAt: null, expiresAt: { gt: new Date() } },
    });
    if (!reset) return false;

    await tx.passwordReset.update({ where: { id: reset.id }, data: { usedAt: new Date() } });
    await tx.user.update({
      where: { id: reset.userId },
      data: { passwordHash: await hashPassword(newPassword) },
    });
    // Every other session for this user ends here.
    await tx.session.deleteMany({ where: { userId: reset.userId } });
    return true;
  });

  if (!changed) throw new ResetError("This link has expired or was already used.");
}
The code variant

A six-digit code instead of a link.

When the user is already on your reset screen, or the same flow must work by SMS, let the verification API do the token work: it generates the code, delivers it, enforces ten minutes and five guesses, and rate limits each recipient. You change the password only on verified: true. The limits are the ones on the OTP page.

curl
# Send: Notix generates and delivers the code; you keep the id.
curl -X POST https://app.usenotix.dev/api/v1/verify/send \
  -H "Authorization: Bearer $NOTIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "to": "user@example.com", "appName": "Acme", "clientIp": "203.0.113.10" }'
# 201 { "id": "ver_abc123", "status": "pending", "expiresAt": "...", "attemptsRemaining": 5, ... }

# Check: the id and what the user typed. Change the password only on verified: true.
curl -X POST https://app.usenotix.dev/api/v1/verify/check \
  -H "Authorization: Bearer $NOTIX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "id": "ver_abc123", "code": "482913" }'
# 200 { "verified": true, ... }  or  { "verified": false, "reason": "wrong_code", "attemptsRemaining": 4 }
Rules

What your app enforces, and what Notix does.

The first four are conventions your reset endpoint has to keep; the last three are what the send and verification APIs do on their own.

RuleEnforced byNotes
One answer whether or not the account existsYour appReply "If that address has an account, a reset link is on its way" every time, in the same time. Only send when the account exists.
Token is random, hashed at rest, used onceYour app32 random bytes, stored as a SHA-256 hash with an expiry and a used-at column. Compare the hash, then mark it used in the same transaction as the password change.
Link expires quicklyYour appFifteen to sixty minutes. A reset link that works next week is a stolen mailbox waiting to happen.
Rate limit per account and per IPYour appThree requests per address per hour and a per-IP cap on the endpoint. Still answer with the same message when the limit trips.
One email per request, even on retryNotixPass an idempotency key that names the token, such as reset-{userId}-{tokenId}. A retried call with the same key returns the original response instead of a second email.
Suppressed addresses are not sentNotixAn address that hard bounced or complained is dropped. The send still answers 200 with an id, and that email's status reads SUPPRESSED, so check the status when a user reports nothing arrived.
Code variant: 6 digits, 10 minutes, 5 guessesNotixThe verification API's defaults. Five sends per recipient in ten minutes, 30 seconds apart; more answers 429 RATE_LIMITED.

The send request and response, the idempotency header and the email status values are in the docs.

Example

What the user receives.

Short, specific, and honest about what happens if it was not them. The plain-text part says the same thing without the link styling.

Link

Subject: Reset your Acme password

Someone asked to reset the password for this Acme account. Reset your password (the link works once and expires in 30 minutes). If that was not you, ignore this email. Your password has not changed.

Code

Subject: Acme verification code: 482913

Your Acme verification code is 482913. It expires in 10 minutes. If you did not request a code, you can ignore this email.

The verification API’s built-in template. Pass a templateId to send your own.

Leave out

The password, old or new or temporary. The username, which turns a misdelivered email into half a credential. A link that lives for days. A greeting that reveals whether the account exists to someone who only guessed the address.

Design notes

Five habits that keep resets safe.

Never send the password.

Not the old one, not a new one, not a temporary one. A reset email carries a way to choose a password, never a password. Anything else sits in a mailbox for years.

Hash the token like a password.

The database holds the SHA-256 of the token, never the token. A leaked table then yields nothing that opens an account, and the comparison on the reset endpoint is a hash lookup.

Spend it in the same transaction.

Mark the token used and change the password in one transaction, so two tabs submitting the same link cannot both succeed. End every other session for the user at the same time.

Say the same thing to everyone.

"If that address has an account, a reset link is on its way." Whether the account exists, the address is suppressed or the rate limit tripped, the words and the timing are the same.

Read the status, not just the response.

A suppressed address answers 200 with an id and no email. When a user says nothing arrived, look the id up: SUPPRESSED means a past bounce or complaint, and the fix is a different address, not a resend.

FAQ

Questions, answered.

Does Notix have a built-in password reset API?
No. Notix delivers the email and, for the code variant, generates and checks the code through the verification API. The reset token, its expiry, the reset page and the password change are your application's, because they have to touch your user table. This page gives you both halves.
Link or code: which should I use?
A link when users reset from any device and you want one tap; a code when the user is already on the reset screen in your app, or when the same flow has to work by SMS. The code variant is two API calls with expiry, attempt limits and rate limits already enforced; the link variant is one send plus a small token table you own.
How long should a reset link stay valid?
Thirty minutes is a good default, and never more than an hour. The link is a credential for the account until it is used or expires, and a mailbox is not a safe place to keep a credential. The code variant defaults to ten minutes and can be set between one and thirty.
What should the email say?
Who asked, what to do, how long the link works, and what happens if it was not them: "Someone asked to reset the password for this Acme account. Reset it here; the link works once and expires in 30 minutes. If that was not you, ignore this email; your password has not changed." Send a plain-text part with the same words, keep the subject to the point, and do not include the username or any part of the password.
The user says the email never arrived. What do I check?
Look the send up by the id you stored. A status of SUPPRESSED means a previous hard bounce or spam complaint took the address off your list, and Notix will not send to it until it is removed from the suppression list; ask the user for a different address or for a support check. A bounced status means the mailbox refused it this time. Delivered but unseen usually means the spam folder, which is a domain authentication question, not a code one.
What stops someone from requesting resets for addresses that are not theirs?
Three things on your side: rate limit the endpoint per address and per IP, give the same neutral reply whether or not the account exists, and treat the reset link as spent the moment it is used. Notix adds a fourth: the idempotency key guarantees one email per token even when a retry hits the send endpoint twice, and the verification API's risk scoring refuses disposable domains and suspicious IPs before a code exists.

Ship a reset flow that works once.

One send with an idempotency key, or two calls to the verification API, and the free plan's 5,000 emails a month to test with. No card.