Skip to content

Config

GET /{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.

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 }
FieldTypeDescription
trial_duration_daysnumberDays a new install may run unlicensed. Absent means never configured — keep your own default. 0 means trials are off.
free_tier_enabledbooleanAlways present.
kidstringWhich key signed this. Look it up in the keyset.
issued_at / expires_atnumberUnix seconds. Signatures are valid for 24 hours.
signaturestringBase64 Ed25519 over the canonical string below.

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.

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|true

Fetch 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");
}
CheckWithout it
The signature verifies against kid’s public keyAnything on the path can rewrite the settings
now <= expires_atA once-valid response is replayed forever, pinning an old trial length after you change it
tenantId / productId in the payload match what you asked forA 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 signatureThe 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.

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.

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:

SDKSwitchMinimum version
SwiftKeylight.manager(..., requireSignedConfig: true)0.12.1
JavaScriptnew Keylight({ requireSignedConfig: true, trustedKeys })0.4.0
RustKeylightConfig::builder(...).require_signed_config(true)0.6.0
C#.RequireSignedConfig() on the builder0.4.0
C++cfg.requireSignedConfig = true0.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 (fetchKeyset and 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.

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.

StatusMeaning
401Missing or wrong X-Keylight-SDK-Key
403Tenant state does not allow reads
404Unknown tenant or product
429Per-IP rate limit or tenant ceiling
  • Keyset — the public keys, including the one that signs this
  • Validate — carries the same settings on every licensed check
  • Keyless — carries them for unlicensed devices