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 from_email and jwt_expires_in_seconds. 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:
{
"from_email": "noreply@yourapp.com", // optional
"jwt_expires_in_seconds": 300 // optional, default 300
}
Headers:
Authorization: Bearer <self-auth JWT>
200 → {
"tenant_id": "...",
"public_key_pem": "-----BEGIN PUBLIC KEY-----\n...",
"from_email": "...",
"jwt_expires_in_seconds": 300
}
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.
# 1. Get a self-tenant JWT
curl -X POST https://magiclink.effortlessapi.com/auth/send-code \
-d '{"email":"you@yourdomain.com"}' -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'
# → { "ok": true, "jwt": "<self-auth JWT>", "expires_in": 300 }
# 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 |