Documentation

Integrate licensing that fights back.

Licentry turns a license key into a live, signed, device-bound session. This guide takes you from a raw key to a hardened client, and the contract on this page mirrors the live API exactly.

i

Throughout these docs, BASE is your Licentry API origin: https://api.yourdomain.com for self-host, or the endpoint issued to you on Licentry Cloud. Never hardcode secrets in a shipped client.

Quickstart

Four calls take you from key to a running, verifiable session. Everything else in this guide hardens what you build here.

1 · Activate a license

Send the license key plus a stable 64-hex device hash. You get back a short-lived session.

shell · activate
curl -sS -X POST "$BASE/v1/sess/activate" \
  -H "Content-Type: application/json" \
  -H "x-licentry-build: $BUILD_TOKEN" \
  -H "Idempotency-Key: $(openssl rand -hex 8)" \
  -d '{
    "licenseKey": "aB3xK9m-7Qp2n-M4kL8z-Xy1Wq9r",
    "deviceHash": "9f2c41d8b7a6...c3e1a7"
  }'
201 Created
{
  "accessToken": "9af2c1...",
  "refreshToken": "b7d3e0...",
  "expiresAt": "2026-07-24T12:20:00Z",
  "refreshExpiresAt": "2026-08-07T12:00:00Z",
  "revocationVersion": 3,
  "product": "your-product",
  "idempotent": false,
  "offlineGraceJwt": "eyJhbGciOiJFUzI1NiI..."
}

2 · Verify the response signature

Every response on the session routes is signed with ECDSA P-256. Verify it against a pinned public key before trusting a single field. That check is what stops a spoofed or MITM'd server.

response headers
X-Licentry-Sig:     MEUCIQDr4k...        # base64 raw r||s, 64 bytes
X-Licentry-Sig-Ts:  1753358400          # unix seconds
X-Licentry-Sig-Kid: rs1                 # which pinned key verifies this

# canonical message you verify:  "${status}|${ts}|${sha256hex(body)}"

3 · Heartbeat on a timer

Before expiresAt, send a heartbeat with a strictly increasing sequence number. The first heartbeat after activate is always seq: 1, and each 200 returns the new expiresAt to schedule the next beat from.

shell · heartbeat
curl -sS -X POST "$BASE/v1/sess/heartbeat" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -d '{"seq": 1}'

4 · Refresh before expiry

Rotate tokens before refreshExpiresAt. After a refresh the sequence resets, so your next heartbeat is seq: 1 again.

shell · refresh
curl -sS -X POST "$BASE/v1/sess/refresh" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "b7d3e0..."}'
i

That's a working integration. The sections below make it hard to crack, which is the entire reason to use Licentry over a homemade key check.

Core concepts

License key format

Keys are 28 characters, four mixed-case alphanumeric groups: ^[a-zA-Z0-9]{7}-[a-zA-Z0-9]{5}-[a-zA-Z0-9]{6}-[a-zA-Z0-9]{7}$. Example (illustrative): aB3xK9m-7Qp2n-M4kL8z-Xy1Wq9r. Keys are HMAC-peppered at rest, so a database leak alone reveals nothing usable.

Device hash

A 64-character hex string that identifies one machine or install. Derive it from stable hardware/software identifiers on your platform and hash the result to 64 hex with a fixed recipe. The server validates length and charset; the recipe is yours to document. The device hash binds the session: a leaked key can't activate on a machine it wasn't issued for.

Build token

The x-licentry-build header carries the per-build watermark token patched into your binary at download. It's how the server ties a running client back to the exact copy it was issued to, and to the account that downloaded it. Session routes answer requests without a recognised token with 426 upgrade_required; treat that as an update prompt in your UX.

Revocation version

Each license carries a revocationVersion. Bump it (revoke, freeze, or reset a device) and every session bound to the old version fails on its next heartbeat with stale_revocation. This is the kill switch: propagation is bounded by your heartbeat interval, not by token expiry.

Lifecycle state machine

The client integration is a small, strict state machine. This is the order your code runs in and the transitions you must handle.

activate ──▶ persist { access, refresh, revocationVersion, seq=0, product } │ ▼ ┌─────────────── session loop ───────────────┐ │ before expiry ──▶ heartbeat (seq++) │ │ near expiry ──▶ refresh (seq → 0) │ │ user signs out ──▶ logout ──▶ END │ └────────────────────────────────────────────┘ │ ├─ 401 stale_revocation ──▶ re-activate with key ├─ 401 refresh_token_stale ──▶ re-activate with key └─ 409 stale_seq / seq_gap ──▶ fix seq, or refresh then heartbeat
!

On 401 stale_revocation or refresh_token_stale, always re-activate with the license key. If you don't store the key in secure storage, the user re-enters it, so plan your UX for that path.

Endpoint reference

Every endpoint uses a strict JSON body: unknown properties are rejected with 400 Invalid request body. All bodies are Content-Type: application/json, and every response on these routes carries the signature headers from the quickstart.

The path prefix is deliberately generic. A shipped binary contains /v1/sess/… rather than anything with "license" in it, which is one less string for an attacker to hunt for. All routes are rate limited; on 429, honour the RateLimit-* headers and back off with jitter.

POST/v1/sess/validate

Read-only key check. Optionally enforces a cached revocation version. Requires the x-licentry-build header. Success is 200 with { "valid": true, "product": "…", "revocationVersion": n }, or a deliberately generic { "valid": false }.

FieldTypeReqNotes
licenseKeystringyesMust match the key format.
productstringnoIf the key belongs to another product → { valid:false, reason:"product_mismatch" }.
clientRevocationVersionnumbernoIf sent, must equal the DB version or the key reads invalid.
POST/v1/sess/activate

Creates a runtime session: access and refresh tokens, optionally DPoP-bound. Send an Idempotency-Key (≥ 8 chars) and reuse it when retrying after a network error.

FieldTypeReqNotes
licenseKeystringyesSame 28-character format as validate.
deviceHashstringyes64 hex chars.
dpopPublicJwkstringnoJSON string of a P-256 JWK. Malformed → 403 dpop_jwk_invalid (fails closed).
clientRevocationVersionnumbernoMust match current DB version if provided.
!

Activation failures are deliberately indistinguishable. Unknown, expired, revoked and canary keys all return 400 activation_failed on the same timing schedule. Don't branch on the reason; it exists only in the server log, by design, so activation can't be used as an oracle.

Outcomes your client should handle separately: 403 too_many_devices, 403 product_mismatch, 403 license_suspended, 403 dpop_jwk_invalid and 426 upgrade_required when the build token is missing or unrecognised. A deduplicated retry returns the same session with "idempotent": true.

POST/v1/sess/heartbeat

Extends the access window. Requires Authorization: Bearer <accessToken>, plus a DPoP proof if the session is bound. Success is 200 with { "ok": true, "expiresAt": "…", "revocationVersion": n }. Schedule the next beat from the returned expiresAt.

RuleBehavior
First seqRow starts at seq=0; first heartbeat must send seq: 1.
MonotonicNext value must satisfy oldSeq < newSeq ≤ oldSeq + 8.
After refreshServer resets seq to 0, so send seq: 1 again.
409 stale_seqYou sent seq ≤ stored (replay).
409 seq_gapYou skipped more than 8 (newSeq > oldSeq + 8).
409 bad_seqseq isn't a positive integer.
401 stale_revocationLicense revocation version changed → re-activate.
POST/v1/sess/refresh

Rotates the token pair before refreshExpiresAt. Body: { "refreshToken": "..." } (32–512 chars). If DPoP-bound, send a proof whose ath hashes the refresh token. Success returns a fresh token pair with new expiries. After success, next heartbeat is seq: 1.

POST/v1/sess/logout

Revokes the session for an access token. Body: { "accessToken": "..." }. Returns 204. If the session is DPoP-bound, send a proof over the logout URL with ath of the access token. Without one, a leaked access token is enough to end a customer's session, and strict deployments reject the call.

GET/v1/sess/offline-grace-jwks

Public JWK set (P-256 / ES256) for verifying offline-grace tokens: 200 with { "keys": [ … ] }, or 404 when offline grace isn't enabled for your deployment. Pin these or fetch once. Multi-tenant Cloud vendors have a per-vendor JWKS URL, see the Vendor API reference.

DPoP proofs

DPoP (proof-of-possession, RFC 9449) binds a session to a private key the client holds. Register a P-256 public JWK at activate, then send a fresh signed proof on every heartbeat and refresh. A stolen token becomes useless without the key.

Registering the key (activate)

Send dpopPublicJwk as a JSON string whose parsed value is a P-256 JWK (kty:"EC", crv:"P-256", base64url x/y).

The proof JWT

  • Algorithm ES256, signed with the private key matching the registered JWK.
  • Claims iat and exp required. Proof no older than 120s; lifetime exp - iat ≤ 120s (± ~30s skew).
  • htm POST; htu the exact URL {scheme}://{Host}{path}, no query (for example https://api.yourdomain.com/v1/sess/heartbeat).
  • ath base64url(SHA-256(token)): the access token on heartbeat, the refresh token on refresh.
  • jti a unique string (≥ 8 chars); not reused within the ~120s replay window.
DPoP header · heartbeat
curl -sS -X POST "$BASE/v1/sess/heartbeat" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "DPoP: $DPOP_JWT" \
  -d '{"seq": 2}'
i

Generate a new proof per request with a fresh jti, and keep the client clock within ~30s of UTC. If a proof is rejected for a timing reason, resync the clock and send a fresh proof rather than retrying the old one.

Offline grace

When configured, activate returns an offlineGraceJwt, a short-lived signed offline entitlement your app interprets locally (a grace period without network). Verify it with the JWKS endpoint from the reference above. Key type is ES256 / P-256, so a Windows client verifies with native BCrypt.

You must enforce device binding

The token's dev claim is the device hash it was minted for. Before honouring a stored token, compare dev against the hash you compute locally, case-insensitively, and refuse if they differ.

Skip the dev check and the token becomes a bearer entitlement: copying the app-state directory to another machine keeps working offline for the full TTL. TTL bounds how long a revoked license runs offline; the dev check is what stops cross-machine sharing. Enforce both.

ClaimMeaning
typ"licentry_offline_grace"
licLicense id (UUID).
prodProduct slug.
rvRevocation version. Enforce it in app policy.
devDevice hash (lowercase hex). Compare before honouring.

Make your app crack-resistant

A license check ultimately returns a value the client can patch. These practices raise "bypass the check" to "reverse-engineer and reimplement the product", a far higher bar. Apply as many as your threat model warrants.

  • 1

    Verify the response signature and fail closed. Pin the P-256 public key(s) by kid in your binary. Reject any session response whose X-Licentry-Sig doesn't verify. Without this, an attacker points your app at a fake server that returns "valid" for everything.

  • 2

    Bind sessions with DPoP. Generate a P-256 keypair in the most protected store your platform offers (TPM/Keychain/DPAPI), register the public JWK at activate, and prove possession on every call. A dumped token alone then buys the attacker nothing.

  • 3

    Gate real functionality on server-held parameters. Don't guard a feature with if (licensed). Instead, have the server seal parameters your product genuinely needs into the signed session response, keyed to the live access token. NOP-ing the check leaves the app with nothing to run.

  • 4

    Enforce offline-grace dev binding. As above: compare the dev claim to the local device hash before honouring any stored offline token.

  • 5

    Ship the watermarked build, unmodified. Serve each customer their per-download binary. Its self-hash lets the server detect a binary edited after it left your pipeline; the build token maps a leak straight back to an account.

  • 6

    Store tokens in platform secure storage. Access, refresh and license key belong in Keychain / Credential Manager / libsecret, never plaintext files or logs. Never log secrets.

  • 7

    Handle revocation promptly. Keep a short heartbeat interval so a kill-switch bump takes effect fast. On stale_revocation, stop the licensed path immediately rather than coasting on cached state.

  • 8

    Don't build an oracle. Mirror the server's design in your UI: don't reveal why a key failed. "Couldn't activate" beats "key expired". The latter helps someone sort live keys from dead ones.

Self-hosting

Running Licentry on your own infrastructure? The signing keys are the crown jewels. If one leaks, nobody needs to crack your app anymore; they can sign whatever they want. Key custody is most of the job, and the full configuration reference ships with the self-host license. The posture, in short:

  • Keep private keys out of plaintext. Load them from an encrypted credential store or a root-locked secrets file. Nothing sensitive belongs in a .env file, the process environment, or the repository.
  • Give every install strong, dedicated secrets. Key hashing and session tokens each use their own secret, and the licensing routes refuse to run without them.
  • Tighten enforcement as your fleet updates. Proof-of-possession checks can run permissively first, then strictly once every shipped client sends proofs. The session view in your dashboard shows which clients already do.
  • Scale out deliberately. Replay protection wants a shared store once you run more than one server process. On a single box there is nothing to add.
  • Terminate TLS at a proxy you control, and only trust forwarded client addresses coming from that proxy. HTTPS end to end.
  • Run the preflight before every deploy. The server ships with an audit that checks dependencies and configuration. Wire it into CI so a bad config never reaches production.
i

Managed on Licentry Cloud? All of this is our job. Key custody, strict enforcement, replay stores and proxy hardening ship enabled by default, and every vendor gets its own isolated signing keys.

Launch checklist

Before you ship a licensed build to real customers:

  • Base URL is configurable, no secrets compiled into the client.
  • Response-signature verification pins keys by kid and fails closed.
  • Activate persists access, refresh, revocationVersion, product and seq state.
  • Idempotency key reused on activate retries for the same license + device.
  • Heartbeat scheduled before expiresAt; 401/409 transitions handled.
  • Refresh scheduled before refreshExpiresAt; seq resets to 1 after.
  • DPoP proofs fresh per request; htu matches the exact posted URL.
  • Offline grace verified against JWKS with dev and rv enforced.
  • Logout called on user sign-out; local session state cleared.
  • Tokens in platform secure storage; nothing sensitive logged; 429s backed off.