# Kilden documentation (full) > Complete developer documentation for Kilden, concatenated for LLM consumption. Each page starts with an H1 and a `URL:` line pointing at its canonical location on https://docs.kilden.io --- # Quickstart URL: https://docs.kilden.io/quickstart/ > From snippet to your first event in the live tail, in about two minutes. By the end of this page you'll have Kilden on your site and you'll watch your first event arrive live. ## 1. Create a project Sign in at [app.kilden.io](https://app.kilden.io) and create a project. You land on the onboarding page, which shows your install snippet with the write key already filled in — and a live indicator waiting for your first event. ## 2. Paste the snippet Copy the snippet from onboarding (or use this one, replacing `YOUR_WRITE_KEY`) and paste it before ``: ```html ``` The snippet loads the SDK asynchronously and queues any calls made before it finishes — nothing is lost, and it never blocks your page. Prefer a package? See [installing with npm](/sdk/#install-with-npm). ## 3. Watch the first event arrive Open a page with the snippet installed. Loading the page emits a `$pageview` automatically — and the onboarding screen flips to **"First event received"**, then takes you to the **live tail**: a real-time stream of every event your project receives. Keep it open while you instrument; it's the fastest feedback loop you have. ## 4. Track something of your own ```js kilden.track('signup_completed', { plan: 'pro' }); ``` It appears in the live tail within a couple of seconds, alongside the autocaptured clicks and pageviews. ## Next steps - [Identify your users](/guides/identifying-users/) so pre- and post-signup activity join up. - [Verify identity](/guides/identity-verification/) so nobody can send events as someone else. - [Autocapture & privacy](/guides/autocapture/) — tune what gets captured automatically. - [Feature flags](/guides/feature-flags/) and [campaigns](/guides/campaigns/) run on the same events you just started sending. --- # SDK overview & installation URL: https://docs.kilden.io/sdk/ > The Kilden web SDK — installation via snippet or npm, the client surface, and the guarantees it makes. `@kilden/sdk` is the Kilden web SDK: event capture, identity, autocapture, feature flags and session replay in one client. The core is dependency-free and small (under 15 KB gzipped; autocapture and replay load as separate lazy chunks only when used). ## Install with the snippet Paste this before ``. It creates `window.kilden` with a stub queue **before** the bundle loads, so no call is ever lost — the SDK replays queued calls in their original order once it loads: ```html ``` The project onboarding page in the panel gives you this exact snippet with your write key and API host already filled in. ## Install with npm ```sh npm install @kilden/sdk ``` ```ts import kilden from '@kilden/sdk'; kilden.init('YOUR_WRITE_KEY'); kilden.track('signup_completed', { plan: 'pro' }); ``` The default export is a singleton — call `init` once and import it anywhere. If you need isolated clients (multiple projects on one page, tests), use `createClient`: ```ts import { createClient } from '@kilden/sdk'; const analytics = createClient('YOUR_WRITE_KEY', { debug: true }); analytics.track('signup_completed'); ``` ## The client surface | Method | What it does | | --- | --- | | `init(writeKey, options?)` | Initialize. See [Configuration](/sdk/configuration/) | | `track(event, properties?, options?)` | Queue an event. See [Tracking events](/sdk/tracking/) | | `identify(userId, traits?, options?)` | Tie the anonymous visitor to your user id. See [Identity methods](/sdk/identity/) | | `setPersonProperties(set, setOnce?)` | Update person traits | | `reset()` | Log out: rotate anonymous id, clear token and super properties | | `register(properties)` / `unregister(key)` | Persisted super properties merged into every event | | `getDistinctId()` / `getSessionId()` | Current distinct id / session id | | `optOut()` / `optIn()` / `hasOptedOut()` | Persisted capture kill switch | | `setIdentityToken(token)` | Set or clear the identity verification JWT | | `flush()` | Force-send the queue. The only method that returns a `Promise` | | `use(plugin)` / `removePlugin(name)` | Event pipeline plugins. See [Plugins](/sdk/plugins/) | | `isFeatureEnabled(key)` / `getFeatureFlag(key)` / `onFeatureFlags(cb)` | Feature flags. See [Feature flags](/sdk/feature-flags/) | | `startSessionRecording()` / `stopSessionRecording()` / `getReplayId()` | Session replay. See [Session replay](/sdk/session-replay/) | | `group(groupType, groupKey, properties?)` | **Reserved.** Group analytics is not yet available; calling it is a safe no-op that logs a warning in debug mode | ## Guarantees These behaviors are contractual — the SDK's public surface is frozen and versioned: - **The public API never throws.** Invalid input is discarded, with a warning only when `debug: true`. - **Your data is never mutated** — no trimming, lowercasing or normalizing. - **Calls made before `init` are queued** and processed at init, in order. Nothing is lost. - Events are sent in batches and retried with exponential backoff. Each event carries a client-generated UUID v7, so retries are idempotent — the pipeline deduplicates by UUID. - On page unload the queue is flushed with `fetch(keepalive)` (or `sendBeacon` as a fallback) so tail events survive navigation. - `optOut()` persists and stops all capture and network traffic. --- # Configuration URL: https://docs.kilden.io/sdk/configuration/ > Every InitOptions field of the Kilden web SDK, with defaults and behavior notes. `init(writeKey, options?)` accepts an `InitOptions` object. All fields are optional. ```ts import kilden from '@kilden/sdk'; kilden.init('YOUR_WRITE_KEY', { flushAt: 20, flushIntervalMs: 5000, autocapture: true, debug: false, }); ``` ## InitOptions | Option | Type | Default | Notes | | --- | --- | --- | --- | | `apiHost` | `string` | `https://ingest.kilden.io` | Base URL for ingestion. The default is Kilden Cloud — only set it if you self-host. One host serves `/capture`, `/decide` and `/replay` — there is no separate host for feature flags or replay | | `flushAt` | `number` | `20` | Queue size that triggers a flush | | `flushIntervalMs` | `number` | `5000` | Max time an event waits in the queue | | `persistence` | `'localStorage' \| 'cookie' \| 'memory'` | `'localStorage'` | Where the anonymous id, super properties and opt-out state live. Falls back to `memory` automatically when the chosen storage is unavailable | | `autocapture` | `boolean \| AutocaptureOptions` | `true` | Automatic click/submit/pageview capture. With `false` the autocapture code is never even downloaded. See [Autocapture & privacy](/guides/autocapture/) | | `debug` | `boolean` | `false` | Log warnings (invalid input, `$`-prefixed custom events, no-op calls) to the console. The SDK never throws either way | | `beforeSend` | `(event: EventPayload) => EventPayload \| null` | — | Transform or drop (return `null`) every event before it is queued — **including** `$autocapture` and other automatic events | | `identityToken` | `string` | — | Identity verification JWT, sent as `Authorization: Bearer` with each batch. See [Identity verification](/guides/identity-verification/) | | `getIdentityToken` | `() => Promise` | — | Token refresh callback: called before the current token expires and after a `401` | | `sessionRecording` | `boolean \| SessionRecordingOptions` | `false` | Session replay. Off by default — recording is expensive and privacy-sensitive. See [Session replay](/sdk/session-replay/) | ## AutocaptureOptions Pass an object instead of `true` to tune autocapture: | Option | Type | Default | Notes | | --- | --- | --- | --- | | `captureClicks` | `boolean` | `true` | Emit `$autocapture` on clicks | | `captureSubmits` | `boolean` | `true` | Emit `$autocapture` on form submits | | `captureChanges` | `boolean` | `false` | Emit `$autocapture` on input changes. Only the fact that something changed is recorded — never the value | | `ignoreSelectors` | `string[]` | — | CSS selectors to exclude from capture | | `maskAllText` | `boolean` | `false` | Replace all element text in `$elements` with `"*"` | | `allowUrls` | `(string \| RegExp)[]` | — | Restrict autocapture to matching URLs | ## SessionRecordingOptions Pass an object instead of `true` to tune replay: | Option | Type | Default | Notes | | --- | --- | --- | --- | | `sampleRate` | `number` | `0.1` | Fraction of sessions to record, `0..1`. Decided once per session and sticky across reloads | | `minimumDurationMs` | `number` | `2000` | Recordings shorter than this are discarded | | `maskAllInputs` | `boolean` | `true` | Mask everything the user types. Deliberately the opposite default of autocapture: a replay shows keystrokes, so masking is opt-out | | `maskAllText` | `boolean` | `false` | Mask all page text | | `blockSelectors` | `string[]` | — | CSS selectors to exclude from the recording entirely | | `recordCanvas` | `boolean` | `false` | Record `` contents (heavy, rarely useful) | ## Batching and delivery - Events queue in memory and flush when the queue reaches `flushAt` events or the oldest event is `flushIntervalMs` old, whichever comes first. - On `pagehide`/`visibilitychange` the queue is flushed immediately. With an identity token present the SDK uses `fetch(url, { keepalive: true })` (it can carry the `Authorization` header); without one — or without keepalive support — it uses `sendBeacon`, with the token inside the request body as a documented fallback. - Failed flushes retry with exponential backoff. Every event has a client-generated UUID v7, so retries are idempotent end to end. - Batches are gzip-compressed via `CompressionStream` when the browser supports it. ## Identifiers the SDK manages - **Anonymous id**: `anon_` + UUID v7, generated on first visit and persisted in the configured storage. - **Session id** (`$session_id` on every event): UUID v7, rotated after 30 minutes of inactivity. --- # Tracking events URL: https://docs.kilden.io/sdk/tracking/ > track(), super properties, flush, opt-out, and the shape of an event. ## track() ```ts kilden.track('checkout_completed', { plan: 'pro', total: 49.9 }); ``` ```ts track(event: string, properties?: Properties, options?: TrackOptions): void ``` `track` returns `void`, not a `Promise` — events are queued and batched in the background. If you need to wait for delivery (rare; the SDK already flushes on page unload), call [`flush()`](#flush). - `properties` is a JSON object (`Record`). Kilden never validates or types your properties — the event envelope is typed, the payload is yours. - `options.timestamp` (`Date | string`) backdates the event. Without it, the event is stamped when `track` is called, and the server corrects for client clock skew. - Event names have no schema. One convention is enforced softly: names starting with `$` are reserved for Kilden system events. Sending one from `track` logs a warning in debug mode but the event is **still sent** — the SDK never breaks your calls. Every event automatically includes context properties: `$current_url`, `$referrer`, `$session_id`, `$device_type`, `$screen_width`, `$screen_height`, `$lib`, `$lib_version`, and `$utm_source` / `$utm_medium` / `$utm_campaign` / `$utm_term` / `$utm_content` parsed from the URL. See [Events & properties](/concepts/events-and-properties/). ## The event payload This is the shape of an event as it enters the queue — and as `beforeSend` and [plugins](/sdk/plugins/) see it: ```ts interface EventPayload { uuid: string; // UUID v7, generated by the SDK event: string; distinct_id: string; properties: Properties; // context $-properties already merged in timestamp: string; // ISO 8601 } ``` ## Super properties: register() / unregister() Super properties are persisted and merged into every subsequent event: ```ts kilden.register({ app_version: '3.2.0', experiment_wave: 'q3' }); kilden.unregister('experiment_wave'); ``` They survive reloads (in the configured `persistence`) and are cleared by `reset()`. ## flush() ```ts await kilden.flush(); ``` Sends everything in the queue now. The only method on the client that returns a `Promise`. ## Reading identifiers ```ts kilden.getDistinctId(); // "anon_018f..." or your user id after identify() kilden.getSessionId(); // current $session_id (rotates after 30 min idle) ``` ## Opt-out ```ts kilden.optOut(); // persisted: stops all capture and network traffic kilden.optIn(); // re-enable kilden.hasOptedOut(); // boolean ``` While opted out nothing is captured or sent, including feature-flag loads from `/decide` (already-cached flag values keep answering). ## group() — reserved ```ts group(groupType: string, groupKey: string, properties?: Properties): void ``` B2B group analytics is part of the frozen surface but **not yet available**: calling `group()` is a safe no-op that logs a warning in debug mode. It exists so the install snippet never needs to change when it ships. --- # Identity methods URL: https://docs.kilden.io/sdk/identity/ > identify(), setPersonProperties(), reset() and setIdentityToken() — the SDK surface for user identity. These are the API signatures. For when and how to use them, read [Identifying users](/guides/identifying-users/); for the trust model behind `token`, read [Identity verification](/guides/identity-verification/). ## identify() ```ts identify(userId: string, traits?: Properties, options?: { token?: string }): void ``` ```ts kilden.identify('user_4821', { email: 'ada@example.com', plan: 'pro' }); ``` Ties the current anonymous visitor to your user id. Emits a `$identify` event carrying the anonymous id (`$anon_distinct_id`), which the pipeline uses to link or merge the two identities. From this call on, events carry `distinct_id = userId`. - `traits` are applied to the person as a `$set`. - `options.token` sets the identity verification JWT in the same call — equivalent to calling `setIdentityToken(token)` first. - Identifying also reloads feature flags for the new distinct id. ## setPersonProperties() ```ts setPersonProperties(set: Properties, setOnce?: Properties): void ``` ```ts kilden.setPersonProperties( { plan: 'enterprise' }, // $set: overwrites { signup_source: 'organic' }, // $set_once: only if not already set ); ``` Updates the person's traits. `set` overwrites existing values; `setOnce` writes only keys that don't exist yet. Events are immutable — this changes the person, never past events. ## reset() ```ts kilden.reset(); ``` Call on logout. Generates a **new** anonymous id, clears the identity token and super properties, and reloads feature flags. Without it, the next user on the same browser would be linked to the previous user's identity. ## setIdentityToken() ```ts setIdentityToken(token: string | null): void ``` Sets (or clears, with `null`) the identity verification JWT. The token is sent with every batch as `Authorization: Bearer ` and verified server-side. Prefer passing `identityToken` / `getIdentityToken` in [`init` options](/sdk/configuration/) so refresh is handled for you; use this method when your app fetches tokens on its own schedule. --- # Plugins URL: https://docs.kilden.io/sdk/plugins/ > The before/enrichment/after event pipeline — transform, enrich or observe every event. Every event the SDK produces — `track` calls, `$autocapture`, `$pageview`, `$identify` — flows through a plugin pipeline before it reaches the send queue: ``` track()/identify()/$autocapture/... → before → enrichment → after → send queue ``` ## Writing a plugin ```ts import type { KildenPlugin } from '@kilden/sdk'; const scrubEmails: KildenPlugin = { name: 'scrub-emails', stage: 'before', process(event) { if (typeof event.properties.email === 'string') { const { email, ...rest } = event.properties; return { ...event, properties: rest }; } return event; }, }; kilden.use(scrubEmails); ``` ```ts interface KildenPlugin { name: string; stage: 'before' | 'enrichment' | 'after'; setup?(client: KildenClient): void; process?(event: EventPayload): EventPayload | null; // null = drop teardown?(): void; } ``` ## Stage semantics | Stage | Can transform | Can drop (`return null`) | Typical use | | --- | --- | --- | --- | | `before` | yes | yes | filters, sampling, PII scrubbing | | `enrichment` | yes | *shouldn't* — dropping here is a design smell | adding properties | | `after` | no | no | observation only; the return value is ignored | Within a stage, plugins run in registration order. ## Rules - **`process` is synchronous.** Do async work in `setup`, or in an `after` plugin with its own queue. - `use(plugin)` with an existing name **replaces** that plugin (same slot in the order). - `removePlugin(name)` calls the plugin's `teardown()` if it has one. - `use()` before `init` queues like any pre-init call. - **A plugin that throws is isolated**: the error is caught (logged only with `debug: true`) and the event continues with the previous stage's result. The public API never throws because of a plugin. ## beforeSend is sugar `InitOptions.beforeSend` registers an anonymous `before` plugin under the hood — observable behavior is identical to writing the plugin yourself. It's the quickest way to transform or drop everything, including automatic events: ```ts kilden.init('YOUR_WRITE_KEY', { beforeSend(event) { if (event.event === '$autocapture') return null; // drop return event; }, }); ``` ## Built-in features are plugins too Sessions (`$session_id`), context properties, `$utm_*` parsing and autocapture are implemented as internal plugins on this same pipeline. Autocapture registers itself late, when its lazy chunk finishes loading — plugins you register are never blocked by it. --- # Feature flags (SDK) URL: https://docs.kilden.io/sdk/feature-flags/ > isFeatureEnabled, getFeatureFlag and onFeatureFlags — reading flags from the web SDK. For creating flags, targeting and rollout mechanics, read the [feature flags guide](/guides/feature-flags/). This page is the SDK surface. ## Reading flags ```ts // Boolean check — false until flags load, false for unknown keys if (kilden.isFeatureEnabled('new-checkout')) { renderNewCheckout(); } // Full value — boolean flag or the variant key of a multivariate flag const variant = kilden.getFeatureFlag('pricing-test'); // true | false | "variant-b" | undefined ``` ```ts isFeatureEnabled(key: string): boolean; getFeatureFlag(key: string): boolean | string | undefined; ``` Flags load from `POST {apiHost}/decide` at `init` and are cached in memory for the session. Neither getter ever blocks: - `isFeatureEnabled` returns `false` until flags load. - `getFeatureFlag` returns `undefined` until flags load — so you can distinguish "off" (`false`) from "not loaded yet" (`undefined`). Every flag in the project comes back in each response (inactive ones as `false`), so a missing key after load means the flag doesn't exist. ## onFeatureFlags() ```ts kilden.onFeatureFlags((flags) => { // flags: Record setupUi(flags['new-checkout'] === true); }); ``` The callback fires when flags first load, on every reload, and immediately if you subscribe after they already loaded. Use it to avoid rendering before flags are known. ## When flags reload Flags reload automatically after `identify()` and `reset()` — the evaluation depends on the distinct id, so the SDK converges to the new id's values right away. Because bucketing hashes the distinct id, a user near a rollout boundary can legitimately change sides when they go from anonymous to identified; see [the guide](/guides/feature-flags/#deterministic-bucketing) for why. There is no bootstrap option: the first evaluation of a page load is always an async `/decide` call. Gate flag-dependent UI on `onFeatureFlags` rather than assuming values at startup. ## Exposure events The **first access** to a flag per session — through either getter — emits a `$feature_flag_called` event with `$flag_key` and `$flag_value`, which powers exposure charts in the panel. The dedupe is keyed by `$session_id` and survives page reloads; it resets when the session rotates. Unknown flag keys emit nothing. While [opted out](/sdk/tracking/#opt-out), `/decide` is not called; cached values keep answering. --- # Session replay (SDK) URL: https://docs.kilden.io/sdk/session-replay/ > startSessionRecording, stopSessionRecording, getReplayId and the sessionRecording init option. For privacy defaults, sampling and how replay works end to end, read the [session replay guide](/guides/session-replay/). This page is the SDK surface. ## Enabling recording Replay is **off by default** — recording is expensive and privacy-sensitive: ```ts kilden.init('YOUR_WRITE_KEY', { sessionRecording: true, // or a SessionRecordingOptions object }); ``` | Value | Behavior | | --- | --- | | `false` (default) | Never records. The replay code is not even downloaded, and remote config cannot turn it on — a local `false` always wins | | `true` or options object | Loads the replay chunk eagerly and records subject to sampling and the project's remote kill switch | | unset + manual `startSessionRecording()` | Loads the replay chunk lazily on the first manual start | See [`SessionRecordingOptions`](/sdk/configuration/#sessionrecordingoptions) for `sampleRate`, `minimumDurationMs`, masking and canvas options. Recording also requires session replay to be **enabled for the project** in the panel: `/decide` returns `{"sessionRecording": {"enabled": bool, "sampleRate": float}}`, which acts as a remote kill switch and remote sample rate. Turning it off in project settings stops new recordings within seconds — without a deploy. ## Methods ```ts kilden.startSessionRecording(); // force-start (also loads the chunk if needed) kilden.stopSessionRecording(); kilden.getReplayId(); // string while recording, null otherwise ``` While the replay package hasn't loaded, all three are safe no-ops (`getReplayId` returns `null`) that log a warning with `debug: true`. ## Sampling is sticky Whether a session is recorded is decided **once per session** and persisted: reloads within the session keep the same decision, and the dice re-roll when the session rotates (30 minutes of inactivity). A recording never spans two sessions — rotation cuts it. ## Correlation with analytics Recordings share the session id with events: the replay of a session is always joinable 1:1 with its event timeline. When a recording starts, the SDK emits a `$session_recording_started` event so you can query or filter sessions that have a replay. --- # Identifying users URL: https://docs.kilden.io/guides/identifying-users/ > How anonymous visitors become identified users, what identify() does to the person graph, and the rules that keep it safe. Kilden separates **events** (immutable) from **persons** (mutable). Every event carries a `distinct_id`; identity resolution maps distinct ids to persons so that pre-signup and post-signup activity belong to the same human. ## Anonymous by default On first visit the SDK generates an anonymous id — `anon_` + UUID v7 — persists it, and stamps it on every event. A person is created for it on the first event. No call needed. ## identify() on login or signup When you know who the user is, call `identify` with **your** stable user id (a database id works better than an email — emails change): ```ts kilden.identify('user_4821', { email: 'ada@example.com', plan: 'pro' }); ``` This emits a `$identify` event that carries both ids (the user id as `distinct_id`, the anonymous id as `$anon_distinct_id`). The pipeline then does one of two things: - **Your user id is new** (the common case, ~90%): it's added as another distinct id of the anonymous visitor's person. All the pre-signup history is already attributed — nothing moves. - **Your user id already has a person** (login on a second device): the two persons **merge**. Call `identify` on every page where the user is logged in, not just at login — it's idempotent, and it's what keeps new devices and cleared browsers linked. ## What a merge does - The **older person wins** (its `created_at`); the newer person's distinct ids are re-pointed to it. - Traits: the winner keeps its values on conflict; the loser only fills gaps. A later `$set` overwrites either way. - Past events keep working in queries: person merges are resolved at query time, so historical events attributed to the losing person count toward the winner. ## Updating traits ```ts kilden.setPersonProperties( { plan: 'enterprise' }, // $set — overwrites { signup_source: 'organic' }, // $set_once — only if absent ); ``` Traits live on the person and can change; events are frozen at capture time. If you need a value to be trustworthy (billing plan, revenue), send it [server-side with a secret key](/concepts/trust-levels/) or as [signed traits](/guides/identity-verification/#signed-traits) — anything a browser sends can be forged by the browser's owner. ## reset() on logout ```ts kilden.reset(); ``` Rotates the anonymous id, clears the identity token and super properties. Skip this and the next person to log in on that browser gets linked to the previous user's anonymous trail. ## Rules that protect you - **Two known ids never merge automatically.** If an identified user somehow gets identified as a *different* known id, Kilden refuses the merge (it's almost always an instrumentation bug), logs it, and attributes the event to the user id's person. The web SDK deliberately has no `alias()` — `identify` covers the browser flow completely; `$alias` exists in the pipeline for server SDKs, where linking two known ids is legitimate. - **Distinct-id cap per person** (100 by default) prevents runaway id accumulation ("person bloat"); past the cap, events still attribute but new mappings aren't created. - With [identity verification](/guides/identity-verification/) in `enforce` mode, an unverified `identify` from a browser cannot create or modify identity mappings at all. --- # Identity verification URL: https://docs.kilden.io/guides/identity-verification/ > Sign a short-lived JWT in your backend so nobody can send events as your users from a browser console. Your public write key identifies your project, but it authenticates nobody: anyone can open a browser console and send events as `distinct_id: "ceo@yourcompany.com"`. Identity verification closes that hole — your backend signs a short-lived JWT proving *this browser really is this user*, and Kilden verifies it on every identified event. Anonymous traffic is unaffected: there is no identity to forge, so anonymous events never need a token. ## How it works 1. In **project settings** you create an **identity secret** (separate from your write keys). Each secret has a `kid` (key id), and several can be active at once, so rotation has no cutover window. 2. On login, your backend signs a JWT with that secret — **HS256**, with the user id in `sub`. 3. The SDK sends the token with every event batch as `Authorization: Bearer `. 4. The pipeline verifies it: signature against an active `kid`, `exp` valid, and `sub` equal to the event's `distinct_id`. The result is stored on every event as `verified`. ## The token Claims payload: ```json { "sub": "user_4821", "iat": 1752192000, "exp": 1752195600, "traits": { "plan": "pro" } } ``` - `sub` — **required**. Must equal the `distinct_id` the browser will use (what you pass to `identify()`). - `exp` — **required**. Keep it short (minutes to hours); the SDK refreshes automatically. - `kid` — **required, in the JWT header** (not the payload). Identifies which project identity secret signed the token. - `traits` — optional. [Signed traits](#signed-traits), applied server-verified. - Algorithm: **HS256 only.** Tokens signed with anything else (including `none`) fail verification. ## Signing in your backend ### Node ```js import jwt from 'jsonwebtoken'; // From your project settings → Identity verification. // The secret never leaves your backend. const KILDEN_IDENTITY_SECRET = process.env.KILDEN_IDENTITY_SECRET; const KILDEN_IDENTITY_KID = process.env.KILDEN_IDENTITY_KID; app.get('/api/kilden-token', requireAuth, (req, res) => { const token = jwt.sign( { sub: req.user.id, // must match the id you pass to kilden.identify() traits: { plan: req.user.plan }, }, KILDEN_IDENTITY_SECRET, { algorithm: 'HS256', expiresIn: '1h', keyid: KILDEN_IDENTITY_KID }, ); res.json({ token }); }); ``` ### PHP ```php $userId, // must match the id passed to kilden.identify() 'iat' => $now, 'exp' => $now + 3600, 'traits' => (object) $traits, ]; return JWT::encode($payload, $GLOBALS['secret'], 'HS256', $GLOBALS['kid']); } ``` (Uses [`firebase/php-jwt`](https://github.com/firebase/php-jwt); the fourth argument sets the `kid` header.) ## Wiring the SDK Let the SDK manage the token lifecycle — it refreshes about a minute before `exp` and retries once on a `401`: ```ts kilden.init('YOUR_WRITE_KEY', { getIdentityToken: async () => { const res = await fetch('/api/kilden-token'); if (!res.ok) return null; return (await res.json()).token; }, }); // after login: kilden.identify('user_4821'); ``` Alternatives: pass a token you already have at init (`identityToken: '...'`), per call (`identify(id, traits, { token })`), or imperatively (`setIdentityToken(token)`). On logout, `reset()` clears it. The token travels per **batch**, in the `Authorization: Bearer` header. One documented exception: the end-of-page flush can fall back to `sendBeacon`, which can't carry headers, so the token goes in the body as `identity_token` — same TLS protection, checked second. ## Enforcement modes Verification is **always computed** — every identified event gets `verified: true/false` regardless of mode. The mode only governs what happens to unverified **identity mutations** (`$identify`, `$set`, `$set_once`): | Mode | Unverified identity mutations from identified users | | --- | --- | | `off` | Applied normally | | `monitor` | Applied normally, but each one is counted so you can see exactly what `enforce` would block | | `enforce` | **Not applied**: they can't create persons, link identities or write traits. Identity resolution for unverified identified events becomes lookup-only — existing mappings still attribute, but nothing new is created | Because `monitor` measures precisely what `enforce` would block, the safe rollout is: instrument the token → watch the unverified counter drop to zero → flip to `enforce`. Flipping changes no data, only behavior. Two rules hold in every mode: - **Events are never dropped.** Failed verification keeps the event with `verified: false`; it's excluded from trust-sensitive consumers (campaign triggers, and messenger reads once messaging over identified channels ships) but stays in your analytics. - **Anonymous events always pass.** Their `verified: false` is a convention (there's nothing to verify), not distrust. Server-side events sent with a secret key are `verified: true` — the secret key *is* the authentication. ## Signed traits Traits inside the JWT are applied as a server-verified `$set` and take priority over unsigned traits **in the same event**. Use them for values the browser shouldn't be able to claim about itself (plan, role, account status). For facts that drive money or messaging, prefer sending the event itself [server-side](/concepts/trust-levels/). ## Rotation Create a new secret (new `kid`) in project settings, deploy your backend signing with it, then disable the old `kid`. Multiple active secrets mean zero-downtime rotation; tokens signed by a disabled `kid` immediately fail verification. --- # Autocapture & privacy URL: https://docs.kilden.io/guides/autocapture/ > What autocapture records, what it never records, and every knob to control it. With `autocapture: true` (the default) the SDK records page interactions without any instrumentation: clicks, form submits and pageviews from the first minute. It ships as a lazy chunk — with `autocapture: false` the code is never even downloaded. ## One event: $autocapture Every captured interaction produces the **same** event, `$autocapture`, described by its properties: - `$event_type`: `click` | `submit` | `change`. - `$elements`: the DOM chain from the interaction target upward (about 10 levels). Per element: `tag`, `classes`, `attr_id`, `text`, `nth_child`, and `href` for links. A single event keeps the event namespace small: "clicked element X" is a filter over `$elements` in queries, not a separate event name. ## Pageviews and SPAs - `$pageview` fires on page load. - Client-side navigations fire it too: the SDK hooks the History API (`pushState`, `replaceState`, `popstate`), so single-page apps get a `$pageview` per route change with no router integration. - `$pageleave` fires on `pagehide`. ## Context properties on every event Autocaptured or not, every event carries `$current_url`, `$referrer`, `$session_id`, `$device_type`, `$screen_width`, `$screen_height`, `$lib`, `$lib_version`, and `$utm_source` / `$utm_medium` / `$utm_campaign` / `$utm_term` / `$utm_content` parsed from the URL. ## What is never captured These are hard guarantees, not defaults: - **Input values are never captured.** A `change` interaction records that something changed — never what. - **Password fields don't even produce an event**: a `change` on `input[type=password]` is ignored entirely. - Session replay is a separate, opt-in system with its own (stricter) [privacy defaults](/guides/session-replay/#privacy). ## Controlling capture **In your markup** — add `data-kilden-no-capture` to any element to exclude it and all its descendants: ```html
``` **In init options** — pass an object instead of `true`: ```ts kilden.init('YOUR_WRITE_KEY', { autocapture: { captureClicks: true, // default true captureSubmits: true, // default true captureChanges: false, // default false ignoreSelectors: ['.sensitive', '#admin-panel'], maskAllText: false, // true replaces all $elements text with "*" allowUrls: [/^https:\/\/app\.example\.com/], // restrict to matching URLs }, }); ``` **In code** — `beforeSend` (or a [`before` plugin](/sdk/plugins/)) sees every `$autocapture` event and can transform or drop it: ```ts kilden.init('YOUR_WRITE_KEY', { beforeSend(event) { if (event.event === '$autocapture' && location.pathname.startsWith('/admin')) { return null; } return event; }, }); ``` ## The $ namespace Events and properties starting with `$` are reserved for the Kilden system ($pageview, $autocapture, $identify, …). Sending your own `$`-prefixed event from `track()` logs a debug warning but is still delivered — the SDK never breaks a client over a convention. Full list in [Events & properties](/concepts/events-and-properties/). --- # Feature flags URL: https://docs.kilden.io/guides/feature-flags/ > Target flags by person properties and cohorts, roll out gradually with deterministic bucketing, and read them from the SDK. Kilden feature flags are boolean or multivariate, targeted by person properties and cohorts, rolled out by percentage, and evaluated in a dedicated low-latency service (`/decide`). This guide covers the mechanics; the SDK methods are in the [SDK reference](/sdk/feature-flags/). ## Creating a flag In the panel, under your project's **Feature flags**, create a flag with a key (what you'll check in code), optional targeting, and a rollout percentage. Viewing flags requires project membership; creating or editing them requires an admin role — flags change product behavior, so they're settings-grade. ## Targeting Targeting is a list of **condition groups**. Groups combine with **OR**; conditions inside a group with **AND**. No groups means "everyone". Conditions can match: - **Person properties**: `plan exact "pro"`, `company icontains "acme"`. Operators are `exact` and `icontains` (case-insensitive contains); non-string values compare as their text form. - **Cohort membership**: the person is in a [cohort](/concepts/cohorts/). Membership is precomputed and refreshed about every 10 minutes — flags don't evaluate cohort definitions live. The **rollout percentage** applies *after* targeting matches: "50% of pro-plan users" means half the users who match the conditions. For multivariate flags, define variants with weights that sum to 100 (`control: 50, test: 50`); the evaluated value is the variant key as a string. ## Deterministic bucketing Whether a given user falls inside a rollout is a pure function of the flag key and their distinct id — a hash mapped to `[0, 100)`. No state, no coordination, no storage: - The same user gets the same value on every request, every SDK, every deploy. - Raising a rollout 30% → 50% only **adds** users; nobody who had the flag loses it. - Variant assignment uses an independent hash, so variants don't correlate with the rollout cut. **One accepted trade-off**: the bucket depends on the distinct id, so when `identify()` switches a user from their anonymous id to their user id, someone near the rollout boundary can change sides. This is the documented cost of stateless evaluation (persisting assignments would mean writes in the hot path and drift from config). The SDK reloads flags immediately after `identify()` and `reset()` so the value converges right away. ## Reading flags in your app ```ts kilden.onFeatureFlags((flags) => { render(flags['new-checkout'] === true); }); // or point-in-time checks: kilden.isFeatureEnabled('new-checkout'); // false until loaded kilden.getFeatureFlag('pricing-test'); // 'control' | 'test-b' | true | false | undefined ``` Flags load asynchronously from `POST {apiHost}/decide` at init — there is no synchronous bootstrap, so gate flag-dependent rendering on `onFeatureFlags`. Every response carries **all** the project's flags (inactive ones as `false`), letting the SDK distinguish "off" from "unknown". You can also call [`/decide` directly](/api/decide/) from code that doesn't use the SDK. Evaluation never writes anything: `person_properties` in the request override stored traits for that one evaluation only. ## Exposure tracking The first time a session reads a flag, the SDK emits `$feature_flag_called` with `$flag_key` and `$flag_value`. The panel charts these exposures per flag (daily series by value, 7-day totals with variant breakdown), so you can confirm a rollout is actually reaching users before trusting it. ## Notes on freshness - Flag config changes reach `/decide` within ~10 seconds (it serves from an in-memory replica of the flag table). - Cohort membership is materialized about every 10 minutes. - Person property changes propagate through the event pipeline, then a short evaluation cache (~30 s). If you need an override *now*, pass `person_properties` in the `/decide` request. --- # Campaigns URL: https://docs.kilden.io/guides/campaigns/ > Event-triggered email journeys — triggers, audiences, delays, exits, template variables, and the delivery guarantees behind them. Campaigns send email journeys driven by your event stream: a trigger event starts a journey, steps send emails after configurable delays, and an exit event ends it early ("goal met"). Delivery events flow back into your analytics as `$email_*` events, so a campaign's funnel is queryable like any other data. Journeys are currently **linear**: trigger → (delay → email) → (delay → email) → … Branching journeys, quiet hours and timezone-aware sending are on the roadmap. ## Anatomy of a campaign | Piece | What it does | | --- | --- | | **Trigger event** | An event name that starts a journey for the person who did it (e.g. `cart_abandoned`) | | **Trigger filters** | Optional conditions on the trigger event's properties, combined with AND. Operators: `exact`, `icontains` | | **Audience cohort** | Optional [cohort](/concepts/cohorts/): only members can enter the journey | | **Steps** | Each step = delay + email (subject + body template). Step 1's delay counts from the trigger | | **Exit event** | If the person does this before the journey ends, it stops (`goal met`) and no further emails send | | **Re-entry** | `never` (one journey per person, ever) or `after N days` (may re-enter once the previous journey finished ≥ N days ago) | | **Frequency cap** | Max messages to a person within a rolling window (in hours) — counted **across campaigns**, so overlapping journeys can't pile up | ## Trust rules for triggering - **Identified persons require verified events to trigger a journey.** If [identity verification](/guides/identity-verification/) isn't passing for a user, their events still land in analytics but won't start campaigns — sending email based on a forgeable event is exactly the failure mode verification exists for. Anonymous events can trigger normally. - **Exit events don't require verification.** Exiting is the safe direction: better to lose one email than to send an embarrassing one. - For triggers that must be facts (payment received, subscription cancelled), send the trigger event [server-side with a secret key](/concepts/trust-levels/). ## Template variables Email subjects and bodies support two variable namespaces: ``` Hi {{person.first_name}}, you left {{event.item_count}} items in your cart. ``` - `{{person.x}}` — the person's traits at send time. - `{{event.y}}` — properties of the trigger event, **frozen at trigger time**. A journey remembers the event that started it, even if the person's data changed since. Values are HTML-escaped; unresolved variables render as empty strings. There is no logic (no conditionals or loops) — templates are substitution only. ## Sending guarantees - **Exactly-once per step.** Every send claims a unique `(journey, step)` slot in a message ledger before talking to the email provider, and the send itself carries an idempotency key. Retries, crashes and concurrent workers can't duplicate an email. - **Suppression list.** Hard bounces and spam complaints suppress the address permanently, automatically (soft bounces suppress after the third). Suppressed addresses are skipped at send time, and you can add manual suppressions in the panel. - **Last-second exit check.** Even after a send is scheduled, the dispatcher re-checks the journey state before sending — an exit event that arrived in between still stops the email. - **Pause stops the clock, not the journeys.** Pausing a campaign stops new entries and freezes progress; existing journeys keep their state. Reactivating processes overdue steps immediately (with all the exit/suppression checks). Pausing does not cancel journeys. ## Email events in your analytics The provider's delivery webhooks are converted into events attributed to the same person: `$email_sent`, `$email_delivered`, `$email_opened`, `$email_clicked`, `$email_bounced`, `$email_complained` — each tagged with the campaign, journey and step. The campaign detail page uses them for its funnel (sent → delivered → opened → clicked), and you can use them anywhere: cohorts of people who clicked, flags targeted at non-openers, and so on. ## Broadcasts A campaign with an audience cohort can also be sent as a one-time **broadcast** to the whole cohort (respecting re-entry rules, suppression and frequency caps). Journeys are created in bulk and proceed through the same steps and guarantees. ## Test sends The campaign editor can send a single step to an address you choose without creating a journey; test sends are excluded from frequency-cap counting. --- # Session replay URL: https://docs.kilden.io/guides/session-replay/ > Record and replay real sessions with sampling, strict privacy defaults, and 1:1 correlation with your event timeline. Session replay records the DOM of sampled sessions and lets you watch them back in the panel, side by side with the session's events. It's built for debugging and UX research, and it's deliberately conservative: **off by default, sampled by default, inputs masked by default**. ## Enabling replay Two switches, both required: 1. **Project settings** — enable session recording and set the project-level sample rate. This is also the remote kill switch: turning it off stops new recordings within seconds, no deploy needed. 2. **SDK** — pass `sessionRecording` in [`init`](/sdk/configuration/#sessionrecordingoptions): ```ts kilden.init('YOUR_WRITE_KEY', { sessionRecording: { sampleRate: 0.1, // record 10% of sessions maskAllInputs: true, // default — see privacy below minimumDurationMs: 2000, // drop sub-2s noise }, }); ``` A local `sessionRecording: false` (the default) always wins over remote config — the recording code isn't even downloaded. See the [SDK reference](/sdk/session-replay/) for eager vs lazy loading and the manual `startSessionRecording()` / `stopSessionRecording()` controls. ## Sampling Recording 100% of sessions is expensive for everyone involved; the default records 10%. The decision is made **once per session** and is sticky: reloads keep it, and it re-rolls when the session rotates after 30 minutes of inactivity. The effective sample rate can also be set remotely from project settings. ## Privacy Replay's defaults are deliberately stricter than autocapture's, because a recording shows what the user *types*: | Control | Default | Effect | | --- | --- | --- | | `maskAllInputs` | **true** | Everything typed into inputs is masked. Turning this off is an explicit decision | | `maskAllText` | false | Mask all page text, not just inputs | | `blockSelectors` | — | Elements matching these selectors are not recorded at all | | `recordCanvas` | false | Canvas contents are skipped | Two DOM attributes work across the whole SDK: - `data-kilden-no-capture` — excludes the element and its descendants from autocapture **and** from recordings entirely. - `data-kilden-mask` — records the structure but masks the content (text and inputs) of the element and its descendants. ```html
``` ## Correlation with analytics A recording shares its session id with the session's events, so replays and event timelines join 1:1 — "watch the sessions of everyone who abandoned checkout" is a query, not a search. When recording starts, the SDK emits `$session_recording_started` into your event stream. A recording never crosses a session boundary: session rotation cuts it. ## Transport and retention Replay data does not travel with your events: chunks are gzip-compressed in the browser and posted to `{apiHost}/replay` (max 4 MiB per chunk), with recording metadata in headers — never in the URL, so nothing sensitive lands in access logs. Short sessions (under `minimumDurationMs`, default 2 s) are discarded. Recordings are retained for **30 days**. --- # Capture API URL: https://docs.kilden.io/api/capture/ > POST /capture — the single write path for every event, from browsers, backends and integrations. Everything in Kilden enters through one endpoint: ``` POST https://ingest.kilden.io/capture Content-Type: application/json ``` The SDKs use it under the hood; you can call it directly from any backend, script or integration. There is no other write path. ## Request The body is always a **batch** — a single event is a batch of one: ```json { "write_key": "YOUR_WRITE_KEY", "sent_at": "2026-07-11T12:00:00.000Z", "batch": [ { "uuid": "0197f9d2-5e5a-7cc3-b1a4-1f0e9a2b3c4d", "event": "order_completed", "distinct_id": "user_4821", "properties": { "total": 49.9, "currency": "EUR" }, "timestamp": "2026-07-11T11:59:58.000Z" } ] } ``` | Field | Required | Notes | | --- | --- | --- | | `write_key` | yes | Public **or** secret key, in the body (not a header). Secret keys mark events as server-side facts — see [Trust levels](/concepts/trust-levels/) | | `sent_at` | no | When the client sent the request. Used only to correct client clock skew: `real_time ≈ timestamp + (server_now − sent_at)` | | `batch` | yes | 1 to **1000** events | | `identity_token` | no | Fallback slot for the [identity verification](/guides/identity-verification/) JWT when a header is impossible (`sendBeacon`). The `Authorization` header wins if both are present | Per event: | Field | Required | Notes | | --- | --- | --- | | `uuid` | yes | A valid UUID, generated **by the client**. Use UUID v7. This is the idempotency key: retries with the same `uuid` deduplicate, so retrying on any failure is always safe | | `event` | yes | Event name, ≤ 200 chars. `$`-prefixed names are reserved for the system | | `distinct_id` | yes | ≤ 512 chars. `anon_`-prefixed UUID v7 = anonymous; anything else is treated as an identified user id | | `properties` | no | Any JSON object. Never schema-validated — the envelope is typed, the payload is yours | | `timestamp` | no | ISO 8601. Defaults to server receive time; corrected with `sent_at` when both are present | Headers: - `Authorization: Bearer ` — identity verification token for the whole batch (a batch belongs to one browser session). Max 4096 bytes. - `Content-Encoding: gzip` — supported and recommended for large batches. Limits: request body ≤ 5 MiB, batch ≤ 1000 events. ## Response `200` as soon as the batch is validated and enqueued (capture never blocks on downstream processing): ```json { "status": "ok" } ``` Errors are **plain text**, not JSON: | Status | Meaning | | --- | --- | | `400` | Malformed request: `invalid JSON body: …`, `invalid gzip body`, `write_key is required`, `batch must not be empty`, `batch exceeds 1000 events`, `batch[i]: uuid is not a valid UUID`, `batch[i]: event is required`, `batch[i]: event exceeds 200 chars`, `batch[i]: distinct_id is required`, `batch[i]: distinct_id exceeds 512 chars`, `batch[i]: properties is not valid JSON`, `identity token too large` | | `401` | `unknown write_key` | | `403` | `origin not allowed for this project` — see [allowed origins](#allowed-origins) | | `405` | Method other than `POST`/`OPTIONS` | Validation failures reject the **whole batch** — fix and retry (the UUIDs make that safe). ## Server-side example ```sh curl -X POST https://ingest.kilden.io/capture \ -H 'Content-Type: application/json' \ -d '{ "write_key": "sk_your_secret_key", "sent_at": "2026-07-11T12:00:00Z", "batch": [{ "uuid": "0197f9d2-5e5a-7cc3-b1a4-1f0e9a2b3c4d", "event": "subscription_renewed", "distinct_id": "user_4821", "properties": {"plan": "pro", "mrr": 49}, "timestamp": "2026-07-11T12:00:00Z" }] }' ``` Events sent with a **secret key** are marked `source=server` and `verified=true` — the secret key is the authentication. Never embed a secret key in a browser or mobile app; that's what public keys are for. ## CORS `/capture` is a public write endpoint, CORS-open by design: - Every response carries `Access-Control-Allow-Origin: *` (no credentials). - Preflight `OPTIONS` answers `204` with `Access-Control-Allow-Methods: POST`, `Access-Control-Allow-Headers: Content-Type, Authorization, Content-Encoding`, `Access-Control-Max-Age: 86400`. ## Allowed origins A public write key is visible in your page source, so anyone could copy it. To stop other sites from polluting your data, configure **allowed origins** in project settings: when the list is non-empty, browser requests whose `Origin` doesn't match get `403`. - Patterns: `scheme://host[:port]`, with subdomain wildcards (`https://*.example.com` — doesn't match the apex; list both if needed). - Empty list (default) = allow all. - Requests **without** an `Origin` header (curl, backends, mobile apps) always pass — that vector is covered by secret keys and [identity verification](/guides/identity-verification/), not by Origin matching. This is noise reduction against real-world key reuse in browsers, not a security boundary. --- # Decide API URL: https://docs.kilden.io/api/decide/ > POST /decide — evaluate feature flags and session-recording config for a distinct id. `/decide` evaluates every feature flag of a project for one distinct id, and returns the project's session-recording config. It's what the SDK calls at init and after `identify()`/`reset()`; you can call it directly from anywhere that can't use the SDK. ``` POST https://ingest.kilden.io/decide Content-Type: application/json ``` Same host as [capture](/api/capture/) — SDKs need a single `apiHost` for events and flags. ## Request ```json { "write_key": "YOUR_PUBLIC_WRITE_KEY", "distinct_id": "user_4821", "person_properties": { "plan": "enterprise" } } ``` | Field | Required | Notes | | --- | --- | --- | | `write_key` | yes | **Public keys only.** A secret key is rejected with `403` — it must never leave your backend, and `/decide` never needs it | | `distinct_id` | yes | The id to evaluate for — the same one your events carry | | `person_properties` | no | Overrides the person's stored traits **for this evaluation only**. Nothing is ever written; the event pipeline remains the only way to mutate persons | Body limit: 64 KiB. Cohort-based targeting is resolved server-side from precomputed [cohort membership](/concepts/cohorts/) — there's nothing to send. ## Response ```json { "flags": { "new-checkout": true, "pricing-test": "variant-b", "sunset-legacy-ui": false }, "sessionRecording": { "enabled": true, "sampleRate": 0.1 } } ``` - `flags` — **every** flag in the project, evaluated for `distinct_id`. Values: `true`, `false`, or a variant key string for multivariate flags. Inactive flags come back as `false`, so a consumer can distinguish "off" from "unknown flag". - `sessionRecording` — the project's [session replay](/guides/session-replay/) remote config: the kill switch and sample rate the SDK obeys. `{"enabled": false, "sampleRate": 0}` when recording is off. Evaluation is deterministic: the same flag key and distinct id always produce the same value, across requests, replicas and deploys. See [deterministic bucketing](/guides/feature-flags/#deterministic-bucketing). ## Errors Errors are plain text, not JSON: | Status | Meaning | | --- | --- | | `400` | `invalid JSON body: …` (including bodies over 64 KiB) or `write_key and distinct_id are required` | | `401` | `unknown write_key` | | `403` | `secret keys must never leave your backend; call /decide with the project's public write key` — or `origin not allowed` (same [allowed-origins check](/api/capture/#allowed-origins) as capture) | | `405` | Method other than `POST`/`OPTIONS` | ## Example ```sh curl -X POST https://ingest.kilden.io/decide \ -H 'Content-Type: application/json' \ -d '{"write_key": "pk_your_public_key", "distinct_id": "user_4821"}' ``` ## CORS Identical to capture: `Access-Control-Allow-Origin: *` on every response; preflight `OPTIONS` → `204` with `Access-Control-Allow-Methods: POST` and `Access-Control-Max-Age: 86400`. ## Freshness Flag config changes are visible within ~10 seconds; person traits and cohort membership are cached for ~30 seconds in the evaluation path; cohort membership itself is materialized about every 10 minutes. If you need a property override to apply immediately, pass it in `person_properties`. --- # Events & properties URL: https://docs.kilden.io/concepts/events-and-properties/ > The event model — immutable events, schema-less properties, and the reserved $ namespace. ## Events are immutable An event freezes its properties at capture time. Nothing rewrites it afterwards — not identity merges, not trait updates, not enforcement decisions. If a person's plan changes, their *person* changes; the events they sent while on the old plan keep saying the old plan. That's the split: **events are history, [persons](/concepts/persons-and-identity/) are state**. Every event carries: | Field | What it is | | --- | --- | | `uuid` | Client-generated UUID v7. The idempotency key: the pipeline deduplicates by it, which is why retries at every layer are safe | | `event` | The name. Your names are free-form; `$`-prefixed names are the system's | | `distinct_id` | Who did it, as known at capture time | | `properties` | Arbitrary JSON. Kilden never validates or types it — the envelope is typed, the payload is yours by design | | `timestamp` | When it happened (client-supplied, corrected for clock skew using the batch's `sent_at`) | ## The $ namespace Names starting with `$` — events and properties — are reserved for the system. The SDK warns in debug mode if you `track()` one, but still delivers it: the namespace is a convention, never a reason to drop your data. System events you'll see in your stream: | Event | Emitted by | | --- | --- | | `$pageview`, `$pageleave` | SDK, automatically (including SPA route changes) | | `$autocapture` | [Autocapture](/guides/autocapture/): clicks, submits, changes, described by `$event_type` and `$elements` | | `$identify` | `identify()` — carries `$anon_distinct_id` for identity linking | | `$set`, `$set_once` | Person trait updates | | `$feature_flag_called` | First flag read per session — `$flag_key`, `$flag_value` ([exposures](/guides/feature-flags/#exposure-tracking)) | | `$session_recording_started` | Replay recording start | | `$email_sent`, `$email_delivered`, `$email_opened`, `$email_clicked`, `$email_bounced`, `$email_complained` | [Campaigns](/guides/campaigns/) and the email provider's webhooks | Context properties stamped on every SDK event: `$current_url`, `$referrer`, `$session_id`, `$device_type`, `$screen_width`, `$screen_height`, `$lib`, `$lib_version`, `$utm_source`, `$utm_medium`, `$utm_campaign`, `$utm_term`, `$utm_content`. ## One write path Every event — browser, backend, email webhook, even Kilden's own internal events — enters through the same [capture endpoint](/api/capture/) and the same pipeline. There are no side doors, which is what makes the guarantees (dedup, identity resolution, [trust levels](/concepts/trust-levels/)) hold for everything uniformly. ## Sessions The SDK maintains `$session_id` (UUID v7) on every event, rotating it after 30 minutes of inactivity. Sessions tie together events, [feature-flag exposure dedupe](/sdk/feature-flags/#exposure-events) and [session replay](/guides/session-replay/) — a recording is always joinable 1:1 with its session's events. --- # Persons & identity URL: https://docs.kilden.io/concepts/persons-and-identity/ > How distinct ids map to persons, what merging means, and how traits behave. A **person** is the durable profile behind events: one human (as far as Kilden can tell), with mutable **traits** and one or more **distinct ids** pointing at them. ## Distinct ids Every event names its actor by `distinct_id`. Two kinds: - **Anonymous**: `anon_` + UUID v7, generated by the SDK before you know who the user is. - **Identified**: your own user id, set via [`identify()`](/guides/identifying-users/) or sent server-side. The format is meaningful: anything that doesn't look like `anon_` is treated as an identified user — which is what makes [trust levels](/concepts/trust-levels/) cheap to compute. A person can hold many distinct ids (several devices, pre- and post-signup ids), up to a cap of 100. ## Traits Traits are the person's current state — plan, email, company — updated by `identify(id, traits)`, `setPersonProperties()`, or `$set`/`$set_once` properties on any event: - `$set` overwrites. - `$set_once` writes only keys that don't exist yet. - [Signed traits](/guides/identity-verification/#signed-traits) (inside a verified identity token) win over unsigned values in the same event. Trait updates never touch past events; events snapshot their properties forever. ## Merging When `identify()` reveals that two persons are the same human (your user id already existed, and it just got linked from a new anonymous session), they merge: - The **older** person survives; the newer one's distinct ids re-point to it. - On trait conflicts the survivor keeps its values; the merged-away person only fills gaps. - Historical events don't get rewritten (immutability) — queries resolve old person ids to the survivor at read time, so counts stay correct. Merges are one-directional and conservative: two identified ids are never merged automatically (that pattern is almost always an instrumentation bug), and the web SDK exposes no `alias()` — `identify` covers the browser case completely. ## Person data in queries Analytics queries read events with the person resolved at query time ("person on events" plus a merge-override table), so a merge that happened yesterday retroactively unifies a user's whole history without any backfill job. --- # Trust levels URL: https://docs.kilden.io/concepts/trust-levels/ > Client, verified and server — how much Kilden trusts an event, and which features care. Not all events deserve the same trust. A public write key ships in your page source; anything a browser claims, the browser's owner can forge. Kilden makes the trust level of every event explicit instead of pretending it doesn't exist. ## The three levels | Level | How it's established | `verified` | | --- | --- | --- | | **Client** | Public write key, no proof of identity | Anonymous events: `false` *by convention* — there's no identity to verify, and nothing treats them as suspect. Identified events without a valid token: `false`, meaning *unproven* | | **Verified client** | Public write key + a valid [identity verification](/guides/identity-verification/) JWT signed by your backend (`sub` = the event's `distinct_id`) | `true` | | **Server** | **Secret write key**, from your backend only | `true` — the secret key *is* the authentication | Verification is always computed, on every identified event, regardless of the project's enforcement mode — so `monitor` mode measures exactly what `enforce` would block. ## Which features care - **Analytics**: everything counts. Unverified events are kept (`verified: false`), never dropped or rewritten — they're data, just not trusted. - **[Campaign](/guides/campaigns/) triggers**: identified persons must be verified to start a journey. Anonymous events pass. Exit events always pass (exiting is the safe direction). - **Identity mutations** (`$identify`, `$set`, `$set_once`): governed by the project's mode — in `enforce`, unverified mutations from identified users can't create or change persons. - **Messaging reads** (upcoming in-app messenger): verification required always, in every mode — reading someone's message thread is not a "monitor" kind of risk. ## Two classes of write keys - **Public key** — safe to embed in browsers and mobile apps. Identifies the project, authenticates nothing. Can be scoped with [allowed origins](/api/capture/#allowed-origins). - **Secret key** — backend only, never embedded in a client. Events sent with it are server facts. The [decide API](/api/decide/) outright rejects secret keys (`403`) so they're never tempted into frontend code. Rule of thumb: **data that must be true goes server-side.** Revenue, subscription state, anything that triggers money or messaging — send it from your backend with the secret key. Use the browser for behavior, the backend for facts, and [identity verification](/guides/identity-verification/) to make browser identity trustworthy. --- # Cohorts URL: https://docs.kilden.io/concepts/cohorts/ > Precomputed person groups by traits and behavior — shared by feature flags and campaigns. A cohort is a named group of persons defined by conditions, evaluated ahead of time. One cohort definition serves the whole platform: [feature flag targeting](/guides/feature-flags/#targeting), [campaign audiences](/guides/campaigns/) and analysis. ## Conditions Two kinds, combined with **AND**: - **Trait conditions** — on the person's current properties: `plan exact "pro"`, `company icontains "acme"`. - **Behavior conditions** — "did event X at least N times in the last Y days": `checkout ≥ 3 times in 30 days`. Behavior counting resolves identity merges, so a user's activity across devices counts as one person. ## Materialized, not live Cohort membership is **precomputed**: definitions are evaluated on a schedule (about every 10 minutes) and the member list is swapped atomically — consumers never observe a half-built cohort. What that buys and costs: - Flag evaluation and campaign matching read membership as a simple lookup — no heavy queries in hot paths, so `/decide` stays fast no matter how complex your cohort definitions get. - Membership can be up to ~10 minutes stale. For flags and campaign audiences that's the right trade; cohorts are **not** a real-time primitive. If you need an immediate targeting override for one evaluation, pass [`person_properties` to `/decide`](/api/decide/#request). ## Managing cohorts Cohorts live in the panel next to feature flags. Like flags, they're settings-grade: any project member can view them, changing them requires an admin role — a cohort edit can change who receives a campaign. --- # Use Kilden with your AI editor URL: https://docs.kilden.io/ai/editor/ > Point Claude Code, Cursor or Claude Desktop at the Kilden docs through the @kilden/mcp server. `@kilden/mcp` is a small [Model Context Protocol](https://modelcontextprotocol.io) server that gives AI coding tools searchable access to this documentation. It's stateless, needs no API key, and runs from npm with `npx`. It exposes two tools: - **`search_docs(query)`** — returns the most relevant documentation sections, each with its canonical URL. - **`get_doc(path)`** — returns a full page as Markdown (e.g. `guides/identity-verification`). ## Claude Code ```sh claude mcp add kilden-docs -- npx -y @kilden/mcp ``` Or in a `.mcp.json` checked into your project: ```json { "mcpServers": { "kilden-docs": { "command": "npx", "args": ["-y", "@kilden/mcp"] } } } ``` ## Cursor In `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): ```json { "mcpServers": { "kilden-docs": { "command": "npx", "args": ["-y", "@kilden/mcp"] } } } ``` ## Claude Desktop In **Settings → Developer → Edit Config** (`claude_desktop_config.json`): ```json { "mcpServers": { "kilden-docs": { "command": "npx", "args": ["-y", "@kilden/mcp"] } } } ``` ## How it works The server fetches [/llms-full.txt](/ai/llms-txt/) from `https://docs.kilden.io` at startup, splits it into per-page sections, and serves search and retrieval from memory. To run it against a different docs deployment (e.g. a local preview): ```sh npx -y @kilden/mcp --source-url http://localhost:4321/llms-full.txt ``` No telemetry, no state on disk, no credentials. ## Without MCP Every page of this site is also available as raw Markdown by appending `.md` to its URL, and the whole site is one file at [/llms-full.txt](/llms-full.txt) — see [llms.txt](/ai/llms-txt/). If your tool can fetch URLs, that's often enough. --- # llms.txt URL: https://docs.kilden.io/ai/llms-txt/ > Machine-readable versions of these docs — llms.txt, llms-full.txt, and raw Markdown per page. These docs publish machine-readable surfaces following the [llms.txt convention](https://llmstxt.org), generated from the same source as the HTML on every build — they can't drift. ## /llms.txt [docs.kilden.io/llms.txt](https://docs.kilden.io/llms.txt) — a curated index: every page with a one-line description, grouped by section. Small enough to drop into a prompt so a model can decide what to fetch next. ## /llms-full.txt [docs.kilden.io/llms-full.txt](https://docs.kilden.io/llms-full.txt) — the entire documentation concatenated as Markdown, each page prefixed with its canonical URL. For tools that prefer one retrieval over many, and the data source behind [`@kilden/mcp`](/ai/editor/). ## Raw Markdown per page Append `.md` to any page URL to get its source Markdown: ``` https://docs.kilden.io/guides/identity-verification.md https://docs.kilden.io/sdk/configuration.md ``` ## MCP For editors that speak the Model Context Protocol, `@kilden/mcp` wraps these files with search — see [Use Kilden with your AI editor](/ai/editor/). --- # Self-hosting Kilden URL: https://docs.kilden.io/self-hosting/ > Self-hosting is coming with our open source release. Here's the architecture you'd be running. Kilden is built to be self-hostable, and self-hosting will be documented here **when our open source release ships**. No date promises — we'd rather publish it working than announced. ## What you'd be running The honest preview. Kilden is a small fleet of stateless services around four pieces of infrastructure — Kafka-compatible streaming, ClickHouse, Postgres, Redis, and S3-compatible object storage for replay: ``` SDKs / provider webhooks / external sources │ ▼ capture ──► events_raw (keyed by distinct_id) │ ▼ enricher ──► Postgres (persons, identities) │ ▼ events_enriched (keyed by person_id) │ │ │ │ ▼ ▼ ▼ ▼ warehouse campaign CDP live tail writer matcher sync │ │ │ │ ▼ ▼ ▼ ▼ panel (websocket) ClickHouse journeys destinations (PG) → scheduler → dispatcher → email provider │ provider webhooks ──────┘ (re-enter through capture) ``` Session replay takes a parallel path: recording chunks go to object storage, only ~200-byte pointers travel through Kafka, and an indexer builds the session index in ClickHouse. The properties that make it operable: - **Services are stateless** — all state lives in the databases and consumer offsets, so everything scales horizontally and restarts are boring. - **At-least-once delivery with idempotent edges** — every layer can retry safely; deduplication by client-generated UUIDs makes it exactly-once where it counts. - **One write path** — every event enters through capture, so there's exactly one thing to secure, rate-limit and monitor for ingestion. ## Meanwhile Kilden Cloud is the hosted version of exactly this architecture — same code, same guarantees. Everything else in these docs applies to both.