Security
Licensing
Domain-bound license keys, checked offline in the browser and renewed by your backend.
How keys work
A license key is a signed statement: who it is for, which domains, and until when. Keys last 30 days. The SDK verifies the Ed25519 signature in the browser against public keys compiled into it, so the check itself never calls home. Each key names the signing key it was made with (kid); this lets Kymoscope rotate signing keys, and an SDK release accepts the key ids it knows.
You can paste a key into your code (licenseKey) and replace it each month, or let the SDK fetch fresh keys from your backend (license.get, see Automatic renewal).
const engine = Kymoscope.createEngine({ licenseKey: 'eyJzdWIiOiJhY21lIiwiZG9tYWlucyI6WyJhcHAuYWNtZS5jb20iXSwi…' })
const licensed = await engine.licensed // true or false once checkedGet your first key
- In Kymoscope, open your organization and its SDK license tab (
app.kymoscope.com/org/<id>?tab=license). It is there when your plan includes SDK licenses, for the organization’s owner and admins. - Enter the hostnames your product serves the charts from (see Domains) and issue a key. These become your organization’s license domains: every later key, renewed or issued by hand, covers them.
- Copy the key into
createEngine({ licenseKey }). It works for 30 days; to stop replacing it by hand, set up automatic renewal below.
Automatic renewal
Your backend asks Kymoscope for a fresh key with your API key; the browser asks your backend. The API key never reaches the browser, and the license key is not a secret: it only works on your domains.
- Create an API key in Kymoscope (Account → API & real-time streaming, or a project’s API key). Its owner must be an owner or admin of the organization. A key has two parts,
kymoscope_<auth>.<secret>: send only the auth part (everything before the.), as for publishing streams. The secret part never leaves your machine; Kymoscope stores only a hash of the auth part. - Add a backend endpoint that calls
POST https://clahbbkqdskkhymymoge.supabase.co/functions/v1/license-renewwith headerx-api-key(no body) and serves thelicensefrom the response as text. Every Kymoscope backend function, this one and the live-stream ones, lives underhttps://clahbbkqdskkhymymoge.supabase.co/functions/v1. The response is{ "license": "…", "exp": 1767225600 }, withexpin Unix seconds. - Pass
license.gettocreateEngine, returning the key from your endpoint.
The Node and Python examples cache the key in process memory, which suits one long-running server. They make one renewal at a time (concurrent requests wait for it), and after a failure, even with no key yet, they answer 503 without calling Kymoscope again for an hour. The curl example is for testing only.
// Node 18+ (global fetch). Serve currentLicense() as text at GET /kymoscope-license.
// Caches in process memory: one long-running server. Serverless or several instances: see below.
const FUNCTIONS = 'https://clahbbkqdskkhymymoge.supabase.co/functions/v1'
const AUTH = process.env.KYMOSCOPE_API_KEY.split('.')[0] // the auth part only: the secret never leaves this machine
const DAY = 86_400, HOUR = 3_600
let cached = null // { license, exp }
let nextTry = 0 // after a failure, no call to Kymoscope before this
let inFlight = null // one renewal at a time; concurrent requests await it
export async function currentLicense() {
const now = Date.now() / 1000
const fresh = cached && cached.exp - now > 7 * DAY // reuse until 7 days before it expires
if (!fresh && now >= nextTry) {
inFlight ??= renew().finally(() => { inFlight = null })
await inFlight
}
if (cached && cached.exp > Date.now() / 1000) return cached.license
throw new Error('no license key yet') // → 503, without calling Kymoscope again until nextTry
}
async function renew() {
try {
const r = await fetch(`${FUNCTIONS}/license-renew`, {
method: 'POST', headers: { 'x-api-key': AUTH }, signal: AbortSignal.timeout(10_000),
})
const body = await r.json().catch(() => ({}))
if (!r.ok) throw new Error(`license-renew ${r.status} ${body.error ?? ''}`)
cached = body // { license, exp }
nextTry = 0
} catch (e) {
nextTry = Date.now() / 1000 + HOUR // also from a cold start: back off for an hour
console.error(e.message)
}
}
// Express
app.get('/kymoscope-license', async (req, res) => {
try { res.type('text/plain').send(await currentLicense()) } catch { res.sendStatus(503) }
})Serverless or several instances: process memory starts empty on every cold start and every instance, so keep the key in shared storage (Redis, a KV store, a database row), and the time of the next allowed try with it:
// { license, exp, nextTry } in Redis, a KV store or a database row, shared by every instance
const now = Date.now() / 1000
let saved = (await store.get('kymoscope-license')) ?? { nextTry: 0 }
if (!(saved.exp - now > 7 * 86_400) && now >= saved.nextTry) {
const fresh = await callLicenseRenew() // { license, exp }, or null on failure
saved = fresh ? { ...fresh, nextTry: 0 } : { ...saved, nextTry: now + 3_600 }
await store.set('kymoscope-license', saved)
}
return saved.exp > now ? saved.license : null // null: answer 503<script src="/vendor/kymoscope/kymoscope-embed.js"></script>
<script>
const engine = Kymoscope.createEngine({
license: {
get: () => fetch('/kymoscope-license').then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status))))),
},
})
</script>license.getis called when the engine is created, and again 7 days before the key it holds expires.- A rejected promise, a key that does not verify, or no answer within 30 seconds keeps the current key and retries every hour. The badge appears when the held key expires, not before.
- Without
licenseKey, nothing shows while the firstgetruns (30 seconds at most);isLicensed()isnulluntil it settles. - With both
licenseKeyandlicense,licenseKeyis used until the first successfulget. engine.licensedresolves after the firstget(or its failure) and never rejects; later renewals updateisLicensed()andstate.licensed.
Closed networks
Self-service keys only cover public hostnames, and renewal needs your backend to reach Kymoscope. For an air-gapped network, an intranet host (localhost, single-label names, IP addresses), or a desktop shell served from localhost, contact Kymoscope for a long key: issued by us for those hosts, valid up to one year, used as licenseKey with nothing to renew.
Errors
license-renew answers errors as { "error": code, "message"? }.
| Response | What to do |
|---|---|
401 missing_api_key | No x-api-key header. Send the auth part of your API key. |
401 invalid_api_key | Unknown key. Check you send only the part before the “.”, of a key that exists. |
403 revoked_api_key | The key was revoked. Create a new one. |
403 not_org_admin | The key’s owner is not an owner or admin of an organization. Use a key of one who is. |
403 project_archived | The API key belongs to an archived project. Unarchive the project, or create a key in an active project or a personal key. |
403 key_wrong_org | The key belongs to a different organization than its owner’s current one. Create a new key. |
403 license_inactive | Your organization’s plan does not include SDK licenses (or it ended). Contact Kymoscope. |
409 no_license_domains | No license domains are registered yet. Issue your first key in the SDK license tab. |
429 rate_limited | Over 24 renewals for your organization in the last 24 hours. Cache the key in your backend; renew once a day or less. |
405 method_not_allowed | Use POST. |
500 misconfigured | A problem on Kymoscope’s side. Keep the key you hold and retry later; contact us if it persists. |
500 internal_error | A temporary error. Keep the key you hold and retry later. |
In the browser, a failed renewal changes nothing visible while the held key is valid: the SDK logs [kymoscope] license renewal failed (<reason>): keeping the current key, keeps the key and retries every hour. The badge appears exactly when the held key expires, if no renewal has succeeded by then. With license.get and no licenseKey, no badge shows while the first get runs (30 seconds at most); it shows only if that first key does not arrive or does not verify. Since a key lasts 30 days and renewal starts 7 days before the end, you have a week to fix an error before anyone sees it.
Without a valid key
Everything keeps working. Each mounted chart shows a small “Powered by Kymoscope” badge in its corner, and the console logs why, once per engine: [kymoscope] license <reason>: showing attribution. engine.licensed resolves to false and engine.isLicensed() returns false (it is null while the check runs).
| Reason | Meaning |
|---|---|
missing | No key was passed. |
malformed | The key is not in the expected format. |
bad_signature | The key was altered, not issued by Kymoscope, or signed with a key id this SDK release does not know. |
expired | The key’s end date has passed. |
domain | The page’s hostname is not in the key’s domains. |
unsupported | The browser lacks WebCrypto Ed25519. |
Terms
The SDK is proprietary software under the Kymoscope SDK License Agreement (LICENSE.md in the package). Evaluation without a key is allowed; production use needs a key for each domain. Removing or hiding the attribution, tampering with the license check, and redistributing the SDK are not permitted.
Domains
A key lists exact hostnames (app.acme.com) and wildcards (*.acme.com, which matches subdomains but not acme.com itself). List every hostname your product serves the charts from, including staging. In the SDK license tab:
- 1 to 10 entries.
- Public hostnames with at least two labels; no
localhost, single-label intranet names or IP addresses, and nothing undernip.io,sslip.io,xip.io,lvh.meorlocaltest.me, which point at any IP address or at localhost. - No wildcards on shared hosting or public suffixes, where anyone can get a subdomain (
*.vercel.app,*.github.io,*.run.app,*.co.ukand many more). List your exact hostname there instead (acme.vercel.app). - Saving a new list replaces the previous one; keys already issued keep their own domains until they expire.
For anything else, see Closed networks.
Checking a key yourself
import { verifyLicense } from '@kymoscope/sdk'
const result = await verifyLicense(key, location.hostname)
// { ok: true, sub: '<organization id>', exp: 1767225600 } or { ok: false, reason: 'expired' }
// exp is in Unix seconds