Skip to main content

Magic Links

Magic links provide passwordless authentication — users click a link in their email to log in. No passwords to remember, no credentials to manage.

How It Works

1. User enters email        ──▶  Your App
2. Your app calls send ──▶ Zyphr API ──▶ Email with magic link
3. User clicks link ──▶ Your App receives token from URL
4. Your app calls verify ──▶ Zyphr API ──▶ Returns user + tokens

If the email doesn't match an existing user, Zyphr automatically creates a new account during verification. Magic links work as both login and registration.

curl -X POST https://api.zyphr.dev/v1/auth/magic-link/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",
"redirect_url": "https://myapp.com/auth/magic-link"
}'
Node.js
const response = await fetch('https://api.zyphr.dev/v1/auth/magic-link/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',
redirect_url: 'https://myapp.com/auth/magic-link',
}),
});

Parameters

ParameterTypeRequiredDescription
emailstringYesRecipient's email address
redirect_urlstringNoURL to redirect to after clicking the link. The magic link token is appended as a query parameter.

Response

{
"data": {
"success": true,
"message": "Magic link sent"
}
}
info

The response always indicates success, even if the email doesn't match an existing user. This prevents email enumeration attacks.

When the user clicks the magic link, they're redirected to your redirect_url with a token. Extract the token and verify it:

curl -X POST https://api.zyphr.dev/v1/auth/magic-link/verify \
-H "X-Application-Key: za_pub_xxxx" \
-H "X-Application-Secret: za_sec_xxxx" \
-H "Content-Type: application/json" \
-d '{ "token": "mlk_xxxx" }'
Node.js
const response = await fetch('https://api.zyphr.dev/v1/auth/magic-link/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({
token: magicLinkToken,
}),
});

Parameters

ParameterTypeRequiredDescription
tokenstringYesThe magic link token from the URL

Response

{
"data": {
"user": {
"id": "usr_abc123",
"email": "jane@example.com",
"name": null,
"email_verified": true,
"created_at": "2025-01-15T10:00:00Z"
},
"tokens": {
"access_token": "eyJhbGci...",
"refresh_token": "zrt_xxxx",
"expires_in": 3600,
"token_type": "Bearer"
},
"is_new_user": true
}
}

The is_new_user field indicates whether the user was created during this verification. Use this to redirect new users to an onboarding flow.

Error Responses

StatusCodeCause
400validation_errorMissing or empty token
401invalid_tokenToken is invalid, expired, or already used

Complete Frontend Implementation

Here's a full implementation showing both the send and verify flows:

Send magic link (from your login page)
async function sendMagicLink(email) {
const response = await fetch('/api/auth/magic-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});

if (response.ok) {
// Show "check your email" message
showMessage('Check your email for a magic link!');
}
}
Your backend — POST /api/auth/magic-link
app.post('/api/auth/magic-link', async (req, res) => {
const { email } = req.body;

await fetch('https://api.zyphr.dev/v1/auth/magic-link/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,
redirect_url: `${process.env.APP_URL}/auth/magic-link`,
}),
});

res.json({ success: true });
});
Magic link callback page — /auth/magic-link?token=mlk_xxxx
// Extract token from URL and verify
const params = new URLSearchParams(window.location.search);
const token = params.get('token');

if (token) {
const response = await fetch('/api/auth/magic-link/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});

const { data } = await response.json();

// Store tokens
localStorage.setItem('access_token', data.tokens.access_token);
localStorage.setItem('refresh_token', data.tokens.refresh_token);

// Redirect based on user status
if (data.is_new_user) {
window.location.href = '/onboarding';
} else {
window.location.href = '/dashboard';
}
}
Your backend — POST /api/auth/magic-link/verify
app.post('/api/auth/magic-link/verify', async (req, res) => {
const { token } = req.body;

const response = await fetch('https://api.zyphr.dev/v1/auth/magic-link/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({ token }),
});

const result = await response.json();
res.json(result);
});

Endpoint Reference

MethodEndpointAuthDescription
POST/v1/auth/magic-link/sendApp credentialsSend magic link email
POST/v1/auth/magic-link/verifyApp credentialsVerify token and get auth tokens