Client Endpoints
The /api/client/* routes are the four calls a licensed application makes at runtime: open a session with init, then hit register, login, or license depending on how you authenticate the user.
Every request is POST with a JSON body and every response is JSON. Every call carries the HMAC headers from Request Signing, and every call after init carries the session token that init returned. Base URL and shared transport rules live in API Overview.
POST /api/client/init
Opens a session. Every other client call needs the token this returns.
Before endpoint logic runs, the server enforces the per-IP rate limit, verifies the signature, screens the IP against the app blacklist, and refuses if the application is disabled. When force_update is set on the app and the client version does not match the server version, the request is rejected with a 426.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
api_key | string | yes | Public application key. |
version | string | no, default "1.0" | Client-reported version, compared to app version. |
enc_key | string, max 35 | no | Client-side encryption key, required when force_encryption is on. |
init_iv | string, max 64 | no | Per-request IV seed for AES-256-CBC response encryption. |
binary_hash | string, max 128 | no | SHA-256 or MD5 of the client binary, checked when hash_check is on. |
binary_token | string, max 128 | no | Pre-issued binary token embedded in the client build. |
binary_token_hash | string, max 128 | no | HMAC-SHA256(binary_token, api_secret) proving token origin. |
Success response
{
"success": true,
"session": "<opaque session token>",
"app": {
"name": "My App",
"version": "1.0",
"hwid_lock": true
}
}
Errors
| Status | Detail | Cause |
|---|---|---|
| 403 | disabled_message on the app, or "Application disabled" | App is not enabled. |
| 426 | {"message", "latest_version", "update_url", "force_update": true} | Version mismatch while force_update is on. |
| 429 | Rate limit | More than 30 inits per minute from one IP. |
The call still succeeds. The server writes a client.version_mismatch audit entry at warning level and proceeds. Only force_update turns the mismatch into a hard refusal.
POST /api/client/register
Claims a license key and creates a new end user in one transaction. On success the server calls mark_session_activated for the username, matching the login flow so the heartbeat session binds to the new account immediately.
The new user row is created with last_login set to null. Only a subsequent successful login stamps it.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
session | string | yes | From init. |
username | string | yes | Must be unique inside the app. |
password | string | yes | Max 72 UTF-8 bytes (bcrypt limit). |
license | string | yes | Unused license key for this app. |
hwid | string, 8-256 chars | yes | Normalised to lowercase server-side. |
screenshot | string | no | Base64 screenshot for the activation capture. |
init_iv | string, max 64 | no | Response-encryption IV. |
Success response
{
"success": true,
"message": "Account created",
"user": {
"username": "alice",
"level": 1,
"expires_at": "2026-10-03T12:00:00+00:00"
}
}
Errors
| Status | Detail | Cause |
|---|---|---|
| 400 | "Invalid license key" | Key does not exist for this app. |
| 400 | "License key already used" | Key status is used, or was claimed by another activation between fetch and update. |
| 400 | "Username already taken" | Username exists for this app, or a duplicate insert raced. |
| 403 | "License key is banned" | Key status is banned. |
| 403 | "Application disabled" or the app's disabled_message | App disabled. |
| 403 | "License validation is temporarily paused for this application." | keys_paused on the app. |
| 409 | "License state changed mid-activation, please retry" | Key moved out of pending between claim and finalise. |
| 429 | "Too many failed attempts, try again later." | Register lockout. |
| 429 | Rate limit | More than 20 registers per minute from one IP. |
License state during register moves unused -> pending -> used. If any exception fires while the row is pending, the server resets it to unused and re-raises, so a crashed activation never leaves a key wedged.
POST /api/client/login
Authenticates an existing end user. The response fails uniformly with "Invalid username or password" for a wrong password, a banned user, an expired user, or an HWID mismatch, so an attacker cannot tell which of those tripped.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
session | string | yes | From init. |
username | string | yes | |
password | string | yes | Max 72 UTF-8 bytes. |
hwid | string, 8-256 chars | yes | Normalised to lowercase. |
screenshot | string | no | Base64 screenshot for the login capture. |
init_iv | string, max 64 | no | Response-encryption IV. |
Success response
{
"success": true,
"message": "Authenticated",
"user": {
"username": "alice",
"level": 1,
"expires_at": "2026-10-03T12:00:00+00:00"
}
}
Errors
| Status | Detail | Cause |
|---|---|---|
| 401 | "Invalid username or password" | Wrong username, wrong password, banned user, expired user, or HWID mismatch when hwid_lock is on. |
| 403 | "Application disabled" or the app's disabled_message | App disabled. |
| 403 | "Access is temporarily paused for this application." | keys_paused on the app. |
| 403 | "Heartbeat session required. Call /heartbeat/start first." | require_heartbeat is on and the session has no live heartbeat. See Heartbeat. |
| 429 | "Too many failed attempts, try again later." | Login lockout. |
| 429 | Rate limit | More than 20 logins per minute from one IP. |
When hwid_lock is on and the user has no stored HWID yet, the first successful login writes the provided HWID to the user record. Every login after that must match it.
POST /api/client/license
Validates a license key without a username or password. Useful for license-only apps that do not maintain end-user accounts.
First use of an unused key promotes it to used, stamps expires_at, sets used_by to "license-only", and pins hwid to the caller.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
session | string | yes | From init. |
license | string | yes | License key for this app. |
hwid | string, 8-256 chars | yes | Normalised to lowercase. |
screenshot | string | no | Base64 screenshot for the activation capture. |
init_iv | string, max 64 | no | Response-encryption IV. |
Success response
{
"success": true,
"message": "License valid",
"level": 1,
"expires_at": "2026-10-03T12:00:00+00:00"
}
Errors
| Status | Detail | Cause |
|---|---|---|
| 400 | "Invalid license key" | Key does not exist for this app. |
| 403 | "Application disabled" or the app's disabled_message | App disabled. |
| 403 | "License validation is temporarily paused for this application." | keys_paused on the app. |
| 403 | "Heartbeat session required. Call /heartbeat/start first." | require_heartbeat is on and the session has no live heartbeat. |
| 403 | "License key is banned" | Key status is banned. |
| 403 | "License not valid" | Key status is not one of unused or used (after the banned and pending checks). |
| 403 | "HWID mismatch. Key locked to another device." | hwid_lock is on and the key's stored HWID differs. |
| 403 | "License expired" | Stored expires_at is in the past. |
| 409 | "License activation in progress, retry." | Key is pending on a concurrent register. |
| 429 | Rate limit | More than 20 license calls per minute from one IP. |
Shared behaviour
Every client endpoint runs the same gate before the endpoint-specific logic:
- Per-IP rate limit (30/min for
init, 20/min for the others). - HMAC signature check (see Request Signing).
- Blacklist check on IP and, where present, HWID.
enabledcheck on the application.
Success and failure both write to the app's audit log. Register, login, and license also fire an activation webhook when the app has one configured, including geo lookup and the optional screenshot capture.