Never see the code.
Notix generates the code, delivers it and checks it; your server only ever holds the verification id. Log the id, never the code, and there is no secret in your logs to leak.
To send an OTP by email from your app, call POST /v1/verify/send with the address and your app name, keep the id it returns, then call POST /v1/verify/check with that id and whatever the user typed. Notix generates the code, delivers it through the transactional email API, enforces expiry and attempt limits, rate limits each recipient and scores every request for risk. The same two calls deliver by SMS. The free plan covers it, no card needed.
Your app never handles the secret. It handles an id, a form field and a yes-or-no answer.
One request with the recipient, your app name and, if you have it, the end user’s IP address for risk scoring. Notix answers with a verification id, when the code expires, how many wrong guesses are left and the risk result. Store the id against the user’s session.
The email carries the digits and how long they stay valid, ten minutes by default. Your form collects the code; nothing else changes on your side.
Post the id and the typed code. verified: true means proceed. Otherwise reason says why: wrong_code with the attempts left, expired (send a new one), already_used, too_many_attempts after the fifth wrong guess, or refused when risk scoring declined the send.
curl, or the TypeScript and Python SDKs. Every field but to has a default; the ones shown are the ones you are most likely to set.
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",
"codeLength": 6,
"expiresIn": 600,
"clientIp": "203.0.113.10"
}'
# 201 { "id": "ver_abc123", "status": "pending", "expiresAt": "...",
# "attemptsRemaining": 5, "risk": { "score": 0, "level": "LOW", "reasons": [] } }
import { Notix } from "notix-js";
const notix = new Notix(process.env.NOTIX_API_KEY);
const { data, error } = await notix.verify.send({
to: "user@example.com",
appName: "Acme",
codeLength: 6,
expiresIn: 600,
clientIp: request.ip,
});
if (error) {
// 422 RISK_REFUSED carries error.risk.reasons; 429 means slow down.
return respond(error);
}
// Keep data.id next to the user's session; the code itself never reaches you.
session.verificationId = data.id;
import os
from notix import Notix
notix = Notix(os.environ["NOTIX_API_KEY"])
data, error = notix.verify.send(
{
"to": "user@example.com",
"appName": "Acme",
"codeLength": 6,
"expiresIn": 600,
"clientIp": request.remote_addr,
}
)
if error:
# 422 RISK_REFUSED carries error["risk"]["reasons"]; 429 means slow down.
return respond(error)
session["verification_id"] = data["id"]
Same id, the code the user typed, one boolean back. A failed check tells you the reason, so the form can say the right thing.
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 { "id": "ver_abc123", "verified": true, "status": "verified", ... }
# or { "verified": false, "reason": "wrong_code", "attemptsRemaining": 4, ... }
const { data, error } = await notix.verify.check({
id: session.verificationId,
code: form.code,
});
if (error) return respond(error);
if (data.verified) {
session.emailVerified = true;
} else {
// "wrong_code", "expired", "already_used", "too_many_attempts" or "refused"
showError(data.reason, data.attemptsRemaining);
}
data, error = notix.verify.check(
{"id": session["verification_id"], "code": form["code"]}
)
if error:
return respond(error)
if data["verified"]:
session["email_verified"] = True
else:
# "wrong_code", "expired", "already_used", "too_many_attempts" or "refused"
show_error(data["reason"], data["attemptsRemaining"])
These are the documented defaults and bounds. The two you set per request are code length and expiry; the rest are the same for every verification.
| Limit | Value | Notes |
|---|---|---|
| Code length | 4 to 8 digits, default 6 | codeLength on the send request. |
| Expiry | 60 to 1,800 seconds, default 600 | expiresIn on the send request; expiresAt comes back in the response. |
| Wrong guesses | 5 per code | Only a wrong code costs an attempt. The sixth wrong guess locks the code; attemptsRemaining is on every response. |
| Sends per recipient | 5 in 10 minutes, 30 seconds apart | More than that answers 429 RATE_LIMITED. A new send replaces the recipient's earlier pending code. |
| Risk scoring | Every send, before anything goes out | A refused request answers 422 RISK_REFUSED with the reasons, and no code exists. |
| SMS channel | channel: "sms" with appName | E.164 number, prepaid from your wallet, your own sender ID once approved. |
The full request and response shapes, including the risk object, are in the docs.
Notix generates the code, delivers it and checks it; your server only ever holds the verification id. Log the id, never the code, and there is no secret in your logs to leak.
Ten minutes is the default because a code that lives longer is a code that can be found later. A magic link or a session token is the place for a longer life, not the OTP.
Notix caps sends per recipient. Add a cap per IP address and per account on your own endpoint, so a bot cannot burn a stranger's five sends by asking you to send them.
A disposable domain or a suspicious IP is refused before a code exists. Ask for a different address, or route the user to a slower path; do not retry the same request in a loop.
The same two calls deliver by SMS with channel set to sms, an E.164 number and your appName. Nigeria and Kenya are routed and billed from your wallet; a sender ID of your own follows once approved.
The built-in template, with an app name of Acme, a six-digit code and the default ten-minute expiry. By SMS, the same facts in one line.
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.
Acme: your code is 482913. It expires in 10 minutes.
Sent from the shared Notix sender ID, or your own once the networks approve it.
OTPs, receipts and alerts with idempotency keys, a deliverability pre-check and a shared suppression list.
Use casesA token link flow and an OTP variant, the template copy, and the limits and suppression rules that apply to resets.
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.
Two endpoints, one id in your session, and the free plan's 5,000 emails a month to test with. No card.