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.
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.
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.
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.
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.
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.
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.
# 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" }
import { createHash, randomBytes } from "node:crypto";
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
const RESET_TTL_MS = 30 * 60 * 1000;
export async function requestPasswordReset(email: string) {
const user = await db.user.findUnique({ where: { email } });
// Same reply either way; only the found account gets an email.
if (!user) return;
const token = randomBytes(32).toString("base64url");
const reset = await db.passwordReset.create({
data: {
userId: user.id,
tokenHash: createHash("sha256").update(token).digest("hex"),
expiresAt: new Date(Date.now() + RESET_TTL_MS),
},
});
const { data, error } = await notix.emails.send(
{
from: "Acme <no-reply@acme.com>",
to: user.email,
templateId: "tpl_password_reset",
variables: {
resetUrl: `https://acme.com/reset?token=${token}`,
expiresMinutes: "30",
},
},
{ idempotencyKey: `reset-${user.id}-${reset.id}` },
);
if (error) {
// Log it against the user; the request still returns the neutral reply.
logger.error({ userId: user.id, error }, "reset email failed");
return;
}
await db.passwordReset.update({ where: { id: reset.id }, data: { emailId: data.emailId } });
}
import hashlib
import os
import secrets
from datetime import datetime, timedelta, timezone
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
RESET_TTL = timedelta(minutes=30)
def request_password_reset(email: str) -> None:
user = db.users.find_by_email(email)
# Same reply either way; only the found account gets an email.
if user is None:
return
token = secrets.token_urlsafe(32)
reset = db.password_resets.create(
user_id=user.id,
token_hash=hashlib.sha256(token.encode()).hexdigest(),
expires_at=datetime.now(timezone.utc) + RESET_TTL,
)
data, error = notix.emails.send(
payload={
"from": "Acme <no-reply@acme.com>",
"to": user.email,
"templateId": "tpl_password_reset",
"variables": {
"resetUrl": f"https://acme.com/reset?token={token}",
"expiresMinutes": "30",
},
},
options={"idempotency_key": f"reset-{user.id}-{reset.id}"},
)
if error:
logger.error("reset email failed", user_id=user.id, error=error)
return
db.password_resets.update(reset.id, email_id=data["emailId"])
Find, spend and change in one transaction, then sign every other session out.
// 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.");
}
# POST /reset { token, newPassword }
def complete_password_reset(token: str, new_password: str) -> None:
token_hash = hashlib.sha256(token.encode()).hexdigest()
# One transaction: find a live token, spend it, change the password.
with db.transaction():
reset = db.password_resets.find_live(token_hash) # used_at IS NULL AND expires_at > now()
if reset is None:
raise ResetError("This link has expired or was already used.")
db.password_resets.mark_used(reset.id)
db.users.set_password(reset.user_id, hash_password(new_password))
# Every other session for this user ends here.
db.sessions.delete_for_user(reset.user_id)
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.
# 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 }
// Step 1: the user asks for a reset.
const { data, error } = await notix.verify.send({
to: user.email,
appName: "Acme",
clientIp: request.ip,
});
if (error) return neutralReply(); // 422 RISK_REFUSED or 429: same reply to the user
session.resetVerificationId = data.id;
// Step 2: the user types the code and a new password.
const check = await notix.verify.check({ id: session.resetVerificationId, code: form.code });
if (check.error || !check.data.verified) {
// "wrong_code" (with attemptsRemaining), "expired", "already_used", "too_many_attempts"
return showError(check.data?.reason);
}
await setPassword(user.id, form.newPassword);
delete session.resetVerificationId;
# Step 1: the user asks for a reset.
data, error = notix.verify.send(
{"to": user.email, "appName": "Acme", "clientIp": request.remote_addr}
)
if error:
return neutral_reply() # 422 RISK_REFUSED or 429: same reply to the user
session["reset_verification_id"] = data["id"]
# Step 2: the user types the code and a new password.
data, error = notix.verify.check(
{"id": session["reset_verification_id"], "code": form["code"]}
)
if error or not data["verified"]:
# "wrong_code" (with attemptsRemaining), "expired", "already_used", "too_many_attempts"
return show_error(data.get("reason") if data else None)
set_password(user.id, form["new_password"])
session.pop("reset_verification_id")
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.
| Rule | Enforced by | Notes |
|---|---|---|
| One answer whether or not the account exists | Your app | Reply "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 once | Your app | 32 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 quickly | Your app | Fifteen to sixty minutes. A reset link that works next week is a stolen mailbox waiting to happen. |
| Rate limit per account and per IP | Your app | Three 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 retry | Notix | Pass 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 sent | Notix | An 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 guesses | Notix | The 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.
Short, specific, and honest about what happens if it was not them. The plain-text part says the same thing without the link styling.
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.
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.
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.
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.
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.
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.
"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.
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.
OTPs, receipts and alerts with idempotency keys, a deliverability pre-check and a shared suppression list.
Use casesSend one-time codes by email or SMS with two API calls, with expiry, length and risk scoring handled for you.
GuidesThe SDK, plain fetch, or Nodemailer pointed at the relay: three ways to send from Node in a few minutes.
LearnWhat a pre-send deliverability check looks at, the verdict and score it returns, and the four findings that block a send.
NotixA free plan with no card, and Pro at $15 a month for 50,000 emails. Only sent volume is metered.
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.