Config
/{tenantId}/{productId}/config The product settings your dashboard owns — trial length and free tier — signed so a client can trust them. Uses the common headers & path parameters.
This is the explicit-refresh escape hatch, not the delivery path. The same settings — in the same signed envelope, byte for byte — ride on validate and the keyless beacon, which every SDK already calls. Verify it the same way wherever it arrives; one verification path covers all three routes. Reach for this endpoint when you want a settings refresh at a moment of your choosing — a settings pane, a manual “check for changes” — or when you are integrating directly and would rather ask than wait for the next beacon.
Response (200)
Section titled “Response (200)”Two shapes, and the difference matters.
A trial length is configured — a signed envelope covering both settings:
{ "trial_duration_days": 14, "free_tier_enabled": true, "kid": "k1", "issued_at": 1788671279, "expires_at": 1788757679, "signature": "base64…"}No trial length has ever been configured — the unsigned, trial-less body:
{ "free_tier_enabled": true }| Field | Type | Description |
|---|---|---|
trial_duration_days | number | Days a new install may run unlicensed. Absent means never configured — keep your own default. 0 means trials are off. |
free_tier_enabled | boolean | Always present. |
kid | string | Which key signed this. Look it up in the keyset. |
issued_at / expires_at | number | Unix seconds. Signatures are valid for 24 hours. |
signature | string | Base64 Ed25519 over the canonical string below. |
Absent is not zero
Section titled “Absent is not zero”trial_duration_days is omitted, never 0, when a tenant has not configured one. Sending 0 would tell every existing install that trials are off. Absent means “no opinion” — keep the length you compiled in. An explicit 0 does mean off. Treat the two as different values, not as one falsy case, or turning trials off will be indistinguishable from never having set them.
Verify the signature
Section titled “Verify the signature”Do this. The settings decide how long your app runs unlicensed, so an attacker who can answer this request — a proxy, a hosts entry, anything on the network path — otherwise just replies {"trial_duration_days": 3650}. Verifying is the difference between a setting and a suggestion.
The signed bytes are this exact string, UTF-8, |-separated, no spaces:
cfg1|{kid}|{tenantId}|{productId}|{issued_at}|{expires_at}|{trial_duration_days}|{free_tier_enabled}free_tier_enabled serialises as the literal true or false. So the example above signs:
cfg1|k1|acme|widget|1788671279|1788757679|14|trueFetch the public key from the keyset — public, no auth, cacheable — and check the signature with Ed25519.
const res = await fetch(`${base}/${tenantId}/${productId}/config`, { headers: { "X-Keylight-SDK-Key": sdkKey },});const cfg = await res.json();
if ("trial_duration_days" in cfg) { // A trial length must arrive signed. Reject it otherwise — see below. const keyset = await (await fetch(`${base}/${tenantId}/.well-known/keylight-keys`)).json(); const raw = keyset.keys[cfg.kid]; if (!raw) throw new Error("unknown kid");
const now = Math.floor(Date.now() / 1000); if (now > cfg.expires_at) throw new Error("config signature expired");
const payload = [ "cfg1", cfg.kid, tenantId, productId, cfg.issued_at, cfg.expires_at, cfg.trial_duration_days, String(cfg.free_tier_enabled), ].join("|");
const key = await crypto.subtle.importKey( "raw", Uint8Array.from(atob(raw), c => c.charCodeAt(0)), { name: "Ed25519" }, false, ["verify"], ); const ok = await crypto.subtle.verify( { name: "Ed25519" }, key, Uint8Array.from(atob(cfg.signature), c => c.charCodeAt(0)), new TextEncoder().encode(payload), ); if (!ok) throw new Error("bad config signature");}Four checks, and why each one
Section titled “Four checks, and why each one”| Check | Without it |
|---|---|
The signature verifies against kid’s public key | Anything on the path can rewrite the settings |
now <= expires_at | A once-valid response is replayed forever, pinning an old trial length after you change it |
tenantId / productId in the payload match what you asked for | A signed config for a cheap app is replayed at an expensive one — same tenant, same key, different product |
A present trial_duration_days carries a signature | The downgrade: an attacker strips kid/signature and returns a bare {"trial_duration_days": 3650}, and a client that only verifies when a signature is present accepts it |
That last one is the easy mistake. Verify-if-present is not a check. The rule is: no trial_duration_days without a valid signature. An unsigned body may only ever carry free_tier_enabled.
If a tenant has no signing key
Section titled “If a tenant has no signing key”The route still answers, unsigned, rather than taking a working feature offline — and so do validate and the beacon. Under the rule above your client ignores the trial length and keeps its own — which is the safe direction, and the failure is logged server-side as config.signing_failed.
In the SDKs
Section titled “In the SDKs”You do not write any of this if you use a Keylight SDK: each one verifies the same envelope on every route it arrives on — validate, the keyless beacon and this endpoint go through one check — against the keys you compiled in. It is behind a switch, off by default:
| SDK | Switch | Minimum version |
|---|---|---|
| Swift | Keylight.manager(..., requireSignedConfig: true) | 0.12.1 |
| JavaScript | new Keylight({ requireSignedConfig: true, trustedKeys }) | 0.4.0 |
| Rust | KeylightConfig::builder(...).require_signed_config(true) | 0.6.0 |
| C# | .RequireSignedConfig() on the builder | 0.4.0 |
| C++ | cfg.requireSignedConfig = true | 0.2.2 |
Why off: Keylight signs an app’s settings only once that app has a Trial length set in the dashboard. Turning the switch on for an app without one rejects every legitimate response and pins the install to its compiled-in value. Set a trial length first, then enable it.
Two consequences of how the SDKs are built, both deliberate:
- Verification trusts only compiled-in keys. The keyset helpers (
fetchKeysetand friends) exist to bootstrap a project, not as a trust root — a keyset fetched over the same connection that serves the settings can be forged by whoever forges the settings. If you enable the switch, pin the key in your build. - A failed verification keeps your compiled-in value. Nothing unverified is ever cached. Rotating to a key id a shipped build does not know leaves that build on its last known settings until it updates — a freeze, not a failure.
Caching
Section titled “Caching”Signatures last 24 hours, so cache a verified response and re-fetch when it nears expires_at. Do not call this on every launch: the settings already ride validate and the keyless beacon, and this endpoint is rate-limited per IP and against the tenant ceiling like every other SDK route.
Errors
Section titled “Errors”| Status | Meaning |
|---|---|
401 | Missing or wrong X-Keylight-SDK-Key |
403 | Tenant state does not allow reads |
404 | Unknown tenant or product |
429 | Per-IP rate limit or tenant ceiling |