API Documentation
Everything you need to integrate passwordless email authentication with Signonix.
Overview
Signonix provides passwordless authentication via email verification. No passwords to store, no OAuth complexity to manage. Redirect your users to Signonix, they verify their email, and you receive a cryptographically verified identity in return.
Base URL: https://signonix.com
All API responses are JSON with the shape { "ok": true|false, ... }
Authentication for client endpoints uses HTTP Basic Auth:
Authorization: Basic base64(client_id:api_key)
Integration Flow
After verification, Signonix redirects the user back to your return_to URL with data in the URL hash fragment (not query params):
https://yourapp.com/callback#signonix=TICKET&static_id=USER_ID&state=STATE
The hash contains:
| Key | Description |
|---|---|
| signonix | A one-time ticket for verified exchange. |
| static_id | A stable, deterministic user identifier. Same email + same app = same ID, every time. |
| state | Your CSRF token, passed through unchanged. |
How It Works
- Redirect the user to the Signonix login page with your
client_idandreturn_toURL. - The user verifies their email address (magic link or code entry).
- If first time, the user sees a consent screen to authorize your app.
- Signonix redirects back to your
return_toURL with the ticket andstatic_idin the URL hash.
State (CSRF) Validation
To prevent CSRF and replay attacks, validate the state parameter on every callback:
- Generate a random
statevalue before redirecting to Signonix. - Store it in
sessionStorage. - On callback, compare the returned
stateto the stored value. - Reject if they don't match.
// Before redirect
const state = crypto.randomUUID();
sessionStorage.setItem('signonix_state', state);
const loginUrl = `https://signonix.com/login.html?client_id=YOUR_APP&return_to=${encodeURIComponent(callbackUrl)}&state=${state}`;
// On callback
const params = new URLSearchParams(location.hash.slice(1));
const returnedState = params.get('state');
const savedState = sessionStorage.getItem('signonix_state');
sessionStorage.removeItem('signonix_state');
if (returnedState !== savedState) {
throw new Error('CSRF state mismatch — possible replay attack');
}
Integration Tiers
Signonix supports three levels of integration. Choose the one that fits your security needs.
Frontend-Only (Zero Backend)
Read static_id directly from the URL hash. No API call needed.
const params = new URLSearchParams(location.hash.slice(1));
const userId = params.get('static_id');
localStorage.setItem('user', userId);
// Same user gets the same ID every time
Trade-off: the static_id could be spoofed by modifying the URL. Fine for personalization, preferences, and non-sensitive state.
Verified Frontend (Recommended)
Exchange the ticket via a frontend-safe API call. No backend or API key needed.
const params = new URLSearchParams(location.hash.slice(1));
const ticket = params.get('signonix');
const res = await fetch(
`https://signonix.com/auth/verify-ticket?ticket=${ticket}&client_id=YOUR_CLIENT_ID`
);
const { ok, static_id } = await res.json();
// Cryptographically verified — cannot be spoofed
Backend (Get Email)
Exchange the ticket server-side with your API key to also receive the user's email address.
POST https://signonix.com/auth/redeem
Authorization: Basic base64(client_id:api_key)
Content-Type: application/json
{ "ticket": "TICKET_TOKEN" }
Response: { "ok": true, "static_id": "sx_...", "email": "user@example.com" }
Ticket Rules
TTL: Tickets expire 60 seconds after issuance.
Single-use: Both /auth/verify-ticket and /auth/redeem consume the ticket on first exchange. Subsequent calls return already_used / already_redeemed.
Origin-bound: Tickets are bound to the return_to origin at issuance. The verify-ticket endpoint checks the Origin header (or Referer fallback) against the stored origin and rejects mismatches.
Rate limits: IP-based and email-based rate limits are enforced. Excessive requests return rate_limited (429).
Login Page
Redirect users here to begin authentication. After the user verifies their email, they are redirected back to your return_to URL with a ticket and static_id in the URL hash fragment.
Query Parameters
| Parameter | Description |
|---|---|
| client_id required | Your application's client ID. |
| return_to required | The URL to redirect the user back to after verification. |
| state optional | An opaque value passed through to the callback for CSRF protection. |
Example
https://signonix.com/login.html?client_id=example_app&return_to=https://myapp.com/callback&state=random_csrf_token
After verification the user lands on:
https://myapp.com/callback#signonix=abc123...&static_id=sx_def456...&state=random_csrf_token
Verify Ticket
Frontend-safe endpoint. Exchange a one-time ticket for the user's static_id. No API key required. Does not return the user's email address -- use /auth/redeem for that.
POST is preferred — tickets are credentials and should not appear in URLs or server logs.
POST (preferred)
POST /auth/verify-ticket
Content-Type: application/json
{ "ticket": "...", "client_id": "..." }
GET (alternative)
| Parameter | Description |
|---|---|
| ticket required | The one-time ticket from the URL hash. |
| client_id required | Your application's client ID. |
Response 200
{
"ok": true,
"static_id": "sx_abc123def456..."
}
Response 400
{
"ok": false,
"error": "invalid_ticket"
}
Other possible errors: expired_ticket, client_mismatch, already_used, usage_limit_exceeded
Origin binding: the endpoint checks the request Origin header against the origin the ticket was issued for. Mismatches are rejected (403).
Redeem Ticket (Server-Side)
Server-side endpoint. Requires HTTP Basic Auth. Returns both static_id and email.
Authentication
Authorization: Basic base64(client_id:api_key)
Request Body
{
"ticket": "TICKET_TOKEN"
}
Response 200
{
"ok": true,
"static_id": "sx_abc123...",
"email": "user@example.com"
}
Response 401
{
"ok": false,
"error": "missing_client_auth"
}
Who Am I
Check if the current browser has an active Signonix session. Uses the session cookie set automatically during login on signonix.com.
Response 200 -- Logged in
{
"logged": true,
"email": "user@example.com",
"exp": 1234567890
}
Response 200 -- Not logged in
{
"logged": false
}
Access control: This endpoint uses the Signonix session cookie (SameSite=Lax, HttpOnly, Secure). Cross-origin fetch calls do not include cookies, so whoami is effectively same-origin only. Client applications should use ticket exchange (verify-ticket or redeem) to learn user identity, not whoami.
Logout
End the current Signonix session and clear the session cookie.
Response 200
{
"ok": true
}
Create App
Create a new application. Requires an active Signonix session (logged-in user).
Request Body
{
"client_id": "my_app",
"display_name": "My Application",
"allowed_origins": ["https://myapp.com"],
"default_return_to": "https://myapp.com/callback"
}
| Field | Description |
|---|---|
| client_id required | 3-64 characters. Lowercase alphanumeric, underscores, hyphens. Cannot start with signonix, admin, or system. |
| display_name optional | Human-readable name for the consent screen. Defaults to client_id. |
| allowed_origins optional | Array of allowed redirect origins. |
| default_return_to optional | Default callback URL if none is specified in the login redirect. |
Response 201
{
"ok": true,
"client_id": "my_app",
"api_key": "my_app_a1b2c3d4e5f6...",
"message": "Store this API key securely. It cannot be retrieved again."
}
Client Dashboard Endpoints
These endpoints require HTTP Basic Auth: Authorization: Basic base64(client_id:api_key)
GET /client/me
Returns your app's configuration.
GET /client/stats
Returns user count and login statistics for your app.
GET /client/logins
Returns recent logins. Optional limit query param (default 100, max 500).
GET /client/users
Returns users who shared their email with your app. Optional limit query param (default 100, max 500).
Response 200
{
"ok": true,
"users": [
{ "static_id": "sx_...", "email": "user@example.com", "consented_at": 1706000000 }
]
}
GET /client/usage
Returns monthly and daily verification statistics. Optional months query param (default 12, max 24).
PATCH /client/settings
Update app settings such as display name, logo, brand color, webhook URL, and more.
Plans
Public endpoint -- no authentication required. Retrieve available pricing plans.
Response 200
{
"ok": true,
"plans": [
{
"id": "free",
"name": "Free",
"price": 0,
"limit": 1000,
"overage_rate": 0,
"features": ["1,000 verifications/month", "Branded login pages", "Community support"]
},
{
"id": "starter",
"name": "Starter",
"price": 1000,
"limit": 5000,
"overage_rate": 0.5,
"features": ["5,000 verifications/month", "$0.005 per extra", "Custom email branding", "Email support"]
},
{
"id": "pro",
"name": "Pro",
"price": 2900,
"limit": 15000,
"overage_rate": 0.3,
"features": ["15,000 verifications/month", "$0.003 per extra", "Webhooks", "Priority support"]
},
{
"id": "scale",
"name": "Scale",
"price": 7900,
"limit": 100000,
"overage_rate": 0.1,
"features": ["100,000 verifications/month", "$0.001 per extra", "SLA guarantee", "Dedicated support"]
}
]
}
Note: price is in cents (e.g., 2900 = $29.00). overage_rate is in cents per verification beyond the included limit (0 = hard cutoff).
A 'verification' is counted each time a ticket is exchanged via verify-ticket or redeem. Plans with overage_rate: 0 enforce a hard cutoff — new verifications return usage_limit_exceeded (429). Paid plans with overage_rate > 0 allow unlimited verifications with per-extra charges.
Static ID
The static_id is a stable, pseudonymous identifier assigned to each user per application. It is the primary way to identify returning users in Signonix.
Format
sx_ followed by a base64url-encoded HMAC value.
Properties
| Property | Description |
|---|---|
| Deterministic | Same email + same app = same static_id, forever. A returning user always gets the same identifier. |
| App-scoped | Each app gets a different static_id for the same user. This prevents cross-app tracking. |
| Pseudonymous | The static_id does not reveal the user's email address. It is a one-way derivation. |
Derivation
The static_id is derived using:
user_id = "usr_" + sha256(lowercase(email))[0:24]
static_id = "sx_" + base64url(HMAC-SHA256(master_key, "static_id:v1\0" + client_id + "\0" + user_id)[0:18])
The user_id is Signonix's internal identifier derived from the normalized (lowercased, trimmed) email address. The HMAC output is truncated to 18 bytes (24 base64url characters) to keep IDs compact.
The master key is held server-side by Signonix. Because the derivation includes the client_id, the same user produces a different static_id for every application they authenticate with.
Minimal Integration Example
A complete end-to-end integration in a single HTML page: login button, state validation, and verified ticket exchange.
<!-- 1. Login button -->
<button id="loginBtn">Sign in</button>
<script>
const CLIENT_ID = 'your_app';
const CALLBACK = location.origin + '/callback';
// 1. Redirect to Signonix
document.getElementById('loginBtn').onclick = () => {
const state = crypto.randomUUID();
sessionStorage.setItem('signonix_state', state);
location.href = `https://signonix.com/login.html?client_id=${CLIENT_ID}&return_to=${encodeURIComponent(CALLBACK)}&state=${state}`;
};
// 2. Handle callback (run on your /callback page)
function handleCallback() {
const hash = location.hash;
if (!hash.includes('signonix=')) return;
const params = new URLSearchParams(hash.slice(1));
// 3. Validate state
const state = params.get('state');
const saved = sessionStorage.getItem('signonix_state');
sessionStorage.removeItem('signonix_state');
if (state !== saved) return alert('State mismatch');
// 4. Exchange ticket (verified)
const ticket = params.get('signonix');
fetch('https://signonix.com/auth/verify-ticket', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticket, client_id: CLIENT_ID }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
localStorage.setItem('user_id', data.static_id);
// User is authenticated!
}
});
// Clean up URL
history.replaceState(null, '', location.pathname);
}
handleCallback();
</script>