Everything you need to run the full passwordless-auth loop with nothing but a tenant id and a public key. The endpoints below are open — no SDK, no login, no dashboard required.
If you already have a tenant_id and the tenant’s public_key_pem (from the dashboard or from GET /api/tenants/{id}), you can run the entire authentication loop without ever touching a UI.
curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/send-code \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com"}'
# → { "ok": true } (always — does not reveal whether email exists)
curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/verify-code \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","code":"123456"}'
# → { "ok": true, "jwt": "eyJ...", "expires_in": 300 }
const jwt = require('jsonwebtoken');
const decoded = jwt.verify(token, PUBLIC_KEY_PEM, {
algorithms: ['RS256'],
issuer: `https://magiclink.effortlessapi.com/${TENANT_ID}`,
});
// decoded.email is now a verified email — that's the entire claim.
That is the whole loop. No SDK, no callback URL, no session storage on our side. Anything you do after that — sessions, roles, tenant ownership — is yours to design.
A tenant is a UUID + an RSA keypair. The private key signs JWTs; the public key verifies them. Each tenant has its own sender (from_email, optionally its own SMTP server), Reply-To, email branding (label, subject, colours) and jwt_expires_in_seconds. See Tenant settings. Tenants are independent — JWTs from tenant A are not valid in tenant B.
PKCS#8 PEM (-----BEGIN PUBLIC KEY-----). Safe to embed in clients, commit to source, paste into Postgres. It is the only thing your verifier needs.
RS256, standard claims plus email and tenant_id:
{
"sub": "user@example.com",
"email": "user@example.com",
"tenant_id": "00000000-0000-0000-0000-000000000000",
"iss": "https://magiclink.effortlessapi.com/00000000-...",
"iat": 1714200000,
"nbf": 1714200000,
"exp": 1714200300
}
The iss claim is ${AUTH_BASE_URL}/${tenant_id} — verify it strictly.
Base URL: https://magiclink.effortlessapi.com. These four endpoints are all you need if you already have a tenant.
POST /api/tenants/{id}/send-codeIssue a 6-digit code and email it. Always returns {ok: true}, even for unknown tenants and missing emails — this prevents enumeration.
Body:
{
"email": "user@example.com",
"additional_claims": { "role": "admin" } // optional, see below
}
POST /api/tenants/{id}/verify-codeExchange a code for a JWT. Codes are single-use, 5-minute TTL, in-memory.
Body:
{
"email": "user@example.com",
"code": "123456",
"additional_claims": { "plan": "pro" } // optional — merges with send-time claims
}
200 → { "ok": true, "jwt": "...", "expires_in": 300 }
401 → { "ok": false, "error": "invalid_or_expired_token" }
GET /api/tenants/{id}Returns the public information for a tenant — including the public_key_pem you need to verify JWTs.
200 → {
"tenant_id": "00000000-...",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...",
"from_email": "noreply@magiclink.effortlessapi.com",
"jwt_expires_in_seconds": 300,
"created_at": "2026-04-27T00:00:00Z"
}
Both send-code and verify-code accept an additional_claims object. Whatever you pass gets baked verbatim into the issued JWT alongside the verified email.
Magic Links forms no opinion on these claims — it just signs them. This lets you carry role, app_user_id, plan, anything you need, without wrapping or re-signing the token.
Reserved claims are not overrideable: email, iss, iat, nbf, exp, sub, tenant_id.
POST /api/tenants/{id}/send-code
{ "email": "u@x.com", "additional_claims": { "role": "admin", "org_id": 42 } }
→ JWT payload includes: role: "admin", org_id: 42
Within a 60-second grace window after a JWT expires, you can refresh it without going through the email loop again. Send the recently-expired JWT as a Bearer token.
curl -X POST https://magiclink.effortlessapi.com/api/tenants/$TENANT_ID/refresh \
-H "Authorization: Bearer $EXPIRED_JWT" \
-H "Content-Type: application/json" \
-d '{"grace_period": 60}'
200 → { "ok": true, "jwt": "...", "expires_in": 300 }
401 → { "ok": false, "error": "expired_beyond_grace" }
All non-reserved claims (including any additional_claims) are carried forward into the new JWT.
You only need the tenant’s public_key_pem. The JWT is RS256 — every standard library can verify it.
const jwt = require('jsonwebtoken');
const TENANT_ID = process.env.TENANT_ID;
const PUBLIC_KEY = process.env.PUBLIC_KEY_PEM;
function verify(token) {
return jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: `https://magiclink.effortlessapi.com/${TENANT_ID}`,
});
}
import jwt # pyjwt
decoded = jwt.decode(
token,
PUBLIC_KEY,
algorithms=["RS256"],
issuer=f"https://magiclink.effortlessapi.com/{TENANT_ID}",
)
SELECT (extensions.verify(jwt, public_key, 'RS256')).valid;
Drop the public key in your database, expose auth.email(), and use it directly in policies. The DB enforces access — your app can have bugs without leaking rows.
CREATE TABLE auth.trusted_tenants (
tenant_id uuid PRIMARY KEY,
public_key_pem text NOT NULL
);
CREATE FUNCTION auth.email() RETURNS text LANGUAGE sql STABLE AS $$
SELECT current_setting('request.jwt.claim.email', true)
$$;
CREATE POLICY user_orders ON orders
USING (customer_email = auth.email());
Your gateway verifies the JWT against the matching public_key_pem and sets the claim on the connection (Postgrest, Hasura, or your own). Migrate to a different issuer later? Replace the row in auth.trusted_tenants. Policies don’t change.
| Error | Meaning |
|---|---|
invalid_or_expired_token | Code wrong, expired (5 min), already used, or tenant unknown |
expired_beyond_grace | JWT is older than the refresh grace window |
tenant_mismatch | Refresh attempted on a JWT issued by a different tenant |
tenant_not_found | Tenant id is unknown (only on management endpoints) |
invalid_tenant_id_format | Tenant id is not a valid UUID |
When the server runs with NODE_ENV not equal to production, the literal code 424242 (or whatever MAGIC_AUTH_DEBUG_CODE is set to) is accepted for any email on any tenant. Never enable in production.
Creating, updating, or deleting tenants requires a self-auth JWT — see Self-auth. Once you have a tenant id and public key, none of these endpoints are needed for day-to-day login traffic.
POST /api/tenantsServer generates the keypair. The private key never leaves the server.
Body:
{
"display_name": "Your App", // required
"project_name": "your-app", // required
"from_email": "noreply@yourapp.com", // optional
"jwt_expires_in_seconds": 300 // optional, default 300
// plus any field from Tenant settings below
}
Headers:
Authorization: Bearer <self-auth JWT>
200 → {
"tenant_id": "...",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...",
"from_email": "...",
"jwt_expires_in_seconds": 300
}
PATCH /api/tenants/{id}Send only the fields you want to change; omitted fields stay as they are, null clears one. The same fields are accepted on POST.
curl -X PATCH https://magiclink.effortlessapi.com/api/tenants/<tenant id> \
-H "Authorization: Bearer <self-auth JWT>" \
-H "Content-Type: application/json" \
-d '{
"from_email": "access@yourapp.com",
"email_label": "Your App",
"reply_to": "support@yourapp.com",
"smtp_config": { "host": "smtp.resend.com", "port": 587, "secure": false,
"username": "resend", "password": "re_..." }
}'
| Field | What it does |
|---|---|
from_email | Sender of the code email. A custom address needs smtp_config for a server allowed to send as it; on the platform server the sender is hello@effortlessapi.com. |
reply_to | Reply-To of the code email (an address; 400 if it is not one). Works with or without smtp_config. |
smtp_config | {host, port, secure, username, password}: send this tenant's mail through your own SMTP server or provider (Resend: smtp.resend.com, 587, user resend, password = API key; SendGrid: user apikey). The password is never returned. Microsoft 365 tenants with Security Defaults refuse password SMTP: use a transactional provider. |
display_name | The app's name, shown to people signing in. |
email_label | Name on the sender line (and {tenant} in the subject). Put the name here, a bare address in from_email. |
email_subject_template | Subject line; {code} and {tenant} are filled in. |
brand_color / gradient_to | Hex colours of the email's header. |
jwt_expires_in_seconds | Lifetime of the JWTs this tenant issues (default 300). |
login_url | Your app's login page, for sign-in links: https (plain http only for localhost). PATCH only. Read back with login_url_verified. |
The email's body is fixed: the code, styled with the fields above, plus a Sign in button once sign-in links are on. Custom templates are not supported.
Optional. The email keeps its code and gains a Sign in to Your App button (just Sign in if the tenant has no name): the person clicks it and is signed in to your app. Off until you do all three steps, so an app that does not handle links never receives one.
// 1. Your login page, on load. Run this from script, never on the server:
// mail scanners fetch links but do not run the page's code.
const q = new URLSearchParams(location.search);
const email = q.get('email'), token = q.get('ml');
if (token) {
history.replaceState(null, '', location.pathname); // keep the token out of history
const r = await fetch(`https://magiclink.effortlessapi.com/api/tenants/${TENANT_ID}/verify-link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, token })
});
const { jwt } = await r.json();
if (r.ok) signedIn(jwt); // the same JWT verify-code returns
else showCodeStep(email); // the code in the same email still works
}
// 2. PATCH /api/tenants/{id} { "login_url": "https://yourapp.com/login" }
// 3. POST /api/tenants/{id}/test-link, then click the link in the owner's inbox.
The test link signing someone in sets login_url_verified and real emails get the button. Changing login_url turns it off until a new test link works. A link lasts as long as the code (5 minutes) and works once. Using it leaves the code good until it expires; using the code spends the link. A link that fails shows no error: open the code step for that address.
The magic-links service is itself a magic-links tenant. To prove you own a tenant in the database, you authenticate with the same email-OTP loop against the self-tenant.
The easiest way is to sign in on the dashboard. The self-tenant's send-code is protected by a proof-of-work CAPTCHA: fetch a puzzle from GET /auth/challenge, find the number (0–maxnumber) where SHA-256(salt + number) equals challenge, and send base64 of {"salt","number","challenge","signature"} as captcha_token. Each solution works once. Sends to your own tenants never need this.
# 1. Get a self-tenant JWT
curl -X POST https://magiclink.effortlessapi.com/auth/send-code \
-d '{"email":"you@yourdomain.com","captcha_token":"<solved puzzle>"}' -H 'Content-Type: application/json'
curl -X POST https://magiclink.effortlessapi.com/auth/verify-code \
-d '{"email":"you@yourdomain.com","code":"123456"}' -H 'Content-Type: application/json'
# → { "token": "<self-auth JWT>", "expires_in": 300, "user": { ... } }
# 2. Use it to create a tenant
curl -X POST https://magiclink.effortlessapi.com/api/tenants \
-H "Authorization: Bearer <self-auth JWT>" \
-H "Content-Type: application/json" \
-d '{"from_email":"noreply@yourapp.com"}'
The self-tenant JWT carries an app_user_id claim — the server uses that to enforce tenant ownership on PATCH/DELETE. You can decode the JWT client-side to read it; it’s also returned by GET /me.
Running your own copy of magic-links? These are the env vars that matter.
| Var | Purpose | Default |
|---|---|---|
PORT | HTTP port | 3131 |
NODE_ENV | production disables the debug code | unset |
AUTH_BASE_URL | Used as the JWT iss prefix | http://localhost:3131 |
POSTGRES_URL | Tenant store | — |
SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_FROM | Nodemailer transport | Gmail SMTP |
MAGIC_AUTH_DEBUG_CODE | Universal bypass code in non-prod | 424242 |