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:

KeyDescription
signonixA one-time ticket for verified exchange.
static_idA stable, deterministic user identifier. Same email + same app = same ID, every time.
stateYour CSRF token, passed through unchanged.

How It Works

  1. Redirect the user to the Signonix login page with your client_id and return_to URL.
  2. The user verifies their email address (magic link or code entry).
  3. If first time, the user sees a consent screen to authorize your app.
  4. Signonix redirects back to your return_to URL with the ticket and static_id in the URL hash.

State (CSRF) Validation

To prevent CSRF and replay attacks, validate the state parameter on every callback:

  • Generate a random state value before redirecting to Signonix.
  • Store it in sessionStorage.
  • On callback, compare the returned state to 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 /login.html

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

ParameterDescription
client_id requiredYour application's client ID.
return_to requiredThe URL to redirect the user back to after verification.
state optionalAn 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

GET POST /auth/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)

ParameterDescription
ticket requiredThe one-time ticket from the URL hash.
client_id requiredYour 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)

POST /auth/redeem

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

GET /auth/whoami

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

POST /auth/logout

End the current Signonix session and clear the session cookie.

Response 200

{
  "ok": true
}

Create App

POST /auth/me/apps

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"
}
FieldDescription
client_id required3-64 characters. Lowercase alphanumeric, underscores, hyphens. Cannot start with signonix, admin, or system.
display_name optionalHuman-readable name for the consent screen. Defaults to client_id.
allowed_origins optionalArray of allowed redirect origins.
default_return_to optionalDefault 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

GET /client/me

Returns your app's configuration.

GET /client/stats

GET /client/stats

Returns user count and login statistics for your app.

GET /client/logins

GET /client/logins

Returns recent logins. Optional limit query param (default 100, max 500).

GET /client/users

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

GET /client/usage

Returns monthly and daily verification statistics. Optional months query param (default 12, max 24).

PATCH /client/settings

PATCH /client/settings

Update app settings such as display name, logo, brand color, webhook URL, and more.

Plans

GET /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

PropertyDescription
DeterministicSame email + same app = same static_id, forever. A returning user always gets the same identifier.
App-scopedEach app gets a different static_id for the same user. This prevents cross-app tracking.
PseudonymousThe 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>