Skip to content

License Server API Reference

Complete endpoint reference for the License Server API. See Introduction for an overview and quick start, or Rate Limits for the full per-category request-quota breakdown.

API Workflow Diagrams

License Verification Flow

License Activation Flow

Admin License Management Flow

Release Registration & Ownership Flow

See Software Distribution below for the full endpoint reference.

Update Check Flow

Authentication

Admin endpoints require a Bearer token in the Authorization header:

Authorization: Bearer your-api-key

Which key depends on how you're deployed. On a self-hosted deployment, this is your ADMIN_API_KEY. On the hosted (SaaS) tier, it's your tenant's own API key instead — ADMIN_API_KEY is never accepted there (see Authentication for the full explanation of why). The one exception is GET /v1/admin/build-info, which always requires ADMIN_API_KEY specifically, on every deployment mode, since the platform-level build watermark it returns isn't a tenant's data to read.

Rate Limits

  • Validation Endpoints: 1000 req/15min (verify, validate, usage tracking, check-update)
  • Activation Endpoints: 300 req/15min per IP (activate, deactivate, verify-file, verify-file-base64) — activate/deactivate additionally share a stricter 20 req/15min limit per license key, on top of the per-IP one
  • Public Endpoints: 500 req/15min (export, export/file, export offline, public-key)
  • Management Endpoints: 100 req/15min (issue, revoke, update-license-terms, delete, register-release, unpublish-release)
  • Admin-Info Endpoints: 300 req/15min by default (list-licenses, admin/license/:key, admin/stats, recent-activations, list-activations, reissue-token, admin/deactivate-by-instance-id, admin/reset-usage, admin/update-notes, admin/build-info, billing/status, billing/checkout, rotate-api-key, list-releases, release/:id) — the only tier that's configurable, via the ADMIN_RATE_LIMIT_MAX environment variable

See Rate Limits for the full breakdown.

Base URL

https://your-license-api.com/v1

Health Endpoints

GET /

Health check endpoint to verify server status. Note this route has no /v1 prefix — it's at the server root, not /v1/.

Response:

json
{
  "message": "License API is running",
  "version": "1.0.0",
  "buildFingerprint": "a1b2c3d4e5f6"
}

License Validation

POST /v1/verify-license

Verify if a license key is valid.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}

Response:

json
{
  "valid": true,
  "tier": "pro",
  "product_id": "my-product",
  "limits": {},
  "expires_at": "2027-12-31T23:59:59.000Z",
  "issued_at": "2025-01-01T00:00:00.000Z",
  "status": "active"
}

POST /v1/validate-license

Validate a license for a specific instance.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "instance_id": "unique-instance-id"
}

Response:

json
{
  "valid": true
}

License Activation

POST /v1/activate-license

Activate a license for a specific instance.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "instance_id": "unique-instance-id"
}

Response (first activation of this key/instance_id pair):

json
{
  "activated": true,
  "token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789"
}

token is returned once — only on the call that actually creates the activation. Capture and store it immediately: it's the credential POST /v1/deactivate-license requires to free this seat later, and it cannot be retrieved again. If the same key/instance_id pair activates again (e.g. an app restart), the response is instead {"activated": true, "alreadyActivated": true} — no token, since the token from the original activation remains the only valid one.

POST /v1/deactivate-license

Free a single seat so it can be activated on a different instance — e.g. a customer moving from an old machine to a new one. Distinct from POST /v1/revoke-license (Admin Endpoints, below), which invalidates the entire license; this only removes one seat's activation record. Public, like activation itself — no admin authentication required.

Requires the opaque token returned by POST /v1/activate-license when that seat was created — not instance_id. instance_id is a caller-supplied label with no guaranteed entropy, so it isn't accepted as a credential here.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789"
}

Response:

json
{ "deactivated": true }

deactivated is false, not an error, if that token didn't match an active seat — this call is idempotent.


Usage Tracking

POST /v1/track-usage

Track usage for a license instance. Requires token, the activation token returned by POST /v1/activate-license — proof the caller holds a real activation for this key, since the key alone is not a secret.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789",
  "metric": "api_calls_per_day",
  "increment": 1
}

Response:

json
{
  "ok": true,
  "metric": "api_calls_per_day",
  "usage": 101
}

usage is the metric's new running total (an integer), not an object keyed by metric name. metric must already be a key in the license's limits — see Usage Tracking for the full allowed-metric list and error responses. Returns 403 {"error": "Invalid activation token"} if token doesn't match a real activation for this key. On the rare 503 {"error": "..."} (a concurrent update to the same license lost a retry race), just retry the request — it's safe to resend.

POST /v1/usage-report

Get usage report for a license.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}

Response:

json
{
  "valid": true,
  "status": "active",
  "expires_at": "2027-12-31T23:59:59.000Z",
  "metrics": [
    {
      "metric": "api_calls_per_day",
      "used": 101,
      "limit": 1000,
      "remaining": 899,
      "exceeded": false
    }
  ]
}

License Information

GET /v1/export-license/:key

Export a signed license object.

Parameters:

  • key (path) - The license key

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "tier": "pro",
  "product_id": "my-product",
  "expires_at": "2027-12-31T23:59:59.000Z",
  "limits": {},
  "sig": "3f2c9e1a7b5d8f0c1e4a6b9d2f5c8e0a1b3d5f7c9e1a3b5d7f9c1e3a5b7d9f1c"
}

issued_to is deliberately omitted — this export is meant to be embedded in a signed payload a consuming app verifies against, and the purchaser's email has no reason to travel with it. GET /v1/export-license/:key/file below is the one export variant that does include it (its own Content-Disposition-filename presentation model already assumes an operator-facing context); .../offline matches this route in leaving it out.

GET /v1/export-license/:key/file

Export a license as an encrypted, signed binary .lic file (HMAC — same signing mechanism as GET /v1/export-license/:key, just packaged as a downloadable file rather than JSON). Returns 403 if the license is revoked or expired — a non-active license can't be re-exported with a fresh valid signature. Unlike the other two export variants on this page, its encrypted payload includes issued_to.

Parameters:

  • key (path) - The license key

Response: Not JSON — Content-Type: application/octet-stream, Content-Disposition: attachment; filename=license-<key>.lic, with the encrypted license bytes as the body.

GET /v1/export-license/:key/offline

Export a license payload RSA-signed with the server's private key. Unlike GET /v1/export-license/:key (HMAC, shared secret), this can be verified with only the public key (see below) — no server contact required at verification time.

Parameters:

  • key (path) - The license key

Response:

json
{
  "license": {
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "tier": "pro",
    "product_id": "my-product",
    "expires_at": "2027-12-31T23:59:59.000Z",
    "limits": {}
  },
  "signature": "base64-encoded-rsa-signature"
}

issued_to is deliberately omitted here too, same reasoning as GET /v1/export-license/:key above.

Verify locally with the RSA public key (no server call needed):

js
import { createVerify } from 'node:crypto';

const verifier = createVerify('SHA256');
verifier.update(JSON.stringify(license));
verifier.end();
verifier.verify(publicKey, signature, 'base64'); // => true/false

This confirms the payload hasn't been tampered with, but it doesn't know about revocations issued after export — use POST /v1/verify-license-file or POST /v1/verify-license-file-base64 instead if you need live revocation checks.

POST /v1/verify-license-file

Verify a license's HMAC signature and status, submitted as a JSON body — despite the name, this does not accept a file upload.

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "tier": "pro",
  "product_id": "my-product",
  "issued_to": "customer@example.com",
  "expires_at": "2025-12-31T23:59:59.000Z",
  "limits": {},
  "sig": "3f2c9e1a7b5d8f0c1e4a6b9d2f5c8e0a1b3d5f7c9e1a3b5d7f9c1e3a5b7d9f1c"
}

key, tier, product_id, and sig are required; issued_to, expires_at, and limits are optional.

Response (200):

json
{
  "valid": true,
  "reason": null
}

On failure (403), reason is one of "Invalid signature", "Expired", or "Revoked or not found" — the DB is checked live on every call, unlike the offline-RSA export above.

POST /v1/verify-license-file-base64

Same live signature + revocation check as above, but for a license file produced by GET /v1/export-license/:key/file — pass its base64/encrypted contents as license_file, not the individual license fields.

Request Body:

json
{
  "license_file": "base64-encoded-encrypted-license-contents"
}

Response (200):

json
{
  "valid": true,
  "license": {
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "tier": "pro",
    "product_id": "my-product",
    "issued_to": "customer@example.com",
    "expires_at": "2025-12-31T23:59:59.000Z",
    "limits": {}
  }
}

Failure reasons: 400 "Missing required fields" (schema validation intercepts a missing license_file before the handler's own "Missing license_file" message can be reached) / "Incomplete license object" / "Malformed or corrupt license_file", or 403 "Invalid signature" / "Expired" / "Revoked or not found".

GET /v1/public-key

Get the RSA public key used to verify offline-signed license files (see above). Fetch it once and cache/embed it in your application — it isn't meant to be called before every verification.

Response:

json
{ "publicKey": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n" }

Admin Endpoints

INFO

All admin endpoints require Bearer token authentication. On self-hosted deployments this is your ADMIN_API_KEY; on the hosted (SaaS) tier it's your tenant's own API key instead — see Authentication above. GET /v1/admin/build-info is the one exception, requiring ADMIN_API_KEY specifically on every deployment mode.

POST /v1/issue-license

Issue a new license.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "tier": "pro",
  "product_id": "my-product",
  "issued_to": "customer@example.com",
  "expires_at": "2027-12-31T23:59:59.000Z",
  "limits": {},
  "max_activations": 5
}

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "status": "issued"
}

On a hosted account, product_id is bound to whichever tenant issues it first — a second tenant issuing under a product_id already claimed by someone else gets 403 {"error": "product_id is owned by a different tenant"}. Not applicable to self-hosted deployments, which have no other tenant to collide with.

POST /v1/revoke-license

Revoke or reactivate a license.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "revoked": true
}

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "status": "revoked"
}

Setting revoked: false reactivates the license, returning status: "active". Returns 409 if the license is already in the requested state, and 403 {"error": "License has expired"} if reactivating a license whose expires_at has already passed.

On a hosted deployment, reactivating (revoked: false) re-runs the same subscription/quota checks POST /v1/issue-license does — un-revoking is the one other way a license's status flips back to active, so it's gated the same way to prevent a tenant working around their issuance quota by revoking and un-revoking instead of issuing fresh. Returns 403 {"error": "Subscription is not active"} if the tenant's billing subscription isn't current, or 403 {"error": "License issuance quota exceeded"} if reactivating would push them over their plan's active-license limit. Not applicable self-hosted, where there's no subscription/quota concept.

DELETE /v1/delete-license

Delete a license permanently.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28"
}

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "deleted": true
}

POST /v1/admin/update-license-terms

Patch expires_at, max_activations, and/or limits on an existing license, without deleting and re-issuing it — re-issuing cascades away every activation on the old key (ON DELETE CASCADE), forcing the customer to re-enter a new key in every deployed copy of their software.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "expires_at": "2028-12-31T23:59:59.000Z",
  "max_activations": 10,
  "limits": {}
}

At least one of expires_at, max_activations, or limits is required; any field you omit is left unchanged. expires_at: null makes the license perpetual, same as POST /v1/issue-license. limits replaces the whole object — there's no partial/merge mode. Lowering max_activations below the license's current activation count is allowed (a soft cap; no existing activation is force-deactivated to fit), but see activations_count in the response below.

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "expires_at": "2028-12-31T23:59:59.000Z",
  "limits": {},
  "max_activations": 10,
  "usage": {},
  "status": "active",
  "activations_count": 3
}

Returns 400 {"error": "No updatable fields provided (expires_at, max_activations, limits)"} if the body has none of the three fields, 404 {"error": "License key not found"} if the key doesn't exist (or isn't owned by your tenant, on the hosted tier), and 400 {"error": "expires_at is required for self-license credentials"} for the one internal self-licensing credential that can't be made perpetual.

GET /v1/list-licenses

List all licenses with optional filtering.

Headers:

Authorization: Bearer your-api-key

Query Parameters:

  • product_id (optional) - Filter by product ID
  • status (optional) - Filter by status (active or revoked)
  • issued_to (optional) - Case-insensitive substring match against issued_to (e.g. searching by customer name/email)
  • limit (optional) - Maximum number of results (1-1000, default 50)
  • offset (optional) - Pagination offset (default 0)

Response:

json
{
  "licenses": [
    {
      "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
      "tier": "pro",
      "product_id": "my-product",
      "issued_to": "customer@example.com",
      "issued_at": "2025-01-01T00:00:00.000Z",
      "expires_at": "2027-12-31T23:59:59.000Z",
      "status": "active",
      "limits": {},
      "usage": {},
      "max_activations": 5,
      "revoked_at": null,
      "activations_count": 2
    }
  ],
  "total": 1
}

activations_count is computed server-side per row (a correlated subquery), so you don't need a separate GET /v1/list-activations/:key call per license just to show seat usage in a table.

GET /v1/admin/stats

Get admin statistics.

Headers:

Authorization: Bearer your-api-key

Response:

json
{
  "totalLicenses": 100,
  "activeLicenses": 95,
  "revokedLicenses": 5,
  "totalActivations": 250,
  "recentActivations": [
    {
      "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
      "instance_id": "unique-instance-id",
      "activated_at": "2025-01-01T00:00:00.000Z"
    }
  ]
}

GET /v1/recent-activations

Get recent activations.

Headers:

Authorization: Bearer your-api-key

Query Parameters:

  • limit (optional) - Number of activations to return (default: 50)

Response:

json
[
  {
    "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
    "instance_id": "unique-instance-id",
    "activated_at": "2025-01-01T00:00:00.000Z"
  }
]

GET /v1/list-activations/:key

List all activations for one specific license key. Requires admin authentication — this returns per-device instance IDs and activation timestamps, which is sensitive enough that it isn't a public endpoint.

Parameters:

  • key (path) - The license key

Headers:

Authorization: Bearer your-api-key

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "activations": [
    {
      "instance_id": "unique-instance-id",
      "activated_at": "2025-01-01T00:00:00.000Z"
    }
  ]
}

POST /v1/admin/reissue-token

Mint a fresh activation token for a specific key/instance_id pair, invalidating the previous one. Use this when a caller has lost the one-time token POST /v1/activate-license issued — there is no self-service recovery path; only an admin can reissue a lost token.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "instance_id": "unique-instance-id"
}

Response:

json
{
  "reissued": true,
  "token": "9f2c1e4a7b3d6f805c1a2b3d4e5f6789"
}

POST /v1/admin/deactivate-by-instance-id

Free a single seat by key/instance_id, without needing that instance's own activation token. Recovery path for a slot occupied by an attacker-chosen instance_id (the license key alone isn't a secret, so anyone holding it can activate with any instance_id they choose) or an instance whose owner is unreachable and never captured its token — POST /v1/deactivate-license requires the opaque token issued at activation time, which only an admin can bypass, and only by identifying the exact instance_id to free (via GET /v1/list-activations/:key).

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "instance_id": "unique-instance-id"
}

Response:

json
{ "deactivated": true }

Returns 404 {"error": "License key not found"} for an unknown key, or 404 {"error": "No activation found for that key and instance_id"} if that instance_id was never activated on this key.

POST /v1/admin/reset-usage

Reset a license's tracked usage — the recovery path for POST /v1/track-usage, which has no way for a license to self-correct once a metric is maxed out. Provide metric to reset just that one metric to 0; omit it to clear every metric on the license at once.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "metric": "api_calls_per_day"
}

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "usage": { "api_calls_per_day": 0 }
}

Returns 404 {"error": "License key not found"} for an unknown key. On the rare 503 {"error": "..."} (a concurrent update to the same license lost a retry race), just retry the request — it's safe to resend.

GET /v1/admin/license/:key

Fetch a single license's full admin record — unlike GET /v1/export-license/:key (the public, signed export shape meant for offline verification), this returns the same operational fields as GET /v1/list-licenses's per-row shape, plus notes.

Parameters:

  • key (path) - The license key

Headers:

Authorization: Bearer your-api-key

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "tier": "pro",
  "product_id": "my-product",
  "issued_to": "customer@example.com",
  "issued_at": "2025-01-01T00:00:00.000Z",
  "expires_at": "2027-12-31T23:59:59.000Z",
  "status": "active",
  "limits": {},
  "usage": {},
  "max_activations": 5,
  "revoked_at": null,
  "notes": "Renewed via phone call 2024-01-15"
}

POST /v1/admin/update-notes

Update a license's freeform internal notes — editable independently of issuing the license. Not part of GET /v1/list-licenses's per-row payload (notes can be arbitrarily long and have no use in a list view); read them back via GET /v1/admin/license/:key.

Headers:

Authorization: Bearer your-api-key

Request Body:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "notes": "Renewed via phone call 2024-01-15"
}

An empty string is a valid value (clears existing notes) — only a missing or wrong-typed notes field is rejected.

Response:

json
{
  "key": "7yChyZcfMG23Dx1sjBoLziPFrH4n-6f28",
  "notes": "Renewed via phone call 2024-01-15"
}

GET /v1/admin/build-info

Returns the raw build watermark for this deployment — admin-gated, unlike the opaque buildFingerprint on the public GET / health check, since the watermark itself may be a readable per-customer identifier. Unlike every other route in this section, this one always requires ADMIN_API_KEY specifically, on every deployment mode — a tenant's own API key is never accepted here, even on the hosted tier.

Headers:

Authorization: Bearer your-admin-api-key

Response:

json
{
  "watermarkId": "customer-acme-corp",
  "fingerprint": "a1b2c3d4e5f6",
  "version": "1.0.0"
}

Billing (Hosted/SaaS Tier)

These two endpoints let a hosted tenant manage their own subscription without an admin key — see Hosted (SaaS) Tier for the plans and quotas they refer to. They're not useful on a self-hosted deployment (MULTI_TENANT=false): with no tenant identity on the request, GET /v1/billing/status reports the same default as an account with no billing history at all, and there's no billing to check out into.

GET /v1/billing/status

Check the calling tenant's current subscription plan and status.

Headers:

Authorization: Bearer your-tenant-api-key

Response:

json
{
  "status": "active",
  "plan": "pro",
  "cancelAtPeriodEnd": false,
  "currentPeriodEnd": null,
  "licensesUsed": 12,
  "licenseLimit": 100
}

licensesUsed and licenseLimit are the same active-license count and plan limit that license issuance enforces against, so they can't drift from what actually blocks a new license. Both are null on a self-hosted server, which has no plans.

plan is null for a tenant with no billing history, which is treated as the Free plan (see Plans and quotas).

cancelAtPeriodEnd is true once a Pro subscription has been canceled but hasn't taken effect yet — status/plan stay exactly as they are until then. currentPeriodEnd (an ISO 8601 timestamp, or null when cancelAtPeriodEnd is false) is when that cancellation actually takes effect and the account reverts to Free. See Managing your subscription for why cancellation isn't instant.

POST /v1/billing/checkout

Start an upgrade to a paid plan, or manage/cancel an existing one. Returns a URL to send the tenant to — this call alone does not change the subscription; only a completed checkout (or, for a cancellation, the end of the current billing period) does.

Request Body:

json
{
  "plan": "pro",
  "interval": "monthly"
}

interval is "monthly" or "annual" (defaults to "monthly" if omitted) — it selects which of Pro's two prices to check out at and has no effect on the plan's license limit. It's meaningless for plan: "free", since there's no price to check out into there.

plan: "free" while the tenant already holds a paid subscription returns a URL to Casazium's subscription-management page instead of a checkout page — that's where cancellation happens. See Managing your subscription for why cancellation isn't instant.

Headers:

Authorization: Bearer your-tenant-api-key

Response:

json
{
  "url": "https://checkout.example.com/session/..."
}

Account Management (Hosted/SaaS Tier)

Tenant self-service, same tenant-key auth as the Billing endpoints above — not useful on a self-hosted deployment (MULTI_TENANT=false), which has no per-tenant credential to rotate.

POST /v1/rotate-api-key

Mint a fresh API key for the calling tenant and retire the old one in the same operation — the old key stops authenticating immediately, not after a grace period. Use this if your key is ever exposed (committed to a repo, embedded in a client build, leaked in a log) — see Rotating your API key for the full picture, including why there's deliberately no overlap window between the old and new key.

Headers:

Authorization: Bearer your-current-tenant-api-key

Response:

json
{
  "apiKey": "9x2K7pQmR4vN8sT1wY3zB6cF5dH0jL2n"
}

apiKey is the new plaintext key — the only time it's returned; only its hash is stored server-side afterward. Returns 400 {"error": "API key rotation is only available for hosted accounts"} on a self-hosted deployment.

Software Distribution

License-gated software releases and signed update-check manifests — see Software Distribution for the full picture, including the scope boundary these endpoints are built around: License Server never stores or proxies your software's actual bytes, only metadata and a signed pointer to wherever you already host it. Every endpoint below except POST /check-update is tenant self-service, same tenant-key auth as Billing/Account Management above; POST /check-update is different — it's called by your own end-user apps, gated by one of their license keys, not your tenant API key.

POST /v1/register-release

Register a new release for a product_id you own — the same check-and-claim ownership rule as POST /issue-license applies here too.

Headers:

Authorization: Bearer your-tenant-api-key

Request Body:

json
{
  "product_id": "widget-pro",
  "version": "2.3.0",
  "channel": "stable",
  "platform": "darwin-arm64",
  "artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
  "checksum": "sha256:...",
  "release_notes": "Fixes a rare crash on startup."
}

channel defaults to "stable" if omitted. artifact_url must already be reachable by your own end users — this call never uploads or moves a file, and must be http:// or https:// (anything else, including a non-URL string, is rejected). release_notes is capped at 10,000 characters; product_id/version/channel/platform/artifact_url/checksum are each capped too (200/100/100/100/2048/256 characters respectively) — generous for any real value, closing off unbounded input on a field that gets RSA-signed and returned on every check-update call.

Response:

json
{
  "id": 42,
  "status": "published"
}

Errors:

  • 400 {"error": "product_id uses a reserved prefix"}product_id starts with _casazium_.
  • 400 {"error": "artifact_url must be a valid http(s) URL"}artifact_url isn't http:///https://.
  • 403 {"error": "product_id is owned by a different tenant"}product_id is already claimed by another tenant.
  • 403 {"error": "Subscription is not active"} — your subscription payment has failed (hosted tier only).
  • 403 {"error": "Release limit reached (500 published releases) for this product_id/channel/platform - unpublish old releases before registering new ones"} — that bucket is at its cap; unpublish an old release first.
  • 409 {"error": "A release already exists for this product_id/version/channel/platform"} — a published release already exists for this exact combination. Unpublish it, then register again — that's also how you get an accidentally-unpublished version back; there's no separate "republish" call.

GET /v1/list-releases

List your own registered releases, optionally filtered by product_id, channel, platform, and/or status (published or unpublished), paginated the same way as GET /v1/list-licenses.

Headers:

Authorization: Bearer your-tenant-api-key

Response:

json
{
  "releases": [
    {
      "id": 42,
      "product_id": "widget-pro",
      "version": "2.3.0",
      "channel": "stable",
      "platform": "darwin-arm64",
      "artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
      "checksum": "sha256:...",
      "release_notes": "Fixes a rare crash on startup.",
      "status": "published",
      "created_at": "2026-08-19T00:00:00.000Z"
    }
  ],
  "total": 1
}

GET /v1/release/

Fetch a single release you own, including signature — the one field GET /list-releases doesn't return per row.

Headers:

Authorization: Bearer your-tenant-api-key

Response:

json
{
  "id": 42,
  "product_id": "widget-pro",
  "version": "2.3.0",
  "channel": "stable",
  "platform": "darwin-arm64",
  "artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
  "checksum": "sha256:...",
  "release_notes": "Fixes a rare crash on startup.",
  "status": "published",
  "signature": "base64-RSA-SHA256-signature...",
  "created_at": "2026-08-19T00:00:00.000Z"
}

Returns 404 {"error": "Release not found"} for a nonexistent id or one belonging to a different tenant — the same information-hiding behavior as a cross-tenant POST /unpublish-release.

POST /v1/unpublish-release

Mark a release unpublished — a state flip, not a delete, so the row and its history survive. Every one of your end users on that release's channel/platform immediately stops seeing it in POST /check-update results. There's no dedicated "republish" call, but getting the version back is straightforward: call POST /register-release again with the exact same {product_id, version, channel, platform} — the uniqueness constraint that normally rejects a duplicate only applies to published releases, so re-registering an unpublished one always succeeds.

Headers:

Authorization: Bearer your-tenant-api-key

Request Body:

json
{
  "id": 42
}

Response:

json
{
  "id": 42,
  "status": "unpublished"
}

A release id belonging to a different tenant returns 404 {"error": "Release not found"} — the same information-hiding behavior as POST /revoke-license for a cross-tenant key.

POST /v1/check-update

Called by your own end-user apps, not by you directly — license-gated "is there a newer release" check that doubles as the download manifest, since there are no separate bytes for License Server to hand back: the response already is the complete, verifiable answer. Same base license validation as POST /verify-license (a valid, active, non-expired key) — no activation-instance requirement, so an app can check for updates before its first activation.

Request Body:

json
{
  "key": "your-customers-license-key",
  "product_id": "widget-pro",
  "channel": "stable",
  "platform": "darwin-arm64",
  "current_version": "2.2.9"
}

channel defaults to "stable" if omitted. product_id must match the license's own product_id — a license for one product can't be used to probe a different product's release channel.

product_id matching is case/whitespace-insensitive. channel and platform are exact, case-sensitive matches against the values used at registration — "Stable" and "stable" are different channels here. A mismatch on any of these fails closed (update_available: false), not an error — if updates never seem to arrive, double-check these values match registration exactly.

Response:

json
{
  "update_available": true,
  "release": {
    "product_id": "widget-pro",
    "version": "2.3.0",
    "channel": "stable",
    "platform": "darwin-arm64",
    "artifact_url": "https://cdn.example.com/widget-pro/2.3.0/widget-pro-mac.dmg",
    "checksum": "sha256:...",
    "release_notes": "Fixes a rare crash on startup.",
    "signature": "base64-RSA-SHA256-signature...",
    "issued_at": "2026-08-19T00:00:00.000Z"
  }
}

release is null and update_available is false when nothing published matches, or when current_version is already current. "Latest" is the highest semver version among your published releases for that product_id/channel/platform — not registration order, so backporting a hotfix to an older line (e.g. publishing 1.9.1 after 2.0.0 is already out) never shadows the genuinely newer release. A release whose version doesn't parse as semver never wins over one that does; among releases that tie on version, or where none parse, the most recently registered one is used.

signature is an RSA/SHA256 signature (base64) over this exact response object minus signature itself, computed once when the release was registered — verify it against GET /v1/public-key the same way an offline-exported license file is verified, before your app trusts artifact_url or checksum.

Field order matters when verifying

The signature is computed over the plain JSON.stringify() of the manifest — there's no canonical/sorted-key serialization. To verify, your client must reconstruct the exact object in the field order shown in the response above (product_id, version, channel, platform, artifact_url, checksum, release_notes, signature, issued_at, with signature itself excluded) and stringify that, not a re-serialization that reorders keys. This matters most outside JavaScript: Python's json.dumps(manifest, sort_keys=True) or Go's encoding/json on a map (which sorts keys) will produce a signature mismatch even for an untampered response, with no error explaining why. See Auto-Update Checking for a worked example.

issued_at is part of what's signed, so it can't be forged separately from the rest of the manifest — but the signature alone verifies forever, even after a release is unpublished. If your app caches a check-update response instead of always calling it fresh, check issued_at yourself and reject anything older than you're willing to trust; otherwise a captured response for a since-unpublished (e.g. known-bad) release replays successfully indefinitely. The server has no way to force this check — it's the one piece of this API's security model that's the integrator's own responsibility, not something check-update itself can enforce.

Error Responses

All endpoints may return the following error responses:

400 Bad Request

json
{
  "error": "Invalid request parameters"
}

403 Forbidden

json
{
  "error": "Unauthorized"
}

404 Not Found

json
{
  "error": "License not found"
}

429 Too Many Requests

json
{
  "statusCode": 429,
  "error": "Too Many Requests",
  "message": "Rate limit exceeded. Try again in 60 seconds.",
  "retryAfter": 60
}

500 Internal Server Error

json
{
  "error": "An unexpected error occurred"
}