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.
The flow
Section titled “The flow”- Create an app in the dashboard — this is what a license key belongs to.
- Decide this is the right integration for you (server-side licensing, no SDK).
- Create a token scoped to
licenses:runtime. - Store it as a server-side secret.
activatea device,validateon subsequent checks,deactivateto free a seat.
1. Create an app
Section titled “1. Create an app”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.
2. Create a licenses:runtime credential
Section titled “2. Create a licenses:runtime credential”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.)
3. Store it server-side
Section titled “3. Store it server-side”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:
export KEYLIGHT_API_TOKEN=klm_...4. Activate a device
Section titled “4. Activate a device”/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.
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.
5. Validate on subsequent checks
Section titled “5. Validate on subsequent checks”/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.
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}Reading the response
Section titled “Reading the response”There’s no lease to decode — read the fields straight off the JSON body:
| Field | Type | Meaning |
|---|---|---|
valid / activated / deactivated | boolean | The determinate answer for that call. |
status | string | active, fallback, expired, revoked, reminted, inactive, not_found, seat_limit_reached, or plan_limit. |
expires_at | integer | null | Unix seconds, or null for a perpetual license. |
entitlements | string[] | Feature flags granted by the license’s key type. |
device_id | string | null | Echoes the device_id you sent. |
revalidate_after | integer | Unix seconds — don’t call validate again before this. See reducing load. |
reason | string | Present 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 200 — valid: 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.
Rate limits
Section titled “Rate limits”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.
6. Deactivate a device
Section titled “6. Deactivate a device”/v1/licenses/deactivate Free the seat when a customer decommissions a device, moves to a new one, or cancels.
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.
Security: server-only vs client
Section titled “Security: server-only vs client”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.
Reduce load: cache + webhooks
Section titled “Reduce load: cache + webhooks”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 callvalidateagain 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.
Errors
Section titled “Errors”| Status | Meaning |
|---|---|
400 | Malformed request body. |
401 | Missing, invalid, or revoked bearer token. |
403 | Token doesn’t carry the licenses:runtime scope. |
404 | product_id doesn’t exist on this account. |
Every error body is { "error": { "code": "...", "message": "..." } }.
Related
Section titled “Related”- Management API reference — the full token model, scopes, and the
rest of the
/v1surface. - SDKs and platforms — when to use a client SDK instead.
- SDK key — the client-embeddable counterpart to this token.
- Webhooks overview — react to revocations and renewals in real time.
- License lifecycle — what each
statusvalue means.