Skip to content

API integration quickstart

If your app can make an HTTPS request, it can talk to Keylight directly — no SDK, no lease to verify, just a POST and clean JSON back. This is the right fit for a server-side app (a SaaS backend, a CLI, a daemon) where the license check happens on infrastructure you control.

  1. Create an app in the dashboard — this is what a license key belongs to.
  2. Decide this is the right integration for you (server-side licensing, no SDK).
  3. Create a token scoped to licenses:runtime.
  4. Store it as a server-side secret.
  5. activate a device, validate on subsequent checks, deactivate to free a seat.

Every license belongs to an app. If you haven’t already, go to the dashboard and add one — see Apps & key types for the fields (display name, key prefix, key types). You’ll need the app’s productId for every call below.

Go to Settings → API tokens and create a new token with the licenses:runtime scope. Like every Keylight token it’s a personal access token — klm_... — shown once, at creation.

licenses:runtime is deliberately narrow: it can activate, validate, and deactivate devices, and nothing else. It cannot mint or revoke licenses, read customers, or touch billing — give it to a license-checking service without also handing over account administration. (For the broader token model — scopes, the confirmation gate, other endpoints — see the Management API reference.)

Treat the token like any other backend secret — an environment variable or your secrets manager, never committed, never sent to a browser or bundled into an app binary:

Terminal window
export KEYLIGHT_API_TOKEN=klm_...
POST /v1/licenses/activate

Call this the first time a customer’s key is used on a given device. device_id is a stable identifier you choose — a machine id, a container id, a hash of whatever identifies the installation. Reusing the same device_id on a later call is idempotent: it re-confirms the same activation instead of consuming a second seat.

Terminal window
curl -X POST https://api.keylight.dev/v1/licenses/activate \
-H "Authorization: Bearer $KEYLIGHT_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"product_id": "notes",
"license_key": "ACME-XXXX-XXXX-XXXX-XXXX",
"device_id": "host-7f3a1c",
"name": "prod-web-1"
}'
{
"activated": true,
"status": "active",
"expires_at": null,
"entitlements": ["pro"],
"device_id": "host-7f3a1c",
"revalidate_after": 1714236000
}

The Idempotency-Key header is optional but recommended on this call: a retry with the same key replays the original response (with Idempotent-Replay: true) instead of re-running the activation, so a network retry can never double-consume a seat.

POST /v1/licenses/validate

Use this to re-check a license you’ve already activated — on app start, on a schedule, or before gating a feature. It reads authoritative state (so a revoked or expired key is caught immediately) and returns the same clean JSON shape.

Terminal window
curl -X POST https://api.keylight.dev/v1/licenses/validate \
-H "Authorization: Bearer $KEYLIGHT_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"product_id": "notes",
"license_key": "ACME-XXXX-XXXX-XXXX-XXXX",
"device_id": "host-7f3a1c"
}'
{
"valid": true,
"status": "active",
"expires_at": null,
"entitlements": ["pro"],
"device_id": "host-7f3a1c",
"revalidate_after": 1714236000
}

There’s no lease to decode — read the fields straight off the JSON body:

FieldTypeMeaning
valid / activated / deactivatedbooleanThe determinate answer for that call.
statusstringactive, fallback, expired, revoked, reminted, inactive, not_found, seat_limit_reached, or plan_limit.
expires_atinteger | nullUnix seconds, or null for a perpetual license.
entitlementsstring[]Feature flags granted by the license’s key type.
device_idstring | nullEchoes the device_id you sent.
revalidate_afterintegerUnix seconds — don’t call validate again before this. See reducing load.
reasonstringPresent only when the call is not valid/activated/deactivated — a stable, machine-readable code such as revoked, expired, or seat_limit_reached. Branch your logic on this, not on prose.

A rejected license is still a 200valid: false with a reason is a legitimate answer, not an error. This also means a nonexistent license key and a revoked one look the same on the wire, so an attacker probing keys can’t tell which is which. Genuine errors (bad auth, malformed body, unknown product) come back as 4xx with a structured envelope:

{ "error": { "code": "product_not_found", "message": "No product with that product_id" } }

Branch on error.code, not error.message — the message is for logs and humans, the code is stable across releases.

The runtime endpoints share a generous per-workspace budget (thousands of calls per minute) — enough for normal activate/validate traffic with plenty of headroom. If you exceed it, calls come back as 429 with the same { error: { code, message } } envelope; back off and retry. The way to stay well under it is not to poll: cache each validate result until the revalidate_after timestamp it returns, and let webhooks push you revocations and renewals instead of checking for them — both covered under Reduce load: cache + webhooks below.

POST /v1/licenses/deactivate

Free the seat when a customer decommissions a device, moves to a new one, or cancels.

Terminal window
curl -X POST https://api.keylight.dev/v1/licenses/deactivate \
-H "Authorization: Bearer $KEYLIGHT_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"product_id": "notes",
"license_key": "ACME-XXXX-XXXX-XXXX-XXXX",
"device_id": "host-7f3a1c"
}'
{ "deactivated": true }

Deactivating a device that’s already inactive is idempotent — it returns deactivated: true rather than an error.

The klm_... bearer token is a secret that grants full licenses:runtime access to your account. Anyone who has it can activate and deactivate devices and validate any license on your app. Treat it exactly like a database password:

  • Keep it in your server’s environment or secrets manager.
  • Never send it to a browser, embed it in a desktop or mobile binary, or log it.
  • Rotate it (mint a new token, revoke the old one) if it’s ever exposed.

This is a different trust level from the SDK key (X-Keylight-SDK-Key), which is designed to ship inside a client build — see SDK key for what it does and doesn’t protect against.

If the licensing check needs to happen inside code you distribute — a macOS app, a Windows desktop app, a mobile app, anything running on a device you don’t control — use a Keylight SDK instead of this API. The SDKs are built for that trust boundary: they use the embeddable SDK key, not a bearer token, and verify a signed Ed25519 lease offline so the app stays entitled without a live call. This API integration mode is for the opposite case: a server you run, checking licenses on your own infrastructure.

Every validate call is a live read. Two habits keep it cheap at scale:

  • Cache the result until revalidate_after. It’s a stable interval per license, not a fixed TTL — don’t call validate again before it, and don’t invent your own shorter polling interval “to be safe.”
  • Use webhooks for revocation, not tight polling. If you need to react to a refund or a revoked license the moment it happens, subscribe to a webhook (e.g. license.refunded) instead of shortening your validate interval. Webhooks are pushed the instant the event happens; polling faster only adds load and still has a gap.
StatusMeaning
400Malformed request body.
401Missing, invalid, or revoked bearer token.
403Token doesn’t carry the licenses:runtime scope.
404product_id doesn’t exist on this account.

Every error body is { "error": { "code": "...", "message": "..." } }.