Security Features
Advanced security features for your Auth-as-a-Service applications, including password policies, email verification, password reset, account lockouts, user impersonation, phone authentication, and WebAuthn/passkeys.
Email Verification
Verify that users own the email addresses they registered with.
Send Verification Email
Requires the user's access token:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirect_url": "https://myapp.com/verify-email" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/send', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
redirect_url: 'https://myapp.com/verify-email',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
redirect_url | string | No | URL to redirect to after clicking the link. Must match a configured redirect_uri. |
| Status | Code | Cause |
|---|---|---|
| 200 | — | Verification email sent |
| 400 | already_verified | Email is already verified |
| 400 | validation_error | Redirect URL not in allowed list |
Confirm Verification
When the user clicks the verification link, extract the token and confirm:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/confirm \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "evt_xxxx" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/confirm', {
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({ token: verificationToken }),
});
// data.email — the verified email address
Resend Verification
Rate-limited to once per 60 seconds:
curl -X POST https://api.zyphr.dev/v1/auth/verify-email/resend \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "redirect_url": "https://myapp.com/verify-email" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/verify-email/resend', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
redirect_url: 'https://myapp.com/verify-email',
}),
});
| Status | Code | Cause |
|---|---|---|
| 200 | — | Verification email resent |
| 400 | already_verified | Email is already verified |
| 429 | rate_limited | Must wait before resending |
Password Reset
Three-step flow: request reset, validate token (optional), confirm reset.
Request Password Reset
curl -X POST https://api.zyphr.dev/v1/auth/forgot-password \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"email": "jane@example.com",
"redirect_url": "https://myapp.com/reset-password"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/forgot-password', {
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',
redirect_url: 'https://myapp.com/reset-password',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
email | string | Yes | User's email address |
redirect_url | string | No | Must match a configured redirect_uri |
Always returns success to prevent email enumeration, regardless of whether the email exists.
Validate Reset Token
Check if a token is valid before showing the reset form. Does not consume the token:
curl -X POST https://api.zyphr.dev/v1/auth/validate-reset-token \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "prt_xxxx" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/validate-reset-token', {
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({ token: resetToken }),
});
// data.valid — true if token is valid
// data.email — the email associated with the token
Confirm Password Reset
curl -X POST https://api.zyphr.dev/v1/auth/reset-password \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"token": "prt_xxxx",
"new_password": "NewSecureP@ss456"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/reset-password', {
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({
token: resetToken,
new_password: 'NewSecureP@ss456',
}),
});
| Parameter | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Reset token from the email |
new_password | string | Yes | Must meet the application's password requirements |
| Status | Code | Cause |
|---|---|---|
| 200 | — | Password reset successfully |
| 400 | invalid_token | Token is invalid or expired |
| 400 | password_validation_error | New password doesn't meet requirements (includes errors and requirements) |
Account Lockouts
Automatic account lockout after too many failed login attempts. Managed through the dashboard API (JWT auth).
List Locked Accounts
curl "https://api.zyphr.dev/v1/applications/app_abc123/lockouts?limit=50&offset=0" \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/lockouts?limit=50&offset=0',
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
// data.accounts — array of locked accounts
// data.total — total count
Get Lockout Details
Get the full lockout status, history, and recent failed attempts for a specific email:
curl https://api.zyphr.dev/v1/applications/app_abc123/lockouts/jane@example.com \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/lockouts/${encodeURIComponent(email)}`,
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Response
{
"data": {
"email": "jane@example.com",
"current_status": {
"locked": true,
"locked_until": "2025-01-15T11:00:00Z",
"attempt_count": 5
},
"lockout_history": [
{
"id": "lock_123",
"locked_at": "2025-01-15T10:30:00Z",
"locked_until": "2025-01-15T11:00:00Z",
"attempt_count": 5,
"unlocked_at": null,
"unlocked_by": null,
"unlock_reason": null
}
],
"recent_failed_attempts": [
{
"id": "att_456",
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0...",
"failure_reason": "invalid_password",
"created_at": "2025-01-15T10:29:00Z"
}
]
}
}
Unlock Account
Manually unlock a locked account:
curl -X POST https://api.zyphr.dev/v1/applications/app_abc123/lockouts/jane@example.com/unlock \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/lockouts/${encodeURIComponent(email)}/unlock`,
{
method: 'POST',
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
User Impersonation
Allow account admins to impersonate end users for debugging and support. All impersonation sessions are fully audited — every action is logged with the admin's identity.
Impersonation tokens are access-only (no refresh token) and expire after a configurable duration (1–60 minutes, default 15). Requires owner or admin role.
Start Impersonation
curl -X POST https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonate \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT" \
-H "Content-Type: application/json" \
-d '{
"reason": "Investigating billing display issue reported in ticket #1234",
"duration_minutes": 15
}'
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonate',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${dashboardToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: 'Investigating billing display issue reported in ticket #1234',
duration_minutes: 15,
}),
}
);
| Parameter | Type | Required | Description |
|---|---|---|---|
reason | string | No | Audit trail reason for impersonation |
duration_minutes | number | No | Session duration (1–60 min, default 15) |
Response
{
"data": {
"tokens": {
"access_token": "eyJhbGci...",
"expires_in": 900,
"token_type": "Bearer"
},
"session": {
"id": "imp_abc123",
"admin_email": "admin@company.com",
"end_user_email": "jane@example.com",
"reason": "Investigating billing display issue",
"started_at": "2025-01-15T10:00:00Z",
"expires_at": "2025-01-15T10:15:00Z"
},
"user": {
"id": "usr_xyz",
"email": "jane@example.com",
"name": "Jane Doe"
}
},
"meta": {
"warning": "This is an impersonation token. All actions taken will be logged. No refresh token is provided - you must re-authenticate after expiration."
}
}
End Impersonation
Manually end an impersonation session before it expires:
curl -X DELETE https://api.zyphr.dev/v1/applications/app_abc123/impersonation/imp_abc123 \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/impersonation/imp_abc123',
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
List Active Impersonations
curl https://api.zyphr.dev/v1/applications/app_abc123/impersonation \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
'https://api.zyphr.dev/v1/applications/app_abc123/impersonation',
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Get Impersonation History
View the full audit trail for a specific user:
curl "https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonation-history?limit=50&offset=0" \
-H "Authorization: Bearer YOUR_DASHBOARD_JWT"
const response = await fetch(
`https://api.zyphr.dev/v1/applications/app_abc123/users/usr_xyz/impersonation-history?limit=50`,
{
headers: { 'Authorization': `Bearer ${dashboardToken}` },
}
);
Returns sessions with admin_email, reason, ip_address, user_agent, started_at, ended_at, and end_reason.
Phone Authentication
SMS OTP-based registration and login. Requires BYOP (Bring Your Own Provider) Twilio credentials configured in account settings.
Check Availability
curl https://api.zyphr.dev/v1/auth/phone/available \
-H "X-Application-Key: za_pub_xxxx"
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/available', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
},
});
// data.available — true if SMS is configured
Registration Flow
Step 1: Send OTP
curl -X POST https://api.zyphr.dev/v1/auth/phone/register/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+15551234567" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/register/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({ phone_number: '+15551234567' }),
});
// data.expires_in — OTP expiration in seconds
Step 2: Verify OTP and create account
curl -X POST https://api.zyphr.dev/v1/auth/phone/register/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+15551234567",
"code": "123456",
"name": "Jane Doe",
"metadata": { "source": "mobile-app" }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/register/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({
phone_number: '+15551234567',
code: '123456',
name: 'Jane Doe',
metadata: { source: 'mobile-app' },
}),
});
// Returns user + tokens (same as email registration)
| Parameter | Type | Required | Description |
|---|---|---|---|
phone_number | string | Yes | E.164 format (e.g., +15551234567) |
code | string | Yes | OTP code from SMS |
name | string | No | Display name |
metadata | object | No | Arbitrary user metadata |
Login Flow
Step 1: Send OTP
curl -X POST https://api.zyphr.dev/v1/auth/phone/login/send \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "phone_number": "+15551234567" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/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({ phone_number: '+15551234567' }),
});
Step 2: Verify OTP and login
curl -X POST https://api.zyphr.dev/v1/auth/phone/login/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"phone_number": "+15551234567",
"code": "123456"
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/phone/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({
phone_number: '+15551234567',
code: '123456',
}),
});
Phone login also supports custom_claims and returns MFA challenges if MFA is enabled — same as email login.
Error Responses
| Status | Code | Cause |
|---|---|---|
| 400 | phone_not_found | No account with this phone number (login only) |
| 400 | invalid_otp | Incorrect or expired OTP code |
| 429 | sms_rate_limit | Too many SMS requests. Includes retry_after in meta. |
| 503 | sms_not_configured | Twilio credentials not configured |
WebAuthn / Passkeys
FIDO2/WebAuthn support for passwordless authentication using biometrics, hardware keys, or platform authenticators. WebAuthn provides phishing-resistant authentication with no shared secrets.
Registration Flow
Users must be authenticated to register a new credential. The flow uses the Web Authentication API (navigator.credentials.create()).
Step 1: Get registration options
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/registration/start \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "credential_name": "MacBook Pro Touch ID" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/registration/start', {
method: 'POST',
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'X-Application-Secret': process.env.ZYPHR_APP_SECRET_KEY,
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ credential_name: 'MacBook Pro Touch ID' }),
});
// data.challenge_id — needed for verification step
// data.options — pass to navigator.credentials.create()
Step 2: Create credential in browser and verify
// Use the options from Step 1
const credential = await navigator.credentials.create({
publicKey: data.options,
});
// Send the response to your backend for verification
const verifyResponse = await fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge_id: data.challenge_id,
response: {
id: credential.id,
rawId: btoa(String.fromCharCode(...new Uint8Array(credential.rawId))),
type: credential.type,
response: {
attestationObject: btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject))),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON))),
},
},
}),
});
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/registration/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "wac_xxxx",
"response": { "...WebAuthn response object..." },
"credential_name": "MacBook Pro Touch ID"
}'
Authentication Flow
Step 1: Get authentication options
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/authentication/start \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "user_id": "usr_abc123" }'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/authentication/start', {
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({
user_id: userId, // optional — omit for discoverable credential flow
}),
});
// data.challenge_id + data.options — pass options to navigator.credentials.get()
The user_id parameter is optional. Omit it for discoverable credential (passkey) flows where the authenticator identifies the user.
Step 2: Authenticate and verify
curl -X POST https://api.zyphr.dev/v1/auth/webauthn/authentication/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{
"challenge_id": "wac_xxxx",
"response": { "...WebAuthn assertion response..." }
}'
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/authentication/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({
challenge_id: challengeId,
response: webauthnResponse,
}),
});
// Returns user + tokens (same as email login)
// Also supports MFA challenge if MFA is enabled
Authentication verify also supports custom_claims for embedding data in the JWT.
Credential Management
List credentials:
curl https://api.zyphr.dev/v1/auth/webauthn/credentials \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
const response = await fetch('https://api.zyphr.dev/v1/auth/webauthn/credentials', {
headers: {
'X-Application-Key': process.env.ZYPHR_APP_PUBLIC_KEY,
'Authorization': `Bearer ${accessToken}`,
},
});
// data.credentials — array of { id, name, transports, is_discoverable, is_backed_up, last_used_at, created_at }
Rename credential:
curl -X PATCH https://api.zyphr.dev/v1/auth/webauthn/credentials/cred_123 \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Work Laptop" }'
Delete credential:
curl -X DELETE https://api.zyphr.dev/v1/auth/webauthn/credentials/cred_123 \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
Check WebAuthn status:
curl https://api.zyphr.dev/v1/auth/webauthn/status \
-H "X-Application-Key: za_pub_xxxx" \
-H "Authorization: Bearer ACCESS_TOKEN"
{
"data": {
"enabled": true,
"credential_count": 2
}
}
Endpoint Reference
Email Verification
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/verify-email/send | App credentials + user token | Send verification email |
POST | /v1/auth/verify-email/confirm | App credentials | Confirm with token |
POST | /v1/auth/verify-email/resend | App credentials + user token | Resend verification |
Password Reset
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/forgot-password | App credentials | Request reset email |
POST | /v1/auth/validate-reset-token | App credentials | Validate token |
POST | /v1/auth/reset-password | App credentials | Confirm password reset |
Account Lockouts
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/applications/:id/lockouts | Dashboard JWT | List locked accounts |
GET | /v1/applications/:id/lockouts/:email | Dashboard JWT | Get lockout details |
POST | /v1/applications/:id/lockouts/:email/unlock | Dashboard JWT | Unlock account |
User Impersonation
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/applications/:id/users/:userId/impersonate | Dashboard JWT (owner/admin) | Start impersonation |
DELETE | /v1/applications/:id/impersonation/:impersonationId | Dashboard JWT (owner/admin) | End impersonation |
GET | /v1/applications/:id/impersonation | Dashboard JWT (owner/admin) | List active impersonations |
GET | /v1/applications/:id/users/:userId/impersonation-history | Dashboard JWT (owner/admin) | Get audit history |
Phone Authentication
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/auth/phone/available | Public key | Check SMS availability |
POST | /v1/auth/phone/register/send | App credentials | Send registration OTP |
POST | /v1/auth/phone/register/verify | App credentials | Verify OTP + create account |
POST | /v1/auth/phone/login/send | App credentials | Send login OTP |
POST | /v1/auth/phone/login/verify | App credentials | Verify OTP + login |
WebAuthn / Passkeys
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /v1/auth/webauthn/registration/start | App credentials + user token | Start credential registration |
POST | /v1/auth/webauthn/registration/verify | App credentials + user token | Complete registration |
POST | /v1/auth/webauthn/authentication/start | App credentials | Start authentication |
POST | /v1/auth/webauthn/authentication/verify | App credentials | Complete authentication |
GET | /v1/auth/webauthn/credentials | Public key + user token | List credentials |
DELETE | /v1/auth/webauthn/credentials/:id | Public key + user token | Delete credential |
PATCH | /v1/auth/webauthn/credentials/:id | Public key + user token | Rename credential |
GET | /v1/auth/webauthn/status | Public key + user token | Check WebAuthn status |
Customizing Auth Emails
The magic link, password reset, and email verification emails can be fully customized per application. By default, Zyphr ships system templates that interpolate your project brand (primary/secondary color, font family, logo URL) so a tenant who only sets brand defaults gets a branded experience without authoring full overrides.
For full control — custom HTML/MJML, subject, From display name, Reply-To — use the Auth Email Templates API.
Hierarchy
Project (brand defaults)
└─ Application (per-application template overrides)
└─ email_type ∈ { magic_link, password_reset, email_verification }
When an email is sent:
- The application's override row for the email type is loaded if present.
- If
is_enabled = falseor no override exists, the system default is used. - In either case, the project's
brand_*settings are merged into the Handlebars context as{{brand_primary_color}},{{brand_secondary_color}},{{brand_font_family}},{{brand_logo_url}}.
Variable contracts
| Email type | Variables |
|---|---|
magic_link | application_name, magic_link_url, expires_minutes, user_email, user_name, brand_* |
password_reset | application_name, reset_url, expires_minutes, user_email, user_name, brand_* |
email_verification | application_name, verify_url, expires_hours, user_email, user_name, brand_* |
Templates reference these as Handlebars expressions ({{variable_name}}). Unknown variables log a warning on save and render as empty strings.
API
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET | /v1/auth/email-templates | App credentials | List effective templates per type (override or default) |
GET | /v1/auth/email-templates/{type} | App credentials | Get a single template |
GET | /v1/auth/email-templates/{type}/default | App credentials | Fetch the un-rendered system default for forking |
PUT | /v1/auth/email-templates/{type} | App credentials | Create or update an override |
PUT | /v1/auth/email-templates | App credentials | Bulk-upsert any subset of types (atomic) |
DELETE | /v1/auth/email-templates/{type} | App credentials | Remove the override and revert to default |
POST | /v1/auth/email-templates/{type}/preview | App credentials | Render with sample/provided variables (no send) |
POST | /v1/auth/email-templates/{type}/test | App credentials | Send a test render (recipient must be in the app's test_recipients allowlist) |
GET | /v1/auth/email-templates/{type}/versions | App credentials | List version history |
POST | /v1/auth/email-templates/{type}/versions/{version}/restore | App credentials | Restore a previous version |
Dashboard JWT-authenticated equivalents live under /v1/auth/dashboard/applications/:id/email-templates/... for use by your own dashboard UI.
Editing format
A template can be saved as either:
- Plain HTML + text (
html+textfields): Handlebars-templated. - MJML source + text (
mjml_source+textfields): MJML is compiled to HTML server-side on save. Compile errors return400 mjml_compile_failedwith a structureddetailsarray — useful for migration scripts to surface fixable issues to the user.
Test send allowlist
POST /v1/auth/email-templates/{type}/test will only deliver to addresses in the application's test_recipients array. Add allowed addresses by PATCH /v1/applications/:id with a test_recipients field. Empty allowlist (the default) blocks all test sends via the public API. The dashboard JWT flow additionally allows the authenticated user's own email.
Project brand
Brand defaults live on the project (not the application — multiple apps in one project share branding) and are settable via the dashboard's Project Settings → Brand tab or the API:
| Method | Endpoint | Auth |
|---|---|---|
GET | /v1/projects/:id/brand | Dashboard JWT |
PATCH | /v1/projects/:id/brand | Dashboard JWT (account owner/admin or project admin) |
Color fields validate against ^#[0-9A-Fa-f]{6}$.