1. Big Picture
- User pays on the website (Stripe/PayPal). Server issues a license (plan
Pro, idempotent per order) with keyGRAV-XXXXX-XXXXX-XXXXX-XXXXX. Plaintext key is shown once on the Download page; server stores only its SHA-256 hash and can never re-display it. - User pastes the key into the desktop app → app calls
/api/license/verifyat every startup. First verify registers the machine as an activation (max 3 machines per license). - App also logs in with website credentials → gets a 30-day Bearer session token.
- App sends periodic heartbeats with that token; an expired/revoked session or license must lock Pro features.
Two Credentials — Never Confuse Them
| Credential | Source | Purpose | Lifetime |
|---|---|---|---|
License key GRAV-... | Download page (one-time) | Machine activation + verify | 365 days |
| Session token | POST /api/auth/login | Bearer header for heartbeat/logout | 30 days |
2. Conventions
- Dev base URL:
http://localhost:5243(https profile:https://localhost:7130). Production:https://iamgravity.eu - All endpoints:
POST,Content-Type: application/json, JSON responses (camelCase) - No cookies/CSRF — licensing APIs are anonymous; auth is body fields or
Authorization: Bearer <token> - All timestamps are UTC ISO-8601; parse as UTC, display local
- License denials return HTTP 200 with
valid:false— always branch on the JSON body, never on the status code
3. Endpoints
3.1 POST /api/license/verify — Startup Activation Check
Request:
{
"licenseKey": "GRAV-XXXXX-XXXXX-XXXXX-XXXXX",
"machineId": "<stable id>",
"machineName": "DESKTOP-ABC123",
"appVersion": "1.0.0"
}
Success (200):
{
"valid": true,
"reason": "ok",
"license": {
"orderNumber": "PYPL-8XK4321",
"plan": "Pro",
"expiresAt": "2027-10-07T09:26:21Z",
"activations": 1,
"maxActivations": 3
}
}
Failure (also HTTP 200):
{
"valid": false,
"reason": "license_expired",
"message": "Your license has expired. Renew at iamgravity.eu/#pricing."
}
3.2 POST /api/auth/login — Account Login, Returns Session Token
Request:
{
"email": "[email protected]",
"password": "...",
"machineId": "<stable id>",
"machineName": "DESKTOP-ABC123",
"appVersion": "1.0.0"
}
3.3 POST /api/license/heartbeat — Session Keep-Alive
Header: Authorization: Bearer <sessionToken>
{ "machineId": "<stable id>" }
3.4 POST /api/auth/logout — Revoke Session Token
Header: Authorization: Bearer <sessionToken>, empty body → 200 { "success": true }
4. License Key Format & Lifecycle
Format: GRAV- + 4 groups of 5 chars (--separated, 23 chars total). Alphabet: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 — no I, O, 0, 1 (anti-confusion).
Client-side validation regex:
^GRAV-[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}-[A-HJ-NP-Z2-9]{5}$
Lifecycle: Issued on payment success, plan "Pro", valid 365 days. Status Active(0) → Expired(2) (auto-flipped) or Revoked(1) (manual). MaxActivations = 3 machines.
5. Machine ID Requirements
machineId is a client-generated, opaque, stable string. Rules:
- Deterministic — same machine, same ID, across restarts/updates/reinstalls
- Unique per machine — two PCs must never collide
- Recommended recipe (Windows): hex SHA-256 of the registry MachineGuid (
HKLM\\SOFTWARE\\Microsoft\\Cryptography\\MachineGuid) - Do NOT use values that change with hardware dongles, VPNs, MAC randomization, or current username
- Always send
machineName(Environment.MachineName) andappVersion
6. Reason / Error Codes → Required Client Behavior
| Code | Where | Meaning | Client Action |
|---|---|---|---|
ok (valid:true) | verify | Licensed | Unlock Pro features, cache license info |
license_not_found | verify | Key unknown | Re-prompt; link to Download page |
license_revoked | verify | License revoked by staff | Lock; show [email protected] |
license_expired | verify | Past ExpiresAt | Lock; deep-link /pricing to renew |
activation_limit_reached | verify | More than 3 machines | Lock; deep-link account page to revoke a device |
machine_revoked | verify | This device was revoked | Lock; user must re-activate from account page |
session_invalid_or_expired (401) | heartbeat | Session dead or machine not activated | Silent re-login; if still failing → re-verify, then lock |
no_active_license (402) | login | No active license on account | Lock; deep-link /pricing |
7. Recommended Startup Flow
App start
├─ No stored license key → Activation screen (paste key) ─────────────────────────────────────────────────────────────────────────────────────────────│
├─ Stored key: │
│ 1. POST /api/license/verify {key, machineId, ...} ├──────────────────────────────────────────────────────│
│ valid:true → cache license, continue
│ valid:false → show message, LOCK Pro features
│ network error → offline grace
│ 2. POST /api/auth/login {email, password, machineId, ...}
│ 200 → store sessionToken + expiresAt securely
│ 402/401/429 → handle per error codes
│ 3. Start heartbeat timer (every 30–60 min)
└─ Runtime:
heartbeat 200 → refresh cached license info, stay unlocked
heartbeat 401 → silent re-login once; 402 → LOCK
user sign-out / app exit → POST /api/auth/logout (fire-and-forget)
8. Offline Grace Policy
If the last successful verify/heartbeat is within N days (recommend 7; max 30 = session lifetime), allow Pro features offline and retry in background with backoff (1h → 6h → 24h). Lock when grace window exceeded or explicit valid:false / 402 arrives.
9. Storage & Security Checklist
sessionToken→ Windows Credential Manager or DPAPI (ProtectedData). Never plaintext on disk, never in logs/telemetry.- Password → Credential Manager only with explicit "remember me"; otherwise memory only.
- License key → local settings file is acceptable (low sensitivity); display masked in UI.
machineId→ compute deterministically or cache after first generation; reuse forever.- Gate ALL Pro features behind a single
IsLicensedservice — no scattered booleans. - Never embed a default/shared license key in the binary.
- Log reason codes (not keys/tokens) for support diagnostics.
10. Reference C# Client
public sealed class LicenseApiClient
{
private readonly HttpClient _http;
public string? SessionToken { get; set; }
public LicenseApiClient(string baseUrl)
{
_http = new HttpClient { BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/") };
_http.DefaultRequestHeaders.Accept.Add(new("application/json"));
}
public static string GetMachineId()
{
using var key = Microsoft.Win32.Registry
.OpenBaseKey(Microsoft.Win32.RegistryHive.LocalMachine,
Microsoft.Win32.RegistryView.Registry64)
.OpenSubKey("SOFTWARE\\\\Microsoft\\\\Cryptography");
var guid = (string?)key?.GetValue("MachineGuid")
?? throw new InvalidOperationException("MachineGuid unavailable");
return Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(
System.Text.Encoding.UTF8.GetBytes(guid))).ToLowerInvariant();
}
public async Task VerifyAsync(string licenseKey) { ... }
public async Task LoginAsync(string email, string password) { ... }
public async Task HeartbeatAsync() { ... }
public async Task LogoutAsync() { ... }
}
11. Integration Checklist
- [ ] Settings UI to paste the license key (validate shape locally with regex, then send as-is)
- [ ] Deterministic
machineId+machineName+appVersionon every call - [ ] Startup sequence: verify → login → heartbeat timer (30–60 min)
- [ ] Every reason code mapped to a user message + app state
- [ ] Offline grace window with authoritative-lock overrides
- [ ] Silent re-login on
401 session_invalid_or_expired - [ ] Secure token/password storage; masked key display
- [ ] Logout on sign-out/app exit
- [ ] Single
IsLicensedgate for ALL Pro features - [ ] Test matrix: wrong key, expired license, 4th machine, revoked machine, revoked license, session expiry, offline start, rate-limited login