License Your App With No SDK: The Keylight API Path
Not every app gets a first-party SDK. Keylight ships SDKs for Swift, Rust, JavaScript, C++, and C#, and that still leaves Go, Python, Flutter, Elixir, and every internal tool written in whatever the team already knew. For those, the answer is a license key API: two HTTPS calls, a runtime token, and nothing to compile in.
I built the API path because the SDK path was starting to look like a gate. If your stack was not on the list, licensing became “wait for Keylight to catch up” or “build your own server.” Neither is acceptable for a solo builder who wants to ship this week. So the runtime API is a first-class integration mode now, and it is deliberately small.
This post walks through it end to end. One CLI command to mint a token, the two calls in curl, the same two calls in Python and Go, the one rule about caching that everyone gets wrong, and how to run the day-to-day operations from the terminal.
When the API path is the right call
Use the API path when the license check runs on infrastructure you control. A web service that gates paid features per customer. A worker process that only starts jobs for licensed accounts. A CLI tool that phones home from a build machine. A Flutter or Go app where the entitlement decision happens on your own server, not on the device.
Do not use it inside code you hand to customers. The runtime token is a secret with real reach: it can activate and validate any license on your account. The docs say to treat it like a database password, and that is the right instinct. A token embedded in a downloadable binary is a token you have already leaked. If the license check must run inside a desktop app, a game, or a plugin that ships to users, pick an SDK. The SDKs hold a public key, not a secret, and they verify a signed lease on the device without a network call.
That points at the other trade-off. The API path is online-first. There is no lease to verify locally, so there is no offline mode. Every validate call is a live read of the authoritative license state. That is a feature for server-side use, because a revoked or expired key is caught on the very next check. It is a non-starter for an app that has to open on a plane.
One more thing the API does not do: trial length and free tier settings do not reach it. Those are device-side concepts that the SDKs read from your dashboard at launch. In API mode, your server owns pre-purchase policy. If you want a 14-day trial for a Go service, your service decides what “day 14” means.
So the honest rule: online, on your own infrastructure, in any language. That is the API path.
Mint a runtime token with one command
Install the CLI and run init in your project folder:
keylight init --mode api
No prompts. The command detects the project, picks your app (pass --product <id> if you have several), opens a browser for a short device-flow approval, and mints a token scoped to exactly one permission: licenses:runtime. It writes the result to .env, owner-only, and adds .env to .gitignore if you are in a git repo.
The token is never stored in the CLI’s own config. It is your app’s secret, in your app’s env file, and nowhere else.
When it finishes, .env holds one line, KEYLIGHT_API_TOKEN=klm_..., and the command prints a copy-ready curl for your product plus a link to the quickstart. Source the file and you are ready to make the first call.
That scope is the whole security story of the API path, so it is worth being precise about it. A licenses:runtime token can activate, validate, and deactivate devices. It cannot mint licenses. It cannot revoke them. It cannot read your customers or touch billing. If it leaks, someone can run the activation lifecycle on your keys until you rotate it, and nothing else. Rotation is one more keylight init --mode api.
The license key API: activate and validate
There are three routes, all POST, all JSON, all under https://api.keylight.dev/v1/licenses/. Activate binds a license to a device identifier you choose. Validate checks it. Deactivate frees the seat. The device_id is your handle: a hostname, a container id, a hash of a machine identifier. Keylight echoes it back and counts seats against it.
Activate:
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
}
Validate:
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
}
Four fields do the work. status is one of active, fallback, expired, revoked, reminted, inactive, not_found, seat_limit_reached, or plan_limit. entitlements is the list of feature labels you defined in the dashboard for that key’s tier, so gating is a string lookup. expires_at is unix seconds or null for lifetime keys. revalidate_after is the next time you should ask again.
Two behaviors that matter in production. Activate is idempotent on the same device_id: calling it twice re-confirms the seat instead of consuming a second one, so a crash-loop on boot does not eat your customer’s activations. And a rejected license is a 200 with valid: false and a reason, not a 4xx. Every key gets the same response shape, so nobody can probe the endpoint to learn which keys exist.
Real errors are real errors: 401 for a bad token, 403 for a missing scope, 404 for an unknown product, 429 when you are rate limited.
The same two calls in Python and Go
Nothing about the API assumes a library. Here is activate and validate with the Python standard library:
import json, os, urllib.request
BASE = "https://api.keylight.dev/v1/licenses"
TOKEN = os.environ["KEYLIGHT_API_TOKEN"]
def call(route: str, body: dict) -> dict:
req = urllib.request.Request(
f"{BASE}/{route}",
data=json.dumps(body).encode(),
headers={
"Authorization": f"Bearer {TOKEN}",
"Content-Type": "application/json",
},
method="POST",
)
with urllib.request.urlopen(req) as resp:
return json.load(resp)
body = {"product_id": "notes", "license_key": key, "device_id": device_id}
first = call("activate", {**body, "name": "prod-web-1"})
if not first["activated"]:
raise SystemExit(f"activation refused: {first['reason']}")
check = call("validate", body)
if check["valid"] and "pro" in check["entitlements"]:
enable_pro()
And in Go with net/http:
package licensing
import (
"bytes"
"encoding/json"
"net/http"
"os"
"slices"
)
const base = "https://api.keylight.dev/v1/licenses/"
type Result struct {
Valid bool `json:"valid"`
Activated bool `json:"activated"`
Status string `json:"status"`
ExpiresAt *int64 `json:"expires_at"`
Entitlements []string `json:"entitlements"`
DeviceID string `json:"device_id"`
RevalidateAfter int64 `json:"revalidate_after"`
Reason string `json:"reason,omitempty"`
}
func call(route string, body map[string]string) (*Result, error) {
payload, _ := json.Marshal(body)
req, _ := http.NewRequest("POST", base+route, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("KEYLIGHT_API_TOKEN"))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var out Result
return &out, json.NewDecoder(resp.Body).Decode(&out)
}
func Activate(key, deviceID string) (*Result, error) {
return call("activate", map[string]string{
"product_id": "notes", "license_key": key, "device_id": deviceID,
})
}
func Validate(key, deviceID string) (*Result, error) {
return call("validate", map[string]string{
"product_id": "notes", "license_key": key, "device_id": deviceID,
})
}
Then, wherever the paid feature lives:
r, err := Validate(key, deviceID)
if err == nil && r.Valid && slices.Contains(r.Entitlements, "pro") {
enablePro()
}
That is the entire integration. Two functions, one env var, no dependency beyond the standard library. Flutter, Elixir, PHP, Ruby, a shell script on a cron: same shape.
The one rule: cache until revalidate_after, then ask again
This is where hand-rolled integrations go wrong, in both directions.
The first mistake is caching forever. A service validates a key at boot, stores “licensed” in memory, and never asks again. The customer refunds a week later, the key is revoked on the server, and the service keeps running paid features for months. The server owns expiry and entitlements. Your copy of the answer is only as fresh as your last call.
The second mistake is hammering. Someone reads “every validate is a live read” and validates on every request “to be safe.” Now a license check sits on the hot path of every API call, and the rate limiter starts returning 429 on the busiest day of the year.
The API tells you the right interval. Every validate response carries revalidate_after, a unix timestamp. Cache the result until then and call again once it passes. It is a stable interval per license, chosen by the server, not a fixed TTL you should second-guess. Do not shorten it. Do not lengthen it.
For the cases where waiting for the next interval is too slow, the answer is a webhook, not a tighter loop. Subscribe to license.refunded and the related events in the dashboard and Keylight tells your server the moment a key stops being valid. Polling catches the state eventually. The webhook catches it now.
If you already have a subscription model in your head, this will feel familiar. It is the same discipline as a session token with an expiry: honor the expiry, refresh after it, and let the issuer tell you when something is revoked early.
Driving the rest from the CLI
The runtime token runs the lifecycle inside your app. Everything else, creating keys, revoking them, listing them, is a management job, and the CLI covers it with your own login rather than the app’s token:
keylight login
keylight licenses create --product notes --key-type pro \
--customer-email [email protected] --send-email
keylight licenses list --product notes --status active --limit 20
keylight licenses revoke <license-id>
login prints a short code and opens a browser to approve it, so it works over SSH and on headless machines. create prints the raw key exactly once and can email it to the buyer for you. list shows keys masked. revoke needs a browser approval every time, on purpose, because it is the one command that takes access away from a paying customer.
Every command takes --json, so the same operations are scriptable. I wrote up the patterns in Manage Your App Licensing From the Terminal and the four jobs worth automating in Four Licensing Jobs You Can Stop Doing By Hand. Neither one changes for the API path. The management side is the same whether your app uses an SDK or two curl calls.
Where the API path fits
If your stack has a Keylight SDK, use it. You get offline leases, dashboard-owned trial settings, and a public key in the binary instead of a secret. If your stack does not, or the check belongs on your server anyway, the license key API is the whole product with none of the compile step: activate, validate, deactivate, one scoped token, and a clear caching rule.
Setup is keylight init --mode api, then two POST requests in whatever language you already write. The license keys themselves, tiers, seat limits, and payment hooks are the same ones every other Keylight app uses. See license keys for what the dashboard side looks like, pricing for plan limits, and the API integration quickstart for the full reference including deactivate, idempotency, and webhook payloads. The CLI install steps are in the CLI docs.
If you are on a stack this post does not cover and something is missing, send your feedback and I will extend the API for it.
Nicolas Demanez, Founder
Frequently asked
Do I need a Keylight SDK to use the license key API?+
No. The API path is two HTTPS calls, activate and validate, authenticated with a runtime token. Any language that can send a POST request and parse JSON can license an app with it.
Can I put the runtime token inside an app I ship to customers?+
No. The runtime token is a server-side secret. Treat it like a database password and keep it on infrastructure you control. If the license check has to run inside code you distribute, use one of the Keylight SDKs instead.
How often should I call validate?+
Every validate response carries a revalidate_after timestamp. Cache the result until then and call again after it passes. Do not invent a shorter interval, and listen for the license.refunded webhook to catch revocations right away.
Does the API path work offline?+
No. Every call is a live read of the license state on the server. Offline verification with a signed lease lives in the SDKs. The API path is built for services and tools that are online when they run.
Ready to ship?
Create your account and start licensing your apps in under a minute. Free forever tier included.
Start Free