Skip to main content

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.
Requirements

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 flowsregister/* 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" }'
Node.js
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

ParameterTypeRequiredDescription
emailstringYesRecipient'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" }'
Node.js
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

ParameterTypeRequiredDescription
emailstringYesThe email the code was sent to
codestringYesThe 6-digit code from the email
namestringNo(register/verify only) Display name for the new user
metadataobjectNo(register/verify only) Arbitrary metadata to attach to the new user
custom_claimsobjectNo(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

StatusCodeCause
400validation_errorMissing email or code
400invalid_emailEmail failed a basic format check
400otp_expiredNo active code, or the code expired (request a new one)
400otp_invalidWrong code (attempt counted)
400otp_max_attempts5 failed attempts — request a new code
400email_already_registered(register) An account already exists for this email
400email_not_found(login) No account exists for this email
400user_inactiveAccount is suspended or deleted
429otp_send_rate_limit_exceededToo many send requests (3 / 15 min per email). See meta.retry_after.
429otp_verify_rate_limit_exceededToo many verify attempts (10 / 15 min per email). See meta.retry_after.
503email_not_configuredNo 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

MethodEndpointAuthDescription
GET/v1/auth/email-otp/availableApp public keyCheck availability
POST/v1/auth/email-otp/register/sendApp credentialsSend code for registration
POST/v1/auth/email-otp/register/verifyApp credentialsVerify code, create user, return tokens
POST/v1/auth/email-otp/login/sendApp credentialsSend code for login
POST/v1/auth/email-otp/login/verifyApp credentialsVerify code, return tokens (or MFA challenge)