CLV1
CLV1 client protocol
The protocol every ClavisAUH SDK speaks: signed by the device, answered with signatures from your app
Design goals
- No secrets in shipped software. The SDK embeds only the application's public key.
- Every request is signed by the device with Ed25519. The key pair is created on first run and belongs in the OS keychain, DPAPI or TPM.
- Every response is signed by the application key and bound to the request nonce, so emulated, tampered or replayed answers are rejected.
- Replay-proof: a ±60 s time window and single-use nonces.
Request
POST <baseUrl>/v1/client/<endpoint> with a JSON body and these headers:
| content-type | application/json |
|---|---|
| x-clavis-proto | 1.1: asks for CLV1.1 answers (below). Every new client sends it |
| x-clv-app | Application id (app_…) |
| x-clv-time | Unix seconds (device clock + learned offset) |
| x-clv-nonce | 16–64 chars [A-Za-z0-9_-], fresh for every request |
| x-clv-key | Device public key, base64url (32 bytes) |
| x-clv-session | Session id, after activate / login |
| x-clv-signature | base64url Ed25519 signature (64 bytes) of the string below |
| x-clavis-sdk | Optional, not signed: name/version (os), e.g. go/1.0.0 (linux); shown in the connect wizard |
Signed string: the fields joined with a newline (\n), with no newline after the last one:
CLV1-REQUEST
<appId>
<METHOD>
<path including query>
<lowercase hex sha256 of the exact body bytes>
<timestamp>
<nonce>
<sessionId or "-">
The path is the part after the API address, starting with /v1/client/: with the API address https://<your ClavisAUH site>/api, activate signs /v1/client/activate.
Response
A new client sends x-clavis-proto: 1.1 and checks answers as CLV1.1 says; this section is the older format, still answered to clients that do not send the header. Every CLV1 response, success or error, carries x-clv-time and x-clv-signature. Verify it with the application public key over:
CLV1-RESPONSE
<appId>
<HTTP status>
<nonce of the request>
<timestamp>
<lowercase hex sha256 of the exact body bytes>
Reject the response if the signature is missing or invalid. On clock_skew, set offset = serverTime − now() and retry once.
CLV1.1
A client opts in with x-clavis-proto: 1.1. CLV1 clients keep working unchanged. A 1.1 answer carries x-clavis-signature, x-clavis-kid and x-clavis-timestamp, signed over the status and the request line too, so a 403 cannot become a 200 and an answer cannot move to another endpoint. The fields are joined with a newline, with no newline after the last one:
CLV1.1
<kid>
<HTTP status>
<METHOD>
<path including query>
<nonce of the request>
<timestamp>
<lowercase hex sha256 of the exact body bytes>
Every official SDK follows the same verification contract, on every endpoint without exception:
- No signature, a wrong signature, or a kid that is not in the build's trusted list: refuse.
- A timestamp more than 60 s from the corrected clock, in either direction: refuse.
- The signature must cover the nonce that was just sent, so another request's answer fails.
- Refusals surface as a verification error (
unsigned_response,unknown_key,invalid_response_signature,stale_response), and the SDK reports them (reportkindresponse_verification, signal T1) after the next verified answer.
A signed clock_skew refusal is handled as in CLV1: set offset = serverTime − now() and retry once.
Key rotation. kid is the first 16 hex characters of sha256(public key). Builds embed every trusted key as appPublicKeys: [{ kid, key }]. Prepare the next key, ship a build that carries both, then activate it: the old key stays listed for 14 days.
Security hints. The signed init answer includes securityHints: { pinning, spkiPins, heartbeatSec, maxOfflineSec, reportTamperSignals, requireHardwareKey }. SDKs apply them only where they are stricter than their own settings: heartbeatSec, when not null, sets the pace of heartbeats when it is shorter than the client's own. The JS SDK cannot pin TLS: browsers and fetch do not expose the certificate.
Pace and retries. nextCheckSeconds in a signed answer sets when to check in next. A client adds up to a fifth to it, the same share every time for one machine, so copies started together spread out. A signed rate_limited answer carries retryAfter in seconds, and a Retry-After header. Only a verified answer may change anything: an unsigned page from something in between counts as a failed try, then back off with jitter. Calls that may already have happened (trial, register, redeem, transfer) are not repeated after a lost answer.
Uniform refusals. For 1.1 clients, a key that is unknown, suspended, revoked or expired answers invalid_credentials after the same minimum time, unless the asking device already holds a seat on that license; then the precise reason is returned.
Endpoints
| Endpoint | Body | Session |
|---|---|---|
| init | { version?, buildHash? } | no |
| activate | { key, device } | no |
| trial | { device } | no |
| register | { key?, username, password, email?, device } | no |
| login | { username, password, device } | no |
| redeem | { key } | yes |
| heartbeat | {} | yes |
| variables | {} | yes |
| transfer | {} | yes |
| updates | { channel?, platform? } | yes |
| report | { kind, detail? } | yes |
| logout | {} | yes |
device is { fingerprint, components?, name?, platform? }. Hash every hardware identifier on the device before sending it, as the SDKs do: sha256(lowercase(trim(value))), first 32 hex characters (the hashComponent test vector). With components, the server recognises the same machine after a reinstall or a partial hardware change, as long as enough of them still match (the plan's fingerprint threshold, 3 by default). Send a heartbeat at least every 30 minutes; sessions end after 60 minutes without one.
name and platform are free text up to 64 characters; the SDKs send the host name and windows, linux or darwin. init is not required before the other calls; the samples start with it to learn the server's clock and the security hints.
What the main endpoints answer (JSON, HTTP 200 for every call that succeeds):
activate, trial, register, login, redeem:
{ session: { id, expiresAt }, license: { id, status, state, expiresAt, maxDevices, metadata },
entitlements, device: { id }, variables, offlineToken, nextCheckSeconds }
heartbeat: { session: { id, expiresAt }, license: { id, status, state, expiresAt }, entitlements, offlineToken,
nextCheckSeconds }
variables: { variables: { <name>: <value> } }
init: { app: { name, version, loginMode, trial, maintenance, minVersion, … }, securityHints, serverTime }
logout: { ok: true }
any refusal (HTTP 4xx, 5xx): { error: { code, message, retryAfter? }, serverTime }
Offline tokens
clv1.<base64url(JSON payload)>.<base64url(Ed25519 signature of "clv1.<payload>")>
payload: { app, lic, dev, lexp, iat, exp, nbf?, kid?, wm? } dev = sha256(device public key)
Valid while now < exp and the license has not expired, and only on the device whose key hash matches dev. Since 1.1 a token also carries nbf, the signing kid and, with A8, the buyer mark wm. A token issued more than 60 s in the future means the clock was turned back and is refused, as is any token once the license was answered as revoked or suspended.
Error codes
What each code means for the person using the software, and what to do, is in Error codes. The SDKs add a few of their own (status 0) for answers that never came or could not be trusted.
invalid_key, license_expired, license_suspended, license_revoked, license_already_registered, already_redeemed, test_key_disabled, device_limit, device_blocked, device_revoked, transfer_limited, transfer_not_allowed, session_device_mismatch, session_required, session_expired, session_limit, invalid_credentials, username_taken, user_banned, user_limit, no_license, account_required, mode_not_allowed, too_many_attempts, trial_used, trial_expired, trial_unavailable, trial_denied, app_not_found, app_paused, maintenance, access_blocked, update_required, build_blocked, unknown_build, missing_headers, bad_signature, clock_skew, invalid_nonce, replayed_request, invalid_encoding, invalid_json, validation_failed, rate_limited, internal_error