License control
Session + sealed validate (optional HWID), version gate, bans, and end-user register/login/upgrade.
What you embed at build time
From the dashboard when you create an application:
API_BASE_URL—https://api.sdkey.dev(no trailing slash)APP_ID— application UUIDAPP_VERSION— exact version string (must match app settingsversion)APP_PUBLIC_KEY— Ed25519 public key (32 raw bytes as base64)
The private signing key never leaves the server. Clients only verify with the public key.
License validate flow
POST /api/v1/session/init— establish an AES session (~15 minutes); send exactclientVersionPOST /api/v1/licenses/validate— seal the license (+ optional HWID), verify the signed response
There is no separate activate, deactivate, or heartbeat endpoint. When HWID is provided and the app has HWID lock enabled (default), the first successful validate locks the license to that HWID; later validates must match. Omit HWID to skip lock, mismatch, and HWID-ban checks. Re-validate with a fresh nonce when you need another check.
1. Session init
{
"appId": "<uuid>",
"clientNonceB64": "<base64 of 32 random bytes>",
"clientVersion": "1.0.0"
}{
"success": true,
"sessionId": "<uuid>",
"serverNonceB64": "...",
"hkdfSaltB64": "...",
"timestamp": 1720000000,
"signatureB64": "...",
"v": 1
}clientVersion must exactly match the application's configured version; mismatch → APP_OUTDATED. Banned client IP → IP_BANNED. Disabled app → APP_DISABLED. Success has no message field. Failures use the plaintext error shape with customizable error text for those three codes:
{
"success": false,
"error": "Client version outdated",
"code": "APP_OUTDATED"
}Verify the Ed25519 signature over canonical JSON of { appId, hkdfSaltB64, serverNonceB64, sessionId, timestamp, v }, then derive the AES-256 session key via HKDF-SHA256. Rate limit: 60 / min / IP.
2. Sealed validate
Call POST https://api.sdkey.dev/api/v1/licenses/validate. Outer HTTPS body:
{
"sessionId": "<uuid>",
"ivB64": "...",
"ciphertextB64": "...",
"tagB64": "..."
}Inner plaintext before AES-GCM seal (hwid optional):
{
"hwid": "MACHINE-1",
"licenseKey": "SDKY-XXXX-XXXX-XXXX-XXXX",
"nonce": "<base64 ~16 bytes>",
"timestamp": 1720000001,
"v": 1
}Response envelope adds signatureB64. After decrypt, both success and failure plaintext include a message string (editable per app for OK and most business failure codes). Success plaintext also includes integer subscriptionTier (default 0 on create):
{
"success": true,
"code": "OK",
"message": "validated",
"status": "active",
"expiresAt": "2026-01-01T00:00:00.000Z",
"subscriptionTier": 0,
"sessionId": "...",
"timestamp": 1720000001,
"v": 1
}{
"success": false,
"code": "HWID_MISMATCH",
"message": "Hardware ID mismatch",
"status": null,
"expiresAt": null,
"sessionId": "...",
"timestamp": 1720000001,
"v": 1
}Client order is mandatory:
- AES-GCM open → plaintext JSON
- Ed25519 verify with the app public key over canonical JSON
- Check
sessionIdand clock skew (±60s) - Only then honor
success/ readmessage
Rate limit: 120 / min / IP. Use a fresh nonce every call (replay window ~120s).
Success and failure codes
Sealed path success: code: "OK" with message from the app's OK response message. Common sealed failures (often HTTP 200 with sealed body — read message inside the plaintext, not a top-level error):
SESSION_EXPIRED CLOCK_SKEW REPLAY LICENSE_NOT_FOUND APP_MISMATCH BANNED EXPIRED HWID_MISMATCH DECRYPT_FAIL APP_DISABLED APP_OUTDATED HWID_BANNED IP_BANNED
Customize strings via PATCH /api/v1/apps/:id/settings (responseMessages). With HWID lock on and HWID provided: first success binds HWID and starts duration if configured; mismatch later → HWID_MISMATCH.
End-user register / login / upgrade
Same sealed session as validate: open POST /session/init, then POST a sealed envelope to register / login / upgrade (rate 30 / min / IP). applicationId and version gating come from the crypto session — do not send appId or clientVersion in the inner body. Optional hwid follows the same skip-when-omitted rules. When CRYPTO_ENFORCE=true, plaintext bodies are rejected (CRYPTO_REQUIRED).
Outer envelope (same as validate):
{
"sessionId": "<uuid>",
"ivB64": "...",
"ciphertextB64": "...",
"tagB64": "..."
}{
"username": "player1",
"password": "••••••••",
"email": "[email protected]",
"licenseKey": "SDKY-....",
"hwid": "...",
"nonce": "<base64 ~16 bytes>",
"timestamp": 1720000001,
"v": 1
}If app setting requireLicenseToRegister is true (default), licenseKey is required (LICENSE_REQUIRED).
{
"username": "player1",
"password": "••••••••",
"hwid": "...",
"nonce": "<base64 ~16 bytes>",
"timestamp": 1720000001,
"v": 1
}{
"username": "player1",
"licenseKey": "SDKY-....",
"hwid": "...",
"nonce": "<base64 ~16 bytes>",
"timestamp": 1720000001,
"v": 1
}Upgrade attaches a usable key whose subscriptionTier is strictly greater than the user's current tier (no license → 0). Otherwise TIER_NOT_HIGHER.
Response envelope adds signatureB64. Client order matches validate: open → Ed25519 verify → sessionId + skew → then honor success. Both success and failure plaintext include message (not a top-level error).
{
"success": true,
"code": "OK",
"message": "ok",
"sessionId": "...",
"timestamp": 1720000001,
"v": 1,
"sessionToken": "<opaque>",
"expiresAt": "…",
"user": { "id": "…", "username": "player1", "email": null, "applicationId": "…" },
"license": {
"id": "…",
"status": "active",
"expiresAt": null,
"subscriptionTier": 1
},
"session": { "ip": "…", "hwid": "…" }
}{
"success": false,
"code": "INVALID_CREDENTIALS",
"message": "Invalid username or password",
"sessionId": "...",
"timestamp": 1720000001,
"v": 1
}license may be null. Passwords are never echoed. Full wire notes: API reference → Client auth.
TypeScript client
Implement the wire protocol with the Web Crypto API, or use the in-repo @sdkey/sdk client (SdkeyClient) which covers session init, sealed validate (optional HWID), and sealed register / login / upgrade.
const API_BASE_URL = 'https://api.sdkey.dev'
const APP_ID = '<uuid>'
const APP_VERSION = '1.0.0'
const APP_PUBLIC_KEY_B64 = '<base64>' // 32 raw Ed25519 bytes
async function validateLicense(licenseKey: string, hwid?: string) {
// 1. Session init — include clientVersion: APP_VERSION
const clientNonce = crypto.getRandomValues(new Uint8Array(32))
const initRes = await fetch(API_BASE_URL + '/api/v1/session/init', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
appId: APP_ID,
clientNonceB64: bytesToBase64(clientNonce),
clientVersion: APP_VERSION,
}),
})
const hello = await initRes.json()
// Verify Ed25519 over canonical JSON of:
// { appId, hkdfSaltB64, serverNonceB64, sessionId, timestamp, v }
// Then HKDF-SHA256 → 32-byte AES session key (see derivation below).
// 2. Sealed validate — omit hwid to skip HWID checks
const inner = {
...(hwid !== undefined ? { hwid } : {}),
licenseKey,
nonce: bytesToBase64(crypto.getRandomValues(new Uint8Array(16))),
timestamp: Math.floor(Date.now() / 1000),
v: 1,
}
// AES-GCM seal(inner) → POST /api/v1/licenses/validate
// Open envelope → verify Ed25519 → check sessionId + ±60s skew
// Unlock only when success && code === "OK"
// Read subscriptionTier from success plaintext
}Session key derivation: IKM = clientNonce || serverNonce (32 + 32 bytes), salt = hkdfSaltB64, info = UTF-8 sdkey-session-v1 + appId, output 32 bytes. Canonical JSON for signatures: object keys sorted lexicographically, no insignificant whitespace.