Email OTP
Email OTP authenticates users with a 6-digit numeric code emailed to their inbox. The user enters the code back into your app to prove they own the address — no passwords, and no clickable link to open.
It is a sibling of Magic Links: both prove inbox ownership, but a numeric code is a better fit when:
- Email clients or corporate scanners mangle or sandbox links.
- The user reads the code on a phone but signs in on a desktop (cross-device).
- Your auth policy mandates a code rather than a clickable link.
- You want UI parity with SMS/phone OTP.
Email OTP is gated on your project having a verified sending domain (same gate as magic links). It sends over Zyphr's managed email pipeline on your verified domain — there is no third-party provider to configure. This differs from phone OTP, which requires your own Twilio (BYO-provider) credentials. Call GET /v1/auth/email-otp/available to check.
How It Works
Registration:
1. User enters email ──▶ Your App
2. Your app calls register/send ──▶ Zyphr API ──▶ Email with 6-digit code
3. User enters the code ──▶ Your App
4. Your app calls register/verify ──▶ Zyphr API ──▶ Creates user + returns tokens
Login (existing user):
1. User enters email ──▶ Your App
2. Your app calls login/send ──▶ Zyphr API ──▶ Email with 6-digit code
3. User enters the code ──▶ Your App
4. Your app calls login/verify ──▶ Zyphr API ──▶ Returns tokens (or mfa_required)
Codes are 6 digits, expire in 5 minutes, allow 5 verification attempts, and requesting a new code invalidates the previous one. Unlike magic links, registration and login are separate flows — register/* fails if the email already exists, and login/* fails if it does not.
Check Availability
curl https://api.zyphr.dev/v1/auth/email-otp/available \
-H "X-Application-Key: za_pub_xxxx"
{
"data": {
"available": true,
"message": "Email OTP authentication is available"
}
}
Send a Code
Use register/send for new users or login/send for existing users.
curl -X POST https://api.zyphr.dev/v1/auth/email-otp/login/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/email-otp/login/send', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'jane@example.com' }),
});
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Recipient's email address |
Response
{
"data": {
"message": "Verification code sent",
"expires_in": 300
}
}
Verify a Code
Use register/verify (creates the account, returns 201) or login/verify (existing user, returns tokens or mfa_required).
curl -X POST https://api.zyphr.dev/v1/auth/email-otp/login/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "email": "jane@example.com", "code": "123456" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/email-otp/login/verify', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: 'jane@example.com', code: '123456' }),
});
Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | The email the code was sent to |
code | string | Yes | The 6-digit code from the email |
name | string | No | (register/verify only) Display name for the new user |
metadata | object | No | (register/verify only) Arbitrary metadata to attach to the new user |
custom_claims | object | No | (login/verify only) Extra claims to embed in the issued JWT |
Response
{
"data": {
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"email_verified": true,
"name": null,
"status": "active",
"mfa_enabled": false,
"created_at": "2026-07-18T10:00:00Z"
},
"tokens": {
"access_token": "eyJhbGci...",
"refresh_token": "zrt_xxxx",
"expires_in": 3600,
"token_type": "Bearer"
}
}
}
When MFA is enabled for the user, login/verify returns a challenge instead of tokens:
{
"data": {
"mfa_required": true,
"user": { "id": "usr_abc123", "email": "jane@example.com" },
"mfa_challenge": {
"token": "mfa_xxxx",
"expires_at": "2026-07-18T10:05:00Z"
}
}
}
Complete the login by calling the MFA verification endpoint with the challenge token (see Multi-Factor Auth).
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | validation_error | Missing email or code |
| 400 | invalid_email | Email failed a basic format check |
| 400 | otp_expired | No active code, or the code expired (request a new one) |
| 400 | otp_invalid | Wrong code (attempt counted) |
| 400 | otp_max_attempts | 5 failed attempts — request a new code |
| 400 | email_already_registered | (register) An account already exists for this email |
| 400 | email_not_found | (login) No account exists for this email |
| 400 | user_inactive | Account is suspended or deleted |
| 429 | otp_send_rate_limit_exceeded | Too many send requests (3 / 15 min per email). See meta.retry_after. |
| 429 | otp_verify_rate_limit_exceeded | Too many verify attempts (10 / 15 min per email). See meta.retry_after. |
| 503 | email_not_configured | No verified sending domain on the project |
Customizing the Email
The code email uses a branded system-default template. Override its subject and body per application from Auth → Email Templates → Email OTP in the dashboard. Available Handlebars variables include {{application_name}}, {{otp_code}}, {{expires_minutes}}, {{user_email}}, {{user_name}}, and your project brand variables ({{brand_primary_color}}, {{brand_logo_url}}, etc.).
Endpoint Reference
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/auth/email-otp/available | App public key | Check availability |
POST | /v1/auth/email-otp/register/send | App credentials | Send code for registration |
POST | /v1/auth/email-otp/register/verify | App credentials | Verify code, create user, return tokens |
POST | /v1/auth/email-otp/login/send | App credentials | Send code for login |
POST | /v1/auth/email-otp/login/verify | App credentials | Verify code, return tokens (or MFA challenge) |