# darkship documentation (full text) > Guides, concepts, SDK references, the REST API and webhooks for darkship feature flags. Index: https://darkship.tech/llms.txt --- # Documentation URL: https://darkship.tech/docs/ Description: Guides, concepts, SDK references, the REST API and webhooks for darkship feature flags. Documentation · v1 ## Ship it dark. Turn it on when you're sure. Evaluate typed feature flags in-process, roll them out by stable percentage, and keep serving even when we're down. Pick the path that matches what you need to do right now. [Start the 5-minute quickstart](https://darkship.tech/docs/quickstart/)[Browse the REST API](https://darkship.tech/docs/api-reference/) ```ts const ds = init({ sdkKey }); await ds.waitForReady(); // local and in-memory: no network call const on = ds.boolVariation( 'new-checkout-flow', { key: user.id }, false, // default ); ``` **< 5s**console click to SDK, p95 ([how](https://darkship.tech/docs/concepts/caching/#propagation)) **0**network calls per server-side evaluation **6**flag types: boolean to schema-validated JSON **0 B**of end-user context stored, ever ([telemetry](https://darkship.tech/docs/sdk/javascript/#telemetry)) ### Choose your path [I want to see it work Quickstart Install an SDK, create a server key, evaluate a boolean, a string and a JSON flag, then watch the evaluations land in the console. Node Browser Python Start building](https://darkship.tech/docs/quickstart/) [I want the mental model Concepts Hybrid evaluation, versioned snapshots, fail-static behaviour, and the exact order in which targeting rules run. Read the concepts](https://darkship.tech/docs/concepts/evaluation/) [I'm integrating SDK guides JavaScript and TypeScript for Node and the browser, Python, and the OpenFeature provider. Pick an SDK](https://darkship.tech/docs/sdk/javascript/) [I'm automating REST API Manage flags, environments, segments and keys under `/v1`. Try every call from the page. Open the reference](https://darkship.tech/docs/api-reference/) [I'm reacting to changes Webhooks Signed, retried, replay-safe events when a flag or key changes. Verify them in a few lines. Verify a signature](https://darkship.tech/docs/webhooks/) ### How the pieces fit Darkship has two data paths and one control plane. Understanding which one your code uses explains almost every behaviour in these docs. **Server SDK**: Holds the environment's full ruleset in memory and evaluates locally. Authenticated with a `ds_srv_` key that must never leave your backend. **Client SDK**: Sends the context to the evaluation service and receives resolved values only, never rules. Authenticated with a public `ds_cli_` key. **REST API**: The control plane: create flags, change targeting, rotate keys. Authenticated with a scoped `ds_pat_` token. **Webhooks**: Outbound, signed notifications from the control plane when something changes. **Not sure which key?** If the code runs on a machine you control, use a server key. If it runs on a device you don't (a browser, a phone), use a client key. [The decision table](https://darkship.tech/docs/concepts/evaluation/#choosing) has the details. ### Popular right now - [How percentage rollouts are bucketed](https://darkship.tech/docs/concepts/targeting/#bucketing), with a calculator you can run. - [What happens when Redis or Postgres is down](https://darkship.tech/docs/concepts/caching/#fail-static) (your app keeps working). - [OpenFeature reason and error code mapping](https://darkship.tech/docs/sdk/openfeature/#mapping). - [Verifying a webhook signature](https://darkship.tech/docs/webhooks/#verify) in Node and Python. --- # Quickstart URL: https://darkship.tech/docs/quickstart/ Description: Install an SDK, evaluate boolean, string and JSON flags, and verify them in the console in five minutes. Start · 5 minutes ## Quickstart: your first flag Install an SDK, evaluate three flags of three different types, and see the evaluations arrive in the console. You need an account and one project; nothing else. **Your language choice is remembered** Pick Node.js, Browser or Python in any snippet below. Every tab group on every docs page follows your choice. ### Before you start You will use two kinds of key. Create them in the console under **Environments** and **SDK keys**; each key belongs to exactly one environment, so start with `dev`. | Key | Prefix | Use it in | Can read rules? | | --- | --- | --- | --- | | Server key | `ds_srv_` | Node.js, Python, any backend | Yes (evaluates locally) | | Client key | `ds_cli_` | Browsers, mobile apps | No (resolved values only) | **Keep server keys out of the browser** A `ds_srv_` key unlocks your whole ruleset. The browser SDK refuses to start with one, and we alert you if a server key is seen from a browser `Origin`. Load it from an environment variable or secret store. ### Install the SDK **Node.js:** ```bash $ npm install @darkship/sdk ``` **Browser:** ```bash $ npm install @darkship/sdk ``` The same package ships a browser build (client mode). Bundlers pick it up through the `browser` export condition. **Python:** ```bash $ pip install darkship ``` Python 3.10 or newer. ### Initialise with your key Initialisation returns immediately; the SDK fetches the ruleset in the background. Calling `waitForReady` is optional but recommended at process start. **Node.js:** ```ts import { init } from '@darkship/sdk'; export const ds = init({ sdkKey: process.env.DARKSHIP_SDK_KEY!, // ds_srv_dev_... }); await ds.waitForReady({ timeout: 3000 }); // resolves even on timeout, see below ``` **Browser:** ```ts import { init } from '@darkship/sdk'; export const ds = init({ sdkKey: 'ds_cli_dev_Zk3...', // public client key context: { key: user.id, plan: user.plan }, }); await ds.waitForReady({ timeout: 3000 }); ``` **Python:** ```py import os import darkship ds = darkship.init(sdk_key=os.environ["DARKSHIP_SDK_KEY"]) # ds_srv_dev_... ds.wait_for_ready(timeout=3.0) ``` **A timeout is not an error** If the ruleset can't be loaded in time, `waitForReady` resolves anyway and evaluations return your defaults with reason `NOT_READY`. Your app starts; flags catch up when the network does. ### Evaluate three flags Every evaluation takes the flag key, a context, and a **default**. The default is returned if the flag is missing, has a different type, or the SDK isn't ready. The call never throws. **Node.js:** ```ts import { ds } from './darkship'; const ctx = { key: 'usr_marta', plan: 'pro', country: 'BR' }; // boolean const newCheckout = ds.boolVariation('new-checkout-flow', ctx, false); // string const banner = ds.stringVariation('promo-banner-text', ctx, 'Welcome back'); // json (validated against the flag's JSON Schema when saved) const limits = ds.jsonVariation('rate-limits', ctx, { rps: 10, burst: 20 }); console.log({ newCheckout, banner, limits }); ``` **Browser:** ```ts import { ds } from './darkship'; // the context was set at init; values are read from the session cache const newCheckout = ds.boolVariation('new-checkout-flow', false); const banner = ds.stringVariation('promo-banner-text', 'Welcome back'); const limits = ds.jsonVariation('rate-limits', { rps: 10, burst: 20 }); document.querySelector('#promo').textContent = banner; // when the user signs in, swap the context (triggers one re-fetch) await ds.identify({ key: 'usr_marta', plan: 'pro', country: 'BR' }); ``` **Python:** ```py from darkship import ds ctx = {"key": "usr_marta", "plan": "pro", "country": "BR"} new_checkout = ds.bool_variation("new-checkout-flow", ctx, False) banner = ds.string_variation("promo-banner-text", ctx, "Welcome back") limits = ds.json_variation("rate-limits", ctx, {"rps": 10, "burst": 20}) print(new_checkout, banner, limits) ``` Run it. With no flags created yet you will see your defaults: ```text { newCheckout: false, banner: 'Welcome back', limits: { rps: 10, burst: 20 } } ``` ### Create the flags in the console Open **Flags → New flag** and create `new-checkout-flow` (boolean), `promo-banner-text` (string) and `rate-limits` (JSON). Switch each one **On** in `dev`. The type is fixed at creation, so choose carefully; [changing it later means a new flag](https://darkship.tech/docs/concepts/targeting/#types). Within seconds, your running process picks the change up; there is nothing to redeploy: ```text { newCheckout: true, banner: 'Free shipping this week', limits: { rps: 50, burst: 120 } } ``` **Why it's fast** Changes are published as a versioned snapshot and pushed to server SDKs over a stream (on by default; polling every 30 s is only the fallback), so they propagate in under 5 seconds at p95. See [snapshot propagation](https://darkship.tech/docs/concepts/caching/#propagation). ### Verify in the console The SDK counts evaluations per flag and variation in one-minute windows and flushes them in the background. Open the flag and check **Usage**; after about a minute you should see something like this: **dev**SDK connected `new-checkout-flow`on1,842 `promo-banner-text`free-shipping1,842 `rate-limits`burst1,842 Only counts are sent: environment (derived from your key), flag key, variation name, window and count. The context you pass to `boolVariation` never leaves your process in server mode, and is never stored for client mode. [Details](https://darkship.tech/docs/sdk/javascript/#telemetry). **Nothing showing up?** Check, in order: the key is for the same environment you toggled; the flag type matches the accessor you called (a wrong type returns your default); and the process stays alive for at least one flush interval, or call `await ds.close()` to flush on exit. ### Where to go next - [Targeting and rollouts](https://darkship.tech/docs/concepts/targeting/): serve `new-checkout-flow` to 10% of users, then 20%, without anyone flipping back. - [How evaluation works](https://darkship.tech/docs/concepts/evaluation/): why server SDKs don't make network calls. - [JavaScript SDK reference](https://darkship.tech/docs/sdk/javascript/) or [Python SDK reference](https://darkship.tech/docs/sdk/python/). - Already on OpenFeature? Use the [provider](https://darkship.tech/docs/sdk/openfeature/) and skip the native API. --- # Build with AI: integration guide URL: https://darkship.tech/docs/ai/integrate/ Description: Instructions for a coding agent to integrate Darkship into a project with OpenFeature: keys, hosts, a tested provider for Node, browser and Python, the evaluation contract and the rules to follow. Start · Build with AI ## Integrate Darkship with a coding agent This page is written for an AI coding agent (and for the developer who supervises it). Give the agent this URL and one sentence of context, and it has what it needs to add feature flags to your project: how the system works, which key goes where, tested OpenFeature providers, and the rules to follow. ### How to use this page Paste the prompt below into your agent (Claude Code, Cursor, Copilot, Codex or similar), replacing the bracketed parts. If the agent cannot browse, give it [/llms-full.txt](https://darkship.tech/llms-full.txt), which contains every docs page as plain text, or the OpenAPI document at [/openapi.json](https://darkship.tech/openapi.json). ```text Integrate Darkship feature flags into this project. Read and follow https://darkship.tech/docs/ai/integrate/ exactly. Use OpenFeature with the provider from that page (there is no Darkship SDK package to install yet). Context: - The flag to add: [flag key, type, what it controls, safe default] - Where it is evaluated: [server route / component / job] - Environment variables are already set: DARKSHIP_BASE_URL and DARKSHIP_CLIENT_KEY (or tell me where to put them). Do not create or change flags in production without asking me. ``` ### What the system is - Darkship is a feature flag service. A **flag** has a key, a type and per-environment targeting (an on/off switch, target lists, rules, a default). Evaluating a flag for a **context** (a user key plus attributes) returns a value, the variation that produced it and a reason. - Flag types: `boolean`, `string`, `int`, `float`, `json`, `date`. The type is fixed when the flag is created. Flag keys match `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$` (use kebab-case, for example `new-checkout-flow`). - Hierarchy: organisation, project, environment (for example `dev`, `staging`, `production`). A key belongs to exactly one environment, so the same code behaves differently per environment just by changing the key. - Evaluation order: the environment switch, then target lists, then rules top to bottom (first match wins), then the default. Percentage rollouts hash the context `key`, so always send a stable `targetingKey`. - Evaluation never throws. When anything is wrong (flag missing, wrong type, service unreachable) the caller gets its own **default value** and an error code. Always choose a default that is safe if the flag cannot be read. #### Hosts | Purpose | URL | | --- | --- | | Evaluation (SDK data plane) | `https://sdk.darkship.tech` | | Management API | `https://api.darkship.tech/v1` | | Console | `https://app.darkship.tech` | #### Keys | Credential | Prefix | Use it for | Where it may live | | --- | --- | --- | --- | | Client SDK key | `ds_cli_` | `POST /v1/sdk/evaluate` (remote evaluation). This is what the providers below use. | Environment variable or build config. Designed to be public; it only reveals resolved values. | | Server SDK key | `ds_srv_` | Downloading the whole ruleset for local evaluation (needs a native SDK, not available yet). | Secret store only. Never in a browser, mobile app or repository. | | API token | `ds_pat_` | Management API: create flags, toggle them, manage targeting. | Secret store only. Scoped; prefer the narrowest scopes. | ### The integration path today **No SDK package is published yet** Do not run `npm install @darkship/sdk` or `pip install darkship` and do not look for them on a registry. Until the SDKs are published, integrate through **OpenFeature** (the vendor-neutral standard) with the small provider on this page, which calls the remote evaluation endpoint. Do not re-implement rule evaluation yourself; that logic belongs to the SDKs and to the service. - Your application code only talks to the standard OpenFeature client (`getBooleanValue` and friends). The provider is the only Darkship-specific file. - When the native SDKs ship, only the provider registration changes; the flag calls in your code stay exactly the same. - **Limitation:** remote evaluation only serves flags that are enabled for **client-side use** (the flag's client-side setting in the console, `client_side: true` in the API). Enabling it means anyone holding the client key can read that flag's resolved value for any context they supply, so do not use it for secrets. A flag that is not client-side returns `FLAG_NOT_FOUND`. - Each remote evaluation call counts as a request against the plan quota. The providers cache the flags for a context for 10 seconds, which is usually enough; raise `cacheTtlMs` for hot paths. ### Steps for the agent - **Inspect the project.** Find the language, framework, package manager, where configuration lives, how tests are written, and whether OpenFeature is already used (search for `openfeature`). Reuse existing conventions. If OpenFeature is already present, add only the provider. - **Install the OpenFeature SDK** for the runtime: `@openfeature/server-sdk` (Node), `@openfeature/web-sdk` (browser) or `openfeature-sdk` (Python). Other languages: use that language's OpenFeature SDK and implement a provider from [the evaluation contract](https://darkship.tech/docs/ai/integrate/#contract). - **Add configuration.** Two variables, never hard-coded: `DARKSHIP_BASE_URL=https://sdk.darkship.tech` and `DARKSHIP_CLIENT_KEY=ds_cli_...` (one key per environment). Add them to the project's example env file with an empty key, never a real value. Do not commit keys. - **Add the provider file** from [the next section](https://darkship.tech/docs/ai/integrate/#providers), adapted to the project's style (imports, lint rules, logging). Keep it in one module, for example `src/lib/darkship-provider`. - **Register it once at startup** and wait for it to be ready, then use `OpenFeature.getClient()` anywhere. Do not create a provider per request. - **Evaluate with a safe default and a stable context.** Pass `targetingKey` (the user, organisation or anonymous-visitor id) and any attributes your rules use (`plan`, `country`, `orgId`...). Keep the old behaviour as the default so that nothing changes if the flag is missing. - **Wrap the feature** in a single, small conditional. One flag controls one decision; avoid scattering the same flag over many files, and avoid nested flags. - **Test without the network.** Use OpenFeature's in-memory provider in unit tests (`InMemoryProvider` in the JavaScript SDKs, `InMemoryProvider` in Python) so tests never call Darkship. Cover both flag states and the default path. - **Verify** with the checklist at the end of this page and tell the human which flags must exist, with which type, default and client-side setting. ### Providers These providers were tested against the production evaluation service. They resolve boolean, string, number and object flags, map reasons and errors to the OpenFeature standard, apply a request timeout, never cache a failed request, and bound the cache. `date` flags arrive as ISO-8601 strings (use string evaluation); `int` and `float` flags use number evaluation; `json` flags use object evaluation. **Node.js:** #### Node.js (server) ```bash $ npm install @openfeature/server-sdk ``` ```ts import { ErrorCode, StandardResolutionReasons, type EvaluationContext, type JsonValue, type Provider, type ResolutionDetails, } from '@openfeature/server-sdk'; type Wire = { value: JsonValue; variation_key: string | null; reason: 'OFF' | 'TARGET_MATCH' | 'RULE_MATCH' | 'FALLTHROUGH' | 'ERROR'; error_code?: string; bucket?: number; flag_type?: 'boolean' | 'string' | 'int' | 'float' | 'json' | 'date'; }; export interface DarkshipProviderOptions { /** Evaluation host, e.g. https://sdk.darkship.tech (no trailing path). */ baseUrl: string; /** Client key (ds_cli_...). Remote evaluation only accepts client keys. */ clientKey: string; timeoutMs?: number; /** How long the flags resolved for one context are reused. */ cacheTtlMs?: number; } const MAX_CACHED_CONTEXTS = 1000; /** OpenFeature context -> wire context: `targetingKey` becomes `key`, Dates become ISO strings. */ function toWireContext(ctx: EvaluationContext): Record { const out: Record = {}; for (const [k, v] of Object.entries(ctx)) { if (k === 'targetingKey' || v === undefined) continue; out[k] = v instanceof Date ? v.toISOString() : v; } if (typeof ctx.targetingKey === 'string' && ctx.targetingKey !== '') out.key = ctx.targetingKey; return out; } function mapReason(w: Wire): string { switch (w.reason) { case 'OFF': return StandardResolutionReasons.STATIC; case 'TARGET_MATCH': case 'RULE_MATCH': return w.bucket === undefined ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.SPLIT; case 'FALLTHROUGH': return w.bucket === undefined ? StandardResolutionReasons.DEFAULT : StandardResolutionReasons.SPLIT; default: return StandardResolutionReasons.ERROR; } } export class DarkshipProvider implements Provider { readonly metadata = { name: 'darkship-remote' } as const; readonly runsOn = 'server' as const; private readonly cache = new Map> }>(); constructor(private readonly opts: DarkshipProviderOptions) {} resolveBooleanEvaluation(key: string, def: boolean, ctx: EvaluationContext) { return this.resolve(key, def, ctx, (w) => typeof w.value === 'boolean'); } resolveStringEvaluation(key: string, def: string, ctx: EvaluationContext) { return this.resolve(key, def, ctx, (w) => typeof w.value === 'string'); } resolveNumberEvaluation(key: string, def: number, ctx: EvaluationContext) { return this.resolve(key, def, ctx, (w) => typeof w.value === 'number'); } resolveObjectEvaluation(key: string, def: T, ctx: EvaluationContext) { return this.resolve(key, def, ctx, (w) => typeof w.value === 'object' && w.value !== null); } private async resolve( flagKey: string, def: T, ctx: EvaluationContext, matchesType: (w: Wire) => boolean, ): Promise> { const fail = (errorCode: ErrorCode, errorMessage: string): ResolutionDetails => ({ value: def, reason: StandardResolutionReasons.ERROR, errorCode, errorMessage, }); let flags: Record; try { flags = await this.flagsFor(ctx); } catch (err) { return fail(ErrorCode.GENERAL, `darkship: evaluation request failed (${(err as Error).message})`); } const w = Object.hasOwn(flags, flagKey) ? flags[flagKey] : undefined; if (w === undefined || w.error_code === 'FLAG_NOT_FOUND') { return fail(ErrorCode.FLAG_NOT_FOUND, `darkship: flag "${flagKey}" not found, or not enabled for client-side use`); } if (w.reason === 'ERROR') return fail(w.error_code === 'INVALID_CONTEXT' ? ErrorCode.INVALID_CONTEXT : ErrorCode.GENERAL, `darkship: ${w.error_code ?? 'error'}`); if (!matchesType(w)) return fail(ErrorCode.TYPE_MISMATCH, `darkship: flag "${flagKey}" is a ${w.flag_type ?? 'different'} flag`); return { value: w.value as T, variant: w.variation_key ?? undefined, reason: mapReason(w), flagMetadata: { ...(w.flag_type ? { flagType: w.flag_type } : {}), ...(w.bucket !== undefined ? { bucket: w.bucket } : {}) }, }; } /** All client-visible flags for one context, cached briefly and shared by concurrent callers. */ private flagsFor(ctx: EvaluationContext): Promise> { const wireCtx = toWireContext(ctx); const cacheKey = JSON.stringify(Object.entries(wireCtx).sort(([a], [b]) => a.localeCompare(b))); const hit = this.cache.get(cacheKey); if (hit && Date.now() - hit.at < (this.opts.cacheTtlMs ?? 10_000)) return hit.flags; const flags = this.fetchFlags(wireCtx); this.cache.set(cacheKey, { at: Date.now(), flags }); flags.catch(() => this.cache.delete(cacheKey)); // never cache a failure if (this.cache.size > MAX_CACHED_CONTEXTS) this.cache.delete(this.cache.keys().next().value as string); return flags; } private async fetchFlags(context: Record): Promise> { const res = await fetch(`${this.opts.baseUrl.replace(/\/+$/, '')}/v1/sdk/evaluate`, { method: 'POST', headers: { authorization: `Bearer ${this.opts.clientKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ context }), signal: AbortSignal.timeout(this.opts.timeoutMs ?? 2000), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); return ((await res.json()) as { flags: Record }).flags; } } ``` ```ts import { OpenFeature } from '@openfeature/server-sdk'; import { DarkshipProvider } from './lib/darkship-provider'; await OpenFeature.setProviderAndWait( new DarkshipProvider({ baseUrl: process.env.DARKSHIP_BASE_URL!, clientKey: process.env.DARKSHIP_CLIENT_KEY!, }), ); const flags = OpenFeature.getClient(); // Per request: stable targetingKey + the attributes your rules use. const useNewCheckout = await flags.getBooleanValue( 'new-checkout-flow', false, // safe default: the old behaviour { targetingKey: user.id, plan: user.plan, orgId: org.id }, ); ``` **Browser:** #### Browser Browser providers resolve synchronously, so the flags for the current context are loaded in advance: when the provider starts and whenever you call `OpenFeature.setContext` (for example after sign-in). Use only a `ds_cli_` key here; the code must never contain a `ds_srv_` or `ds_pat_` value. ```bash $ npm install @openfeature/web-sdk ``` ```ts import { ErrorCode, StandardResolutionReasons, type EvaluationContext, type JsonValue, type Provider, type ResolutionDetails, } from '@openfeature/web-sdk'; type Wire = { value: JsonValue; variation_key: string | null; reason: 'OFF' | 'TARGET_MATCH' | 'RULE_MATCH' | 'FALLTHROUGH' | 'ERROR'; error_code?: string; bucket?: number; flag_type?: string; }; export interface DarkshipWebProviderOptions { baseUrl: string; /** Public client key (ds_cli_...). Never a server key (ds_srv_...). */ clientKey: string; timeoutMs?: number; } function toWireContext(ctx: EvaluationContext): Record { const out: Record = {}; for (const [k, v] of Object.entries(ctx)) { if (k === 'targetingKey' || v === undefined) continue; out[k] = v instanceof Date ? v.toISOString() : v; } if (typeof ctx.targetingKey === 'string' && ctx.targetingKey !== '') out.key = ctx.targetingKey; return out; } /** * Web providers resolve synchronously, so the flags for the current context are fetched in advance: * once in initialize() and again whenever the context changes (OpenFeature.setContext). */ export class DarkshipWebProvider implements Provider { readonly metadata = { name: 'darkship-web' } as const; readonly runsOn = 'client' as const; private flags: Record = {}; constructor(private readonly opts: DarkshipWebProviderOptions) {} async initialize(context?: EvaluationContext): Promise { await this.load(context ?? {}); } async onContextChange(_old: EvaluationContext, next: EvaluationContext): Promise { await this.load(next); } resolveBooleanEvaluation(key: string, def: boolean) { return this.resolve(key, def, (w) => typeof w.value === 'boolean'); } resolveStringEvaluation(key: string, def: string) { return this.resolve(key, def, (w) => typeof w.value === 'string'); } resolveNumberEvaluation(key: string, def: number) { return this.resolve(key, def, (w) => typeof w.value === 'number'); } resolveObjectEvaluation(key: string, def: T) { return this.resolve(key, def, (w) => typeof w.value === 'object' && w.value !== null); } private resolve(flagKey: string, def: T, matchesType: (w: Wire) => boolean): ResolutionDetails { const fail = (errorCode: ErrorCode, errorMessage: string): ResolutionDetails => ({ value: def, reason: StandardResolutionReasons.ERROR, errorCode, errorMessage, }); const w = Object.hasOwn(this.flags, flagKey) ? this.flags[flagKey] : undefined; if (w === undefined || w.error_code === 'FLAG_NOT_FOUND') { return fail(ErrorCode.FLAG_NOT_FOUND, `darkship: flag "${flagKey}" not found, or not enabled for client-side use`); } if (w.reason === 'ERROR') return fail(ErrorCode.GENERAL, `darkship: ${w.error_code ?? 'error'}`); if (!matchesType(w)) return fail(ErrorCode.TYPE_MISMATCH, `darkship: flag "${flagKey}" is a ${w.flag_type ?? 'different'} flag`); const reason = w.reason === 'OFF' ? StandardResolutionReasons.STATIC : w.bucket !== undefined ? StandardResolutionReasons.SPLIT : w.reason === 'FALLTHROUGH' ? StandardResolutionReasons.DEFAULT : StandardResolutionReasons.TARGETING_MATCH; return { value: w.value as T, variant: w.variation_key ?? undefined, reason }; } private async load(context: EvaluationContext): Promise { const res = await fetch(`${this.opts.baseUrl.replace(/\/+$/, '')}/v1/sdk/evaluate`, { method: 'POST', headers: { authorization: `Bearer ${this.opts.clientKey}`, 'content-type': 'application/json' }, body: JSON.stringify({ context: toWireContext(context) }), signal: AbortSignal.timeout(this.opts.timeoutMs ?? 3000), }); // Throwing here puts the provider in the ERROR state; every resolution then returns the caller's default. if (!res.ok) throw new Error(`darkship: evaluation request failed (HTTP ${res.status})`); this.flags = ((await res.json()) as { flags: Record }).flags; } } ``` ```ts import { OpenFeature } from '@openfeature/web-sdk'; import { DarkshipWebProvider } from './lib/darkship-web-provider'; await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan }); await OpenFeature.setProviderAndWait( new DarkshipWebProvider({ baseUrl: import.meta.env.VITE_DARKSHIP_BASE_URL, // https://sdk.darkship.tech clientKey: import.meta.env.VITE_DARKSHIP_CLIENT_KEY, // ds_cli_... }), ); const flags = OpenFeature.getClient(); const showBanner = flags.getBooleanValue('promo-banner', false); // After the user changes (sign-in, plan change), reload the flags: await OpenFeature.setContext({ targetingKey: newUser.id, plan: newUser.plan }); ``` For React, add `@openfeature/react-sdk` and wrap the app in `OpenFeatureProvider`; it works with this provider unchanged. **Python:** #### Python Standard library only (no HTTP dependency). Tested with `openfeature-sdk` 0.10; check the provider interface if you use a very different version. ```bash $ pip install openfeature-sdk ``` ```py from __future__ import annotations import json import time import urllib.error import urllib.request from typing import Any, Callable, Optional, Sequence, Union from openfeature.evaluation_context import EvaluationContext from openfeature.exception import FlagNotFoundError, GeneralError, InvalidContextError, TypeMismatchError from openfeature.flag_evaluation import FlagResolutionDetails, Reason from openfeature.hook import Hook from openfeature.provider import AbstractProvider, Metadata JsonValue = Union[bool, int, float, str, list, dict, None] class DarkshipProvider(AbstractProvider): """OpenFeature provider over the remote evaluation endpoint (client key, ds_cli_...).""" def __init__(self, base_url: str, client_key: str, timeout: float = 2.0, cache_ttl: float = 10.0) -> None: super().__init__() self._url = base_url.rstrip("/") + "/v1/sdk/evaluate" self._key = client_key self._timeout = timeout self._ttl = cache_ttl self._cache: dict[str, tuple[float, dict[str, Any]]] = {} def get_metadata(self) -> Metadata: return Metadata(name="darkship-remote") def get_provider_hooks(self) -> list[Hook]: return [] def resolve_boolean_details(self, flag_key: str, default_value: bool, evaluation_context: Optional[EvaluationContext] = None): return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, bool)) def resolve_string_details(self, flag_key: str, default_value: str, evaluation_context: Optional[EvaluationContext] = None): return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, str)) def resolve_integer_details(self, flag_key: str, default_value: int, evaluation_context: Optional[EvaluationContext] = None): return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, int) and not isinstance(v, bool)) def resolve_float_details(self, flag_key: str, default_value: float, evaluation_context: Optional[EvaluationContext] = None): return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, (int, float)) and not isinstance(v, bool)) def resolve_object_details(self, flag_key: str, default_value: Union[Sequence[JsonValue], dict], evaluation_context: Optional[EvaluationContext] = None): return self._resolve(flag_key, evaluation_context, lambda v: isinstance(v, (list, dict))) # The SDK turns a raised OpenFeature exception into the caller's default value plus an error code. def _resolve(self, flag_key: str, ctx: Optional[EvaluationContext], matches: Callable[[Any], bool]) -> FlagResolutionDetails: flags = self._flags_for(ctx) flag = flags.get(flag_key) if flag is None or flag.get("error_code") == "FLAG_NOT_FOUND": raise FlagNotFoundError(f'darkship: flag "{flag_key}" not found, or not enabled for client-side use') if flag.get("reason") == "ERROR": if flag.get("error_code") == "INVALID_CONTEXT": raise InvalidContextError("darkship: INVALID_CONTEXT") raise GeneralError(f"darkship: {flag.get('error_code', 'error')}") if not matches(flag["value"]): raise TypeMismatchError(f'darkship: flag "{flag_key}" is a {flag.get("flag_type", "different")} flag') reason = { "OFF": Reason.STATIC, "TARGET_MATCH": Reason.TARGETING_MATCH, "RULE_MATCH": Reason.TARGETING_MATCH, "FALLTHROUGH": Reason.DEFAULT, }.get(flag["reason"], Reason.UNKNOWN) if flag.get("bucket") is not None: reason = Reason.SPLIT return FlagResolutionDetails(value=flag["value"], variant=flag.get("variation_key"), reason=reason) def _flags_for(self, ctx: Optional[EvaluationContext]) -> dict[str, Any]: wire: dict[str, Any] = dict(ctx.attributes) if ctx else {} if ctx and ctx.targeting_key: wire["key"] = ctx.targeting_key cache_key = json.dumps(wire, sort_keys=True, default=str) hit = self._cache.get(cache_key) if hit and time.monotonic() - hit[0] < self._ttl: return hit[1] request = urllib.request.Request( self._url, data=json.dumps({"context": wire}, default=str).encode(), headers={"authorization": f"Bearer {self._key}", "content-type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=self._timeout) as response: flags = json.load(response)["flags"] except (urllib.error.URLError, TimeoutError, ValueError) as exc: raise GeneralError(f"darkship: evaluation request failed ({exc})") from exc if len(self._cache) >= 1000: self._cache.pop(next(iter(self._cache))) self._cache[cache_key] = (time.monotonic(), flags) return flags ``` ```py import os from openfeature import api from openfeature.evaluation_context import EvaluationContext from darkship_provider import DarkshipProvider api.set_provider(DarkshipProvider(os.environ["DARKSHIP_BASE_URL"], os.environ["DARKSHIP_CLIENT_KEY"])) flags = api.get_client() use_new_checkout = flags.get_boolean_value( "new-checkout-flow", False, # safe default: the old behaviour EvaluationContext(targeting_key=user.id, attributes={"plan": user.plan, "org_id": org.id}), ) ``` ### The evaluation contract For any other language, implement a provider from this contract. It is the same endpoint the providers above call ([full schema in the API explorer](https://darkship.tech/docs/api/)). ```http POST https://sdk.darkship.tech/v1/sdk/evaluate Authorization: Bearer ds_cli_... Content-Type: application/json { "context": { "key": "user-123", "plan": "pro", "country": "BR" } } ``` ```json { "version": "snapshot-version", "flags": { "new-checkout-flow": { "value": true, "variation_key": "on", "reason": "FALLTHROUGH", "flag_type": "boolean" }, "max-seats": { "value": 25, "variation_key": "pro", "reason": "RULE_MATCH", "flag_type": "int" }, "retired-flag": { "value": null, "variation_key": null, "reason": "ERROR", "error_code": "FLAG_NOT_FOUND" } } } ``` - `context` is required and must be a JSON object. Put the stable user or organisation id in `key`; every other attribute is free-form (strings, numbers, booleans, ISO-8601 dates, arrays of strings). Do not send secrets or more personal data than your rules need. - To evaluate only some flags add `"flag_keys": ["a", "b"]` (up to 1000). `POST /v1/sdk/evaluate/{flagKey}` evaluates one flag and returns that entry plus `flag_key` and `version`. - Responses are never cacheable on the HTTP level (`no-store`); cache in your client. Honour `Retry-After` on `429`. | Wire `reason` | OpenFeature reason | | --- | --- | | `OFF` | `STATIC` | | `TARGET_MATCH`, `RULE_MATCH` | `TARGETING_MATCH`, or `SPLIT` when `bucket` is present | | `FALLTHROUGH` | `DEFAULT`, or `SPLIT` when `bucket` is present | | `ERROR` (with `error_code`) | `ERROR` | | Situation | OpenFeature error code | | --- | --- | | Flag absent, archived, not client-side, or from another environment (`FLAG_NOT_FOUND`) | `FLAG_NOT_FOUND` | | Value type differs from the requested type | `TYPE_MISMATCH` | | Context rejected (`INVALID_CONTEXT`) | `INVALID_CONTEXT` | | Timeout, network failure, HTTP 401/403/5xx | `GENERAL` | ### Managing flags from the agent (optional) Flags can be created in the console or through the management API with an `ds_pat_` API token (created under the console's API tokens; give it only the flag scopes it needs). Look up the exact schemas in [/openapi.json](https://darkship.tech/openapi.json). The calls an agent usually needs: | Goal | Call | | --- | --- | | List projects and environments | `GET /v1/projects`, `GET /v1/projects/{projectId}/environments` | | List and read flags | `GET /v1/projects/{projectId}/flags`, `GET /v1/projects/{projectId}/flags/{flagKey}` | | Create a flag | `POST /v1/projects/{projectId}/flags` with `{ "key", "name", "type", "client_side": true }` | | Switch a flag on or off in one environment | `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/toggle` with `{ "enabled": true }` | | Read or change targeting | `GET`, `PATCH` or `PUT /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}` | | Preview an evaluation | `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/evaluate-preview` | | Create a client SDK key | `POST /v1/projects/{projectId}/environments/{envId}/sdk-keys` with `{ "role": "client", "name": "..." }` | ```bash $ curl -X POST https://api.darkship.tech/v1/projects/$PROJECT_ID/flags \ -H "Authorization: Bearer $DARKSHIP_API_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "key": "new-checkout-flow", "name": "New checkout flow", "type": "boolean", "client_side": true }' ``` Mutations accept an `Idempotency-Key` header and flags return an `ETag` to send back in `If-Match` when updating. See the [REST API reference](https://darkship.tech/docs/api-reference/) for conventions, pagination and errors. ### Rules for the agent #### Do - Use a safe default at every call site: the pre-flag behaviour, so a missing flag or an outage changes nothing. - Pass a stable `targetingKey`. For anonymous visitors, generate and persist an anonymous id; do not use a value that changes on every request, or percentage rollouts will flicker. - Register the provider once, and reuse the client. Keep flag evaluation off tight loops; evaluate once per request or render and pass the result down. - Name flags by capability in kebab-case (`new-checkout-flow`, `export-csv`), match the type to the use (`boolean` for on/off, `string` for variants, `int` for limits), and remove the flag and the dead branch once a rollout is complete. - Mock the provider in tests and cover both branches. - Ask the human before creating, changing or toggling flags in a production environment. #### Do not - Do not put a `ds_srv_` or `ds_pat_` value in browser code, mobile apps, repositories, logs or error messages. Do not print any key. - Do not put personal data, secrets or card numbers in flag keys, names, descriptions or rule values, and do not send more context attributes than the rules use. - Do not re-implement targeting or percentage bucketing, and do not call the evaluation endpoint on every render without a cache. - Do not use a flag for authorisation. Flags decide what to show or enable; permissions are enforced on your server. - Do not catch and hide evaluation errors in a way that loses the default; the OpenFeature client already returns the default. ### Verify - The flag exists in the target environment, has the intended type, and has client-side enabled. - The key is a `ds_cli_` key for the same environment, and the variables are set. - Check the raw response from a shell (replace the key; the value of `$KEY` is a client key): ```bash $ curl -s https://sdk.darkship.tech/v1/sdk/evaluate \ -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \ -d '{ "context": { "key": "user-1" } }' ``` - Evaluate once through OpenFeature and print the details (`getBooleanDetails`): check `value`, `variant`, `reason` and, if something is wrong, `errorCode`. - Run the project's tests and linter; make sure no key appears in the diff. | Symptom | Likely cause | | --- | --- | | HTTP 401 or 403 | Key missing, revoked, from another environment, or the wrong type (a `ds_srv_` or `ds_pat_` key on the evaluation endpoint). | | `FLAG_NOT_FOUND` | Typo in the key, flag archived, flag is not enabled for client-side use, or the key belongs to a different environment. | | `TYPE_MISMATCH` | The call type differs from the flag type (for example a string call on a boolean flag). | | Always the default | Service unreachable or timing out (check `DARKSHIP_BASE_URL` and the network), or the environment has no published ruleset yet. | | HTTP 429 | Rate limited. Raise the cache TTL and honour `Retry-After`. | | Changes take a few seconds to appear | Expected: the provider caches for 10 seconds per context and does not stream updates. | ### Related - [Evaluation](https://darkship.tech/docs/concepts/evaluation/) and [Targeting and rollouts](https://darkship.tech/docs/concepts/targeting/) explain the model in depth. - [OpenFeature provider (native SDK)](https://darkship.tech/docs/sdk/openfeature/) describes the provider that will ship with the SDK. - [Plan gating](https://darkship.tech/docs/recipes/plan-gating/) is a worked example of rules on a `plan` attribute. --- # MCP server URL: https://darkship.tech/docs/ai/mcp/ Description: Connect an AI agent to Darkship over MCP: manage projects, environments, flags, segments and read the audit log with a scoped API token. Setup, scopes, tools and the production safety model. Start · MCP server ## Let your agent manage flags over MCP The Darkship MCP server lets an AI agent (Claude Code, Cursor, Claude Desktop and any MCP client) list projects, change flags, edit targeting and read the audit log. It is a thin layer over the public REST API: the agent uses **your API token**, so it can never do more than that token allows, and every change is audited. ### Connect The server is remote and needs no installation. Create an API token in the console (the API tokens page) with the scopes you want the agent to have, then add the server to your client. ```json { "mcpServers": { "darkship": { "url": "https://mcp.darkship.tech/mcp", "headers": { "Authorization": "Bearer ds_pat_..." } } } } ``` With Claude Code, the same thing from the terminal: ```bash claude mcp add --transport http darkship https://mcp.darkship.tech/mcp \ --header "Authorization: Bearer ds_pat_..." ``` The transport is MCP Streamable HTTP, stateless: there are no sessions, and the token travels on every request. A missing or malformed token gets `401` before anything else happens. Keep the token in your client's secret store, never in a repository. ### Choose the token's scopes The server asks the API what your token can do and **lists only the tools the token is allowed to use**. If a tool is called without the scope, the error names the missing scope. There is no separate read-only switch: a token with read scopes only is a read-only agent. A token can also be limited to specific projects and environments and can expire (up to 365 days). | Agent role | Scopes | | --- | --- | | Read-only (inspect flags, audit, usage) | `project:read` `environment:read` `flag:read` `audit:read` `usage:read` `sdk_key:read` | | Flag operator (toggle, rollout, targeting) | read scopes above plus `flag:write` | | Flag author (also creates flags and segments) | flag operator plus `segment:manage` | | Project setup (creates projects and environments) | flag author plus `project:manage` `environment:manage` | Prefer the smallest set, limit the token to the projects and environments the agent needs, and set an expiry. For day-to-day work, avoid giving an agent write access to production environments at all. ### Tools | Area | Tools | | --- | --- | | Account | `whoami` | | Projects and environments | `list_projects` `get_project` `create_project` `list_environments` `create_environment` | | Flags | `list_flags` `get_flag` `create_flag` `update_flag` `flag_history` | | Flag configuration per environment | `get_flag_config` `toggle_flag` `set_rollout` `add_target_rule` `remove_target_rule` `set_targets` `evaluate_preview` | | Segments | `list_segments` `get_segment` `create_segment` `update_segment` | | Observability | `list_sdk_keys` (metadata only, never a secret) `audit_log` `usage` | Projects and environments can be given by key or by id. Not available on purpose: billing, members and invitations, organization changes, webhooks, API-token management, creating, rotating or revoking SDK keys, and every irreversible action (deleting or archiving a flag, deleting a segment, project or environment, restoring from history). Do those in the console. ### Production safety - **Production changes need `confirm: true`.** An environment counts as production when its key is `prod` or `production`, or when it requires confirmation. Without `confirm` the tool changes nothing and returns a before and after diff, so the agent (and you) can review it. Repeating the call with `confirm: true` applies it. - **Every change returns the diff**, including in non-production environments. - **No lost updates.** Writes carry the configuration version they read, so a concurrent edit is reported instead of overwritten. Environments that require a reason need one on every change. - **Visible in the audit log.** Requests from the MCP server carry the user agent `darkship-mcp/`; the console marks those events with a via MCP badge and the `audit_log` tool can filter them. - **Same limits as the API.** Rate limits, quotas and tenant isolation are enforced by the API for each token. ### Example requests ```text Which flags in the "checkout" project are enabled in prod? Turn off "new-pricing-page" in staging and show me the diff. Roll "search-v2" out to 20% in staging, 50/50 on the "beta" variation. What would user id 42 in the US get for "new-pricing-page" in prod? Show the audit events from the last day made through MCP. ``` ### Troubleshooting | Symptom | Cause | | --- | --- | | `401` on connect | Missing, malformed, revoked or expired token. Create a new one in the console. | | A tool is missing from the list | The token lacks its scope, or is limited to other projects or environments. `whoami` shows what the token has. | | "repeat with confirm: true" | The target is a production or protected environment. Review the diff, then confirm. | | `429` | API rate limit for the token. The error carries the retry delay. | The REST API behind the server is described in the [API reference](https://darkship.tech/docs/api-reference/), and to add flag evaluation to an application instead, see [Build with AI](https://darkship.tech/docs/ai/integrate/). --- # Hybrid evaluation URL: https://darkship.tech/docs/concepts/evaluation/ Description: Server SDKs evaluate locally from an in-memory ruleset; client SDKs ask the evaluation service. Same logic, different trust boundaries. Concepts · Evaluation ## Hybrid evaluation Server code evaluates flags locally from an in-memory ruleset. Browsers and phones ask us to evaluate and get answers, never rules. Same logic, different trust boundaries. ### Two paths, one evaluator A flag evaluation answers one question: for this flag, in this environment, and this context (a user key plus attributes), which variation do we serve? Where that question is answered matters for latency, availability and what data is exposed. Darkship answers it in two places. Pick a path below to see which parts of the system it touches. #### Evaluation path explorer **SERVER PATH** Ruleset downloaded once, revalidated with `ETag`; every evaluation after that is a function call in your process. #### Server vs client at a glance | | Server SDK | Client SDK | | --- | --- | --- | | Key | `ds_srv_` (secret) | `ds_cli_` (public by design) | | Where it evaluates | In your process, from a local ruleset | On the `eval` service, via `POST /v1/sdk/evaluate` | | Network on each call | None | None after the first fetch; results cached per context for the session | | Receives | Rules, segments, salts, schemas | Resolved values for one context | | Context leaves the process | Never | Sent for evaluation, used in memory, discarded | | Counts as a billable request | Each ruleset fetch, including 304 revalidations | Each remote evaluation | | Survives an `eval` outage | Yes, keeps the last ruleset | Yes, from the SDK's session cache; defaults for new contexts | ### Choosing a key - **Anything you run:** API servers, workers, cron jobs, serverless functions. Use a server key and the Node or Python SDK. - **Anything you ship:** web pages, mobile apps, desktop apps. Use a client key. Treat what it reveals (the values of your flags for a context you supply) as public. - **Other languages:** call the REST API; there is no local evaluation without an SDK. See the [data plane endpoints](https://darkship.tech/docs/api-reference/#eval). **Client keys are not secrets** A client key can only evaluate; it cannot read rules or change anything. You can restrict it to an allow-list of origins, but that is defence in depth, not authentication. Never put anything in a flag value that must stay confidential from your own users. ### One implementation, the same answer Rule matching, percentage bucketing and type handling are specified once and verified in every SDK and in the `eval` service against a shared set of language-neutral conformance fixtures (ruleset + context → expected variation). If the Python SDK, the JavaScript SDK and the remote endpoint disagree for the same ruleset and context, that is a bug in us, not a feature. The fixed algorithm is described in [Targeting and rollouts](https://darkship.tech/docs/concepts/targeting/). ### What we promise | Objective | Target | | --- | --- | | `eval` availability | 99.9% | | `eval` latency, server-side, warm cache | p95 < 50 ms | | Console click to SDK seeing the change | p95 < 5 s | These apply to the service. A server SDK evaluating locally has no network round trip to measure. ### Before the first ruleset arrives Evaluation never blocks and never throws. If you call it before the ruleset has loaded, you receive the default you passed, with reason `NOT_READY`. To avoid serving defaults on a cold start, either `await waitForReady()` at boot, or pass a `bootstrap` snapshot (see [init options](https://darkship.tech/docs/sdk/javascript/#init-options)). --- # Caching and fail static URL: https://darkship.tech/docs/concepts/caching/ Description: Versioned snapshots, ETag revalidation, sub-5-second propagation and fail-static behaviour when Redis or Postgres is down. Concepts · Caching ## Snapshots, ETags and failing static The database is never on the evaluation path. Every change becomes a versioned snapshot that is pushed through Redis to memory, and when a dependency breaks we keep serving the last good one. ### The ruleset snapshot Each environment has exactly one **snapshot**: the complete, self-contained ruleset for that environment. It holds every flag with its variations, targeting rules and per-flag salt, the resolved definitions of the segments those rules reference, and any JSON Schemas. It does not reference anything else, so a server SDK needs one request to evaluate every flag. The snapshot's content hash is its **version**, and the same value is the HTTP `ETag`. Two snapshots with the same hash are identical, which is what makes revalidation cheap. **Derived data** A snapshot is always rebuildable from the control-plane database. Losing Redis loses speed, never data. ### Snapshot propagation When you save a flag, toggle an environment, edit a segment or change a key's state, the API regenerates that environment's snapshot in the same operation as the committed change and writes it to Redis (write-through). It then publishes an invalidation on Redis pub/sub; every `eval` instance reloads the snapshot from Redis into its in-memory (L1) copy. A periodic comparison of version hashes catches any missed message. **FIG 1** Where the five seconds go. Server work is a fraction of a second and server SDKs stream by default, so the change lands well inside the budget; only an SDK that fell back to polling waits for its next poll. - **Commit and snapshot**API commits the change and regenerates the environment snapshot, computing its hash. api - **Write-through to Redis**The snapshot is stored in the same operation as the committed change. Reads never fill Redis from Postgres on a miss. redis - **Invalidate**A pub/sub message tells every `eval` instance to reload; a periodic hash comparison guards against a lost message. pub/sub - **SDK observes**A polling SDK sends `If-None-Match` and receives the new body on its next poll; a streaming SDK is told immediately. sdk **Streaming first, polling as the fallback** Server SDKs (JavaScript and Python) stream by default (`GET /v1/sdk/stream`; any event triggers an ETag refetch of the snapshot), which is what keeps the console-to-SDK p95 under 5 s. While the stream is down they poll every 30 seconds (jittered by ±20%) and reconnect; while it is up they still poll every 5 minutes as a safety net. With `streaming: false` an SDK sees a change within about one poll interval, and every poll, including a 304, counts as a request against your plan. The server can tell SDKs to slow down with `Retry-After`. ### ETag revalidation A server SDK stores the version it holds and sends it back on every poll. If nothing changed, the response is a tiny `304` with no body: ```http GET /v1/sdk/snapshot HTTP/1.1 Host: sdk.darkship.tech Authorization: Bearer ds_srv_prod_9Qm... If-None-Match: "492be076feb012e6aa08cf33ebb022944ef343728b997fa343cad22f29ad8712" HTTP/1.1 304 Not Modified ETag: "492be076feb012e6aa08cf33ebb022944ef343728b997fa343cad22f29ad8712" Cache-Control: private, max-age=0, must-revalidate ``` After a change the same request returns `200` with the new snapshot and a new `ETag`. The exact `Cache-Control` values are placeholders pending load tests; the contract (ETag and conditional requests) is stable, and a CDN could later sit in front of it without changing SDKs. ### Fail static If Redis or PostgreSQL becomes unavailable, `eval` keeps serving the last snapshot it holds in memory. It does not return errors, and it does not fall back to querying Postgres. Staleness is bounded only by how long the dependency is down, and is surfaced in metrics and in the `/ready` health output. Try it. Break something and see what stays up. #### Fail-static simulator All dependencies up Normal operation Console writes publish a new snapshot; evaluations resolve from memory. Nothing is degraded. #### Dependency outage matrix | Down | Evaluations | Flag changes | Visible as | | --- | --- | --- | --- | | Redis | Served from L1 memory; correct as of the last snapshot | Saved but not propagated until Redis returns | Snapshot age rising on `/ready` | | PostgreSQL | Unaffected | Rejected; console shows a banner | Control-plane errors only | | Both | Served from L1 memory | Not possible | Snapshot age rising; banner in console | | `eval` itself | Server SDKs keep their in-memory ruleset; client SDKs use session cache | Unaffected | SDK `error` events; status page | ### Cold start edge cases - **A fresh `eval` instance starts while Redis is down.** It has no snapshot, so it reports not-ready and receives no traffic. Other instances keep serving. - **A fresh server SDK starts while we are unreachable.** With no ruleset it returns your defaults (reason `NOT_READY`). Supply a `bootstrap` snapshot you saved earlier to avoid even that. - **A running server SDK loses us.** It keeps evaluating from its last ruleset indefinitely, retrying with exponential backoff and jitter, and emits an `error` event. **Static means stale, on purpose** During an outage, a flag you flipped minutes ago may not be flipped for running SDKs. We choose "stale but consistent with a real past state" over errors. If you need an emergency off-switch that doesn't depend on us, wrap the risky path with your own config as well. ### Quota never stops evaluation The same philosophy applies to billing and quota: evaluation endpoints keep serving in every plan state, including over-limit. Only control-plane writes that create new things (flags, keys, members) are restricted after a grace period. --- # Targeting and rollouts URL: https://darkship.tech/docs/concepts/targeting/ Description: Flag types, variations, rule evaluation order and stable-hash percentage rollouts, with a bucketing calculator. Concepts · Targeting ## Types, rules and rollouts A flag has one fixed type, named variations, and per-environment rules evaluated in a strict order. Percentage rollouts are stable hashes, so nobody flips back and forth, and nothing about your users is stored. ### Flag types Every flag has exactly one type, chosen at creation and immutable afterwards. Asking for the wrong type (calling `boolVariation` on a string flag) never coerces; you get your default and an error reason. Changing a type means creating a new flag and archiving the old one. | Type | Value domain | Accessor (JS / Python) | Notes | | --- | --- | --- | --- | | boolean | `true` / `false` | `boolVariation` / `bool_variation` | Always has the two variations `on` and `off`. | | string | UTF-8 string | `stringVariation` / `string_variation` | Good for copy, theme names, enum-like switches. | | int | 64-bit-safe integer | `intVariation` / `int_variation` | JSON numbers beyond 2 53 are rejected on save. | | float | IEEE-754 double | `floatVariation` / `float_variation` | `NaN` and `Infinity` are rejected. | | json | Any JSON value, validated by a schema | `jsonVariation` / `json_variation` | Stored JSON Schema (draft 2020-12 subset, no remote `$ref`). Invalid values are refused at save time, never discovered at runtime. | | date | RFC 3339 instant, UTC-normalised | `dateVariation` / `date_variation` | Returned as a `Date` in JS and an aware `datetime` in Python. | A JSON flag example. The schema lives on the flag and every variation is validated against it: ```json { "type": "object", "required": ["rps", "burst"], "properties": { "rps": { "type": "integer", "minimum": 1, "maximum": 1000 }, "burst": { "type": "integer", "minimum": 1 } }, "additionalProperties": false } ``` **A schema change can be refused** If a schema edit would invalidate a variation that is in use in any environment, the save is rejected. Fix or replace the variation first. ### Variations and environments A **variation** is a named, typed value (`on`, `off`, `blue`, `limit-100`). Names are unique per flag and are defined at the flag (project) level. Each **environment** then decides which variation to serve and when: **enabled**: The environment-level off switch. When false, the flag serves `offVariation` and rules are not consulted. **offVariation**: Served when the flag is disabled. **defaultVariation**: The fallthrough: served when enabled and no target or rule matches. Can be a rollout. **targetLists**: Ordered lists of user ids, emails, groups or any other attribute, each served one variation. See [target lists](https://darkship.tech/docs/concepts/targeting/#target-lists). **rules**: Ordered list of rules. Each rule is an OR of AND groups of conditions, with a variation or a percentage rollout. ### Evaluation order For a flag, an environment and a context, evaluation is a pure function of the snapshot and the context. It runs these steps in this exact order and stops at the first that yields a result: - **Flag missing or ruleset unavailable**Return the caller-supplied default. ERROR / DEFAULT - **Environment disabled**Serve `offVariation`. OFF - **Target lists**Lists are checked in order; the first list whose attribute (`key`, `email`, `groups`, ...) matches serves its variation. TARGET_MATCH - **Rules, top to bottom**A rule matches when any of its groups matches; inside a group all conditions must match. The first rule whose conditions all match decides: a fixed variation, or a weighted rollout. RULE_MATCH - **Fallthrough**Serve `defaultVariation` (or its rollout). FALLTHROUGH #### Worked example The production ruleset of `new-checkout-flow`, with the environment enabled, `offVariation: off`, salt `k3x9`: ```json { "enabled": true, "offVariation": "off", "targetLists": [{ "id": "tl_1", "matchBy": "key", "values": ["usr_lena"], "variation": "on" }], "rules": [ { "id": "r1", "if": [{ "attr": "plan", "op": "equals", "value": "enterprise" }, { "attr": "country", "op": "in", "value": ["BR", "AR"] }], "serve": { "variation": "on" } }, { "id": "r2", "if": [{ "attr": "country", "op": "equals", "value": "BR" }], "serve": { "rollout": [{ "variation": "on", "weight": 2000 }, { "variation": "off", "weight": 8000 }] } } ], "defaultVariation": "off" } ``` | Context | Path | Bucket | Result | Reason | | --- | --- | --- | --- | --- | | `usr_lena` | Matches target list 1 (`key`) | n/a | on | `TARGET_MATCH` | | `usr_iris` · enterprise · BR | Skips targets; both conditions of r1 match | n/a | on | `RULE_MATCH` | | `u_1042` · free · BR | r1 fails (plan); r2 matches, rollout | 120 | on (120 < 2000) | `RULE_MATCH` (bucket 120) | | `u_2291` · free · BR | r1 fails; r2 matches, rollout | 4,562 | off (4562 ≥ 2000) | `RULE_MATCH` (bucket 4562) | | `usr_tomas` · pro · US | No target, no rule matches | n/a | off | `FALLTHROUGH` | Notice `u_2291`: a rule that matches always decides, even when the rollout assigns the "off" bucket. It does not fall through to later rules. Put rollouts last, or give each audience its own rule. ### Conditions A condition tests one attribute of the context. Conditions inside a group are combined with AND, and a rule is an [OR of groups](https://darkship.tech/docs/concepts/targeting/#or-groups). | Group | Operators | Applies to | | --- | --- | --- | | Equality | `equals`, `not_equals`, `in`, `not_in` | string, number, boolean | | String | `contains`, `starts_with`, `ends_with`, `matches` | string; `matches` is a length-limited, RE2-safe regular expression | | Numeric | `lt`, `lte`, `gt`, `gte` | number | | Date | `before`, `after` | RFC 3339 string | | Semantic version | `semver_eq`, `semver_lt`, `semver_gt` and friends | version string, e.g. `app_version` | | Segment | `in_segment`, `not_in_segment` | the context key | **Missing and mistyped attributes never throw** If the context lacks the attribute, the positive operators do not match. If the attribute has the wrong type for the operator (a string where `gt` expects a number), the condition simply does not match. The `not_*` operators treat a missing attribute as "not matching the positive form", so they are true. For array attributes see [array attributes](https://darkship.tech/docs/concepts/targeting/#arrays). If you rely on an attribute, send it on every evaluation. ### Segments A segment is a named, reusable set at the project level, with membership per environment: included lists, excluded lists (both keyed by `key`, `email`, `groups` or another attribute, like target lists) and optional attribute rules. **Excluded wins**: a context on an excluded list is never a member, whatever the included lists and rules say; then included lists; then attribute rules. Rules reference segments by id, and the snapshot embeds the resolved definitions, so a server SDK needs no extra fetch. Segments cannot reference other segments. ### Target users by id, email or group The evaluation context stays one flat object. `key` is the user id; `email`, `name` and `groups` (an array of strings) are the conventional optional fields; anything else is a custom attribute. Target lists choose which attribute they match by: ```json { "key": "usr_6140", "email": "Ana@ACME.com", "groups": ["beta-testers", "billing-admins"], "plan": "pro" } ``` ```json [ { "id": "tl_1", "matchBy": "key", "values": ["usr_1203", "usr_7712"], "variation": "on" }, { "id": "tl_2", "matchBy": "email", "values": ["ana@acme.com", "bo@acme.com"], "variation": "on" }, { "id": "tl_3", "matchBy": "groups", "values": ["beta-testers", "acme-staff"], "variation": "on" }, { "id": "tl_4", "matchBy": "groups", "values": ["opted-out"], "variation": "off" } ] ``` The lists are checked in order at step 3 and **the first list that matches wins**. A list matching by `groups` matches when any element of the context's `groups` is in `values`. The console refuses to save a value that serves two different variations within one `matchBy` (so results are deterministic); across different attributes, list order decides. Lists can be edited in the console (chips, paste box, CSV import) or through the [API](https://darkship.tech/docs/api-reference/#targets-get). #### Array attributes Attributes may be arrays of strings, numbers or booleans. Positive operators (`equals`, `in`, `contains`, `starts_with`, ...) are true when **any** element matches. Negative operators (`not_equals`, `not_in`) are true only when **no** element matches. A missing attribute never matches a positive operator. | Condition | Result | Why | | --- | --- | --- | | `groups in ["beta", "qa"]` | match | "beta" is an element | | `groups not_in ["suspended"]` | no match | "suspended" is an element | | `groups not_in ["banned"]` | match | no element is "banned" | #### Email normalisation The `email` attribute is normalised on **both sides**, in lists and in rule operators: Unicode NFC, trim, locale-independent lowercase. `Ana@ACME.com` in the context matches `ana@acme.com` in a list, and `email ends_with "@acme.com"` matches either casing. Every other attribute, including `key`, is compared exactly. SDKs and the evaluation service share conformance vectors for this. #### OR of AND groups A rule has one or more **groups**; a group has one or more conditions. The rule matches when any group matches, and a group matches when all its conditions match. The snapshot field is `groups`; the older `conditions` array is read as a single group. Placeholder limits: 5 groups per rule, 10 conditions per group. ```json { "id": "r2", "groups": [ [ { "attr": "email", "op": "ends_with", "value": "@example.com" }, { "attr": "groups", "op": "not_in", "value": ["suspended"] } ], [ { "attr": "groups", "op": "in", "value": ["acme-staff"] } ] ], "serve": { "variation": "on" } } ``` #### Targeting lab runs the shared engine Flag `ai-search-beta`, production lists above, default off. Edit the context and see which step decides. Context JSON{ "key": "usr_6140", "email": "Ana@ACME.com", "groups": ["billing-admins"] } #### Limits per plan (placeholders) Target-list entries per flag and environment, and entries per segment, are bounded per plan so snapshots stay small and fast. Evaluation compiles lists to hash sets when a snapshot loads. These numbers are **placeholders**, not final pricing. | Limit | Free | Pro | Business | | --- | --- | --- | --- | | Target-list entries per flag-environment | 100 | 5,000 | 50,000 | | Entries per segment | 100 | 5,000 | 50,000 | Going over a limit **blocks the save** with the count and the limit. Entries are never truncated silently. CSV import previews added, duplicate, invalid and over-limit counts before anything is applied, and refuses an import that would exceed the limit. #### Privacy: lists hold identifiers — prefer opaque ids Target and segment lists contain identifiers of your end users, stored in plaintext; we hold them as your processor. They are delivered to **server** SDK keys only (client keys use remote evaluation and never receive lists). Audit events record counts and masked samples (`a***@acme.com`), never full values. Prefer opaque user ids over emails where you can, and remove entries when you no longer need them; hashed lists are not part of the MVP. #### Worked example For the lists above and `ai-search-beta` (production, default off, no matching rule): | Context | Result | Reason | | --- | --- | --- | | `key: usr_7712` | on | `TARGET_MATCH`, list 1 (`key`) | | `email: " Bo@ACME.com "` | on | `TARGET_MATCH`, list 2 (`email`, normalised to `bo@acme.com`) | | `groups: ["finance", "acme-staff"]` | on | `TARGET_MATCH`, list 3 (`groups`, any element) | | `groups: ["opted-out", "beta-testers"]` | on | List 3 matches first (order wins); list 4 is never consulted | | `key: usr_1, email: eve@example.com` | off | `FALLTHROUGH` | ### Percentage rollouts and bucketing A rollout is a list of `(variation, weight)` pairs with weights in **basis points** summing to exactly 10,000 (so 0.01% granularity; 10% is 1,000). Choosing a variation for a context is deterministic and stateless: - **Pick the bucketing attribute.** `context.key` by default, or a configured attribute such as `orgId` to keep a whole account on the same side. If the attribute is missing, the rollout step is skipped and evaluation continues to the fallthrough. - **Build the input string** `"{flagKey}.{salt}.{attributeValue}"`. The salt is a random value created with the flag, per environment. - **Hash it:** MurmurHash3, x86, 32-bit, seed 0, over the UTF-8 bytes. - **Bucket** = `hash mod 10000`, an integer from 0 to 9999. - **Walk the variations in listed order**, accumulating weights; serve the first whose cumulative weight is greater than the bucket. #### Bucketing calculator runs the real algorithm Flag key Salt User key Rollout to **20%** (2000 basis points on `on`)Drag from 10 to 20 and watch which users get added; nobody already in leaves. input string**-** murmur3_x86_32**-** bucket = hash mod 10000**-** served**-** 02,5005,0007,5009,999 | User key | Bucket | At 10% | At selected % | | --- | --- | --- | --- | #### Properties you can rely on - **Sticky.** Same flag, salt and key always produce the same bucket. No state is stored; there is nothing to sync between SDKs. - **Monotonic.** Raising a variation's weight from 10% to 20% (1,000 to 2,000 bps) only moves users into it: buckets 0 to 999 stay, 1,000 to 1,999 join. Nobody who had the new experience loses it. This holds as long as the variation order is unchanged. - **Independent across flags.** The flag key is part of the hash input, so the 10% on one flag are not the same 10% on another. - **Identical everywhere.** Every SDK and the `eval` service run the same algorithm against shared conformance vectors. **Things that reshuffle everyone** Regenerating the **salt**, reordering the **variations** of a rollout, or switching the **bucketing attribute** moves users between buckets. Do it deliberately (for instance to restart an experiment), never as part of a routine edit. The console warns before saving any of these. ### What this means for privacy Because bucketing is a function of the key and a salt, we never record "user X saw variation Y". Debug a specific user in the evaluation playground, where you type a context for one call; it is not stored. See [telemetry](https://darkship.tech/docs/sdk/javascript/#telemetry) for the aggregate counters we do keep. --- # JavaScript SDK URL: https://darkship.tech/docs/sdk/javascript/ Description: Install, initialise, evaluate typed flags, handle errors and shut down the darkship JavaScript and TypeScript SDK. SDKs · JavaScript / TypeScript ## JavaScript SDK One package, two modes. In Node it evaluates locally from an in-memory ruleset. In the browser it asks the evaluation service and caches the answers for the session. The API shape is the same. **@darkship/sdk**npm package, ESM + CJS, bundled types **0.1.0**pre-1.0: minor versions may break **Node 18+**server mode, local evaluation **ES2020**browser mode, remote evaluation ### Install ```bash $ npm install @darkship/sdk $ pnpm add @darkship/sdk $ yarn add @darkship/sdk ``` ### Initialise `init()` returns a client immediately and loads flags in the background. The key prefix selects the mode: `ds_srv_` runs server mode, `ds_cli_` runs client mode. The browser build refuses a `ds_srv_` key at init. **Node.js (server):** ```ts import { init } from '@darkship/sdk'; export const ds = init({ sdkKey: process.env.DARKSHIP_SDK_KEY!, // host: DARKSHIP_BASE_URL or baseUrl; changes arrive over SSE within seconds (polling is the fallback) }); ds.on('error', (err) => logger.warn({ err }, 'darkship error')); await ds.waitForReady({ timeout: 3000 }); ``` **Browser (client):** ```ts import { init } from '@darkship/sdk'; export const ds = init({ sdkKey: 'ds_cli_prod_Zk3...', context: { key: user.id, plan: user.plan, country: user.country }, }); ds.on('update', ({ changed }) => rerender(changed)); await ds.waitForReady({ timeout: 2000 }); ``` #### Init options and Darkship-Sdk: js/. (scrollable table)"> | Option | Type | Default | Description | | --- | --- | --- | --- | | `sdkKey` | string | required | `ds_srv_...` or `ds_cli_...`. Determines mode and environment. | | `context` | Context | `undefined` | Client mode only: initial evaluation context. Change it with `identify()`. | | `baseUrl` | string | `DARKSHIP_BASE_URL`, else `https://sdk.darkship.tech` | Evaluation host. Override for a proxy or a self-hosted test double. `https` only; plain `http` is accepted for loopback hosts. | | `allowInsecureHttp` | boolean | `false` | Allow plain `http` on a non-loopback host (a private network or a test double). The SDK emits a warning: the key travels unencrypted. | | `streaming` | boolean | `true` | Server mode. Subscribes to SSE (`GET /v1/sdk/stream`): a change reaches the SDK within seconds. On any stream error the SDK keeps polling and reconnects. `false` for polling only (for example behind a proxy that buffers responses). | | `pollIntervalMs` | number | `30000` | Server mode. The fallback poll while the stream is down (every 5 minutes while it is connected). Jittered by ±20%. `Retry-After` is honoured and failures back off exponentially. | | `timeoutMs` | number | `3000` | Bound on every network call. SDK work never runs on your call path. | | `bootstrap` | Snapshot or `allFlags()` output | `undefined` | Server mode: a snapshot you saved earlier (`exportSnapshot()`), used until the first fetch succeeds. Client mode: the output of `allFlags()` from your server, for server-side rendering. | | `persist` | boolean | `false` | Client mode. Keeps the last resolved values (never the context) in `localStorage`. | | `fetch` | function | global `fetch` | Injectable, for tests and proxies. | | `telemetry` | boolean or object | `true` | Server mode. Aggregate counters only; `false` turns them off. See [telemetry](https://darkship.tech/docs/sdk/javascript/#telemetry). | | `telemetry.flushIntervalMs` | number | `60000` | Also flushes on a size threshold and on `close()`. | **Each poll is a request** Every snapshot fetch (`GET /v1/sdk/snapshot`), including a `304`, counts toward your plan's monthly requests. A fleet of 200 processes polling every 30 seconds is about 576,000 requests a day. With the default `streaming: true` a connected SDK only polls every 5 minutes as a safety net; if you turn streaming off, raise `pollIntervalMs` where fast propagation is not critical. #### waitForReady `await ds.waitForReady({ timeout })` resolves with `true` when a ruleset (or, in client mode, the first evaluation result) is available, and `false` on timeout. It never rejects. Evaluations before readiness return your default with reason `NOT_READY`. ### Evaluate flags Each accessor takes the flag key, a context and a default in server mode: `getBooleanValue(key, ctx, default)`. In client mode there is no context argument: `getBooleanValue(key, default)` uses the context set at init or by `identify`. They are synchronous and perform no I/O. | Method | Alias | Returns | Flag type | | --- | --- | --- | --- | | `getBooleanValue(key, ctx, def)` | `boolVariation` | `boolean` | boolean | | `getStringValue(key, ctx, def)` | `stringVariation` | `string` | string | | `getIntValue(key, ctx, def)` | `intVariation` | `number` (integer) | int | | `getFloatValue(key, ctx, def)` | `floatVariation` | `number` | float | | `getJsonValue(key, ctx, def)` | `jsonVariation` | `T` | json | | `getDateValue(key, ctx, def)` | `dateVariation` | `Date` | date | | `getNumberValue(key, ctx, def)` | | `number` | int or float | | `get*Detail(...)` | `*VariationDetail` | `{ flagKey, value, variationKey, reason, ruleId?, bucket?, errorCode?, flagType? }` | same as above | Other methods: `allFlags(ctx)` (values only; usable as a client `bootstrap`), `refresh()`, `isReady()`, `close()`; server: `exportSnapshot()`, `snapshotVersion()`, `flagKeys()`; client: `identify()` (alias `setContext()`). ```ts const d = ds.getBooleanDetail('new-checkout-flow', ctx, false); // { flagKey: 'new-checkout-flow', value: true, variationKey: 'on', reason: 'FALLTHROUGH', bucket: 4127, flagType: 'boolean' } const bad = ds.getIntDetail('promo-banner-text', ctx, 0); // { flagKey: 'promo-banner-text', value: 0, reason: 'ERROR', errorCode: 'TYPE_MISMATCH' } ``` #### Reasons and error codes | `reason` | Meaning | | --- | --- | | `OFF` | Environment disabled; served `offVariation`. | | `TARGET_MATCH` | A target list matched: the context `key`, `email`, a `groups` element or another listed attribute. | | `RULE_MATCH` | A rule matched and served a fixed variation. | | `FALLTHROUGH` | Enabled, nothing matched; served the default variation. When a percentage rollout decided (in a rule or the fallthrough) the detail carries `bucket` (0 to 9999); there is no separate rollout reason. | | `DEFAULT` | Your default was returned because the client was closed. | | `NOT_READY` | No ruleset yet; your default was returned, with `errorCode` `PROVIDER_NOT_READY`. | | `ERROR` | Something went wrong; your default was returned. See `errorCode`: `FLAG_NOT_FOUND`, `TYPE_MISMATCH`, `INVALID_CONTEXT`, `MALFORMED_FLAG`, `GENERAL`, `PROVIDER_NOT_READY`. | ### Context A context is a plain object. `key` (the user id) is required: it is the default bucketing attribute and what `key` target lists match. `email`, `name` and `groups` are optional, conventional fields: target lists can match by `email` (normalised: NFC, trimmed, lowercased on both sides) or by `groups` (matches when any element is listed). Everything else is an attribute that rules can test. See [target users by id, email or group](https://darkship.tech/docs/concepts/targeting/#identity). ```ts type Context = { key: string; // stable id: user id, device id, org id email?: string; // matched case-insensitively by email lists and rules name?: string; groups?: string[]; // any element may match a groups list [attribute: string]: string | number | boolean | string[] | undefined; }; const ctx = { key: 'usr_marta', email: 'Marta@Acme.com', groups: ['beta-testers', 'acme-staff'], plan: 'pro', country: 'BR', app_version: '4.12.0', orgId: 'org_acme', }; ``` - Use a stable key. A random key per request defeats sticky rollouts. - Don't send secrets or personal data you don't need for targeting. In server mode the context never leaves your process; in client mode it is sent for evaluation, used in memory and discarded. - Browser: `await ds.identify(newContext)` replaces the context, triggers one re-fetch and emits `update` if any value changed. ### Events ```ts ds.on('ready', ({ version }) => log.info('ruleset', version)); ds.on('update', ({ version, changed }) => log.info('flags changed', changed)); ds.on('error', (err) => log.warn(err.code, err.message)); // never thrown, only emitted ds.on('stale', () => log.warn('serving the last good ruleset')); ds.on('recovered', () => log.info('control plane reachable again')); ``` ### Telemetry and privacy The SDK counts evaluations in memory per flag × variation × one-minute window and flushes the counters to `POST /v1/telemetry/counters` on an interval, when a size threshold is reached, and on `close()`. These are the only fields that leave your process: | Field | Example | Notes | | --- | --- | --- | | `environment` | derived from key | Never sent as free text. | | `flag_key` | `checkout-v2` | | | `variation` | `on` | The variation name, not the value of a JSON flag. | | `window_start` | `2026-10-28T14:03:00Z` | One-minute window. | | `count` | `1842` | | | `sdk`, `sdk_version` | `js/0.1.0` | For support and deprecation planning. | **By design** No context, no user id, no per-user history and no raw events are stored. The console shows distributions and counts. If you need "who saw what", log it in your own system. ### Error handling and defaults Evaluation never throws. When anything is wrong you get the default you passed, plus a reason in the `*Detail` variants and an `error` event for connectivity problems. | Situation | Returned | Reason / code | | --- | --- | --- | | Called before the first ruleset | your default | `NOT_READY` / `PROVIDER_NOT_READY` | | Flag key doesn't exist in this environment | your default | `ERROR` / `FLAG_NOT_FOUND` | | Accessor type differs from the flag type | your default | `ERROR` / `TYPE_MISMATCH` | | Network failure after a ruleset was loaded | evaluates from the last ruleset | unchanged; `error` event emitted | | Invalid context (for example no `key`) | your default | `ERROR` / `INVALID_CONTEXT` | | Internal error | your default | `ERROR` / `GENERAL` | `init()` itself throws synchronously only for programmer errors: no key, a malformed prefix, or a server key in a browser build. ### Shutdown ```ts process.on('SIGTERM', async () => { await ds.close({ timeout: 2000 }); // stop polling, flush counters (best effort), release timers process.exit(0); }); ``` In serverless functions call `await ds.close()` at the end of the invocation, or keep the client at module scope across warm invocations and flush before the runtime freezes. ### Versions and support The package is at `0.1.0`. Until 1.0, minor versions may include breaking changes, listed in the changelog. Server mode needs Node 18 or newer; client mode runs in evergreen browsers. Behaviour is verified against the shared conformance vectors through both the local and the remote path, and a change to targeting semantics ships in the same release as the `eval` service change. Constructing the SDK with a `ds_srv_` key in a browser, web worker or jsdom throws `DarkshipConfigError("SERVER_KEY_IN_BROWSER")`. --- # Python SDK URL: https://darkship.tech/docs/sdk/python/ Description: Install, initialise, evaluate typed flags, handle errors and test with the darkship Python SDK. SDKs · Python ## Python SDK A server SDK for Python services. It downloads your environment's ruleset on a background thread and evaluates in-process, so a flag check is a dictionary lookup and a hash, not an HTTP call. **darkship**PyPI package, typed (PEP 561) **0.1.0**pre-1.0 release **3.10+**CPython, pure Python (`httpx`) **server**local evaluation, `ds_srv_` keys only **Server mode only** The Python SDK is a server SDK. Do not embed a `ds_srv_` key in a distributed application (a desktop app, a notebook you share). For untrusted clients use the JavaScript client SDK or the [evaluate endpoint](https://darkship.tech/docs/api-reference/#eval) with a client key. ### Install ```bash $ pip install darkship $ poetry add darkship $ uv add darkship $ pip install "darkship[openfeature]" # adds the OpenFeature provider ``` ### Initialise ```py import os import darkship ds = darkship.init( sdk_key=os.environ["DARKSHIP_SDK_KEY"], # ds_srv_prod_... ) # host: DARKSHIP_BASE_URL or base_url=... ds.on("error", lambda err: log.warning("darkship: %s", err)) ready = ds.wait_for_ready(timeout=3.0) # True or False, never raises ``` Create one client per process and share it. It owns a polling thread and an in-memory ruleset; creating one per request would multiply both. #### Init options . (scrollable table)"> | Argument | Type | Default | Description | | --- | --- | --- | --- | | `sdk_key` | str | required | A `ds_srv_` key. Other prefixes raise `ValueError` at init. | | `base_url` | str | `DARKSHIP_BASE_URL`, else `https://sdk.darkship.tech` | Evaluation host. Must be `https` (plain `http` only for loopback, unless `allow_insecure_http=True`). | | `allow_insecure_http` | bool | `False` | Allow plain `http` on a non-loopback host (private network, test double). Logs a warning on the `darkship` logger: the key travels unencrypted. | | `streaming` | bool | `True` | SSE (`GET /v1/sdk/stream`): a change reaches the SDK within seconds. Any stream error falls back to polling, which never stops, and the stream reconnects. `False` for polling only. | | `poll_interval` | float (s) | `30.0` | The fallback poll (at least 30 s while the stream is connected). Jittered by up to 20%, minimum 1.0. `Retry-After` is honoured and failures back off exponentially. | | `timeout` | float (s) | `3.0` | Bound on every network call. | | `bootstrap` | dict | `None` | A snapshot saved earlier (`ds.snapshot()`), used until the first fetch succeeds. | | `offline` | bool | `False` | Never open a socket; serve `bootstrap` only. For tests and air-gapped jobs. | | `transport` | `httpx.BaseTransport` | `None` | Inject a transport (proxies, mTLS, `httpx.MockTransport` in tests). | | `telemetry` | bool \| dict | `True` | `{"flush_interval": 60.0}` to tune; `False` to turn off counters. | #### Frameworks and process models **Initialise after you fork** Background threads don't survive `fork()`. With Gunicorn or uWSGI preload, create the client in a `post_fork` hook (or lazily on first use), not at import time in the master process. **FastAPI:** ```py from contextlib import asynccontextmanager from fastapi import FastAPI import darkship, os @asynccontextmanager async def lifespan(app: FastAPI): app.state.ds = darkship.init(sdk_key=os.environ["DARKSHIP_SDK_KEY"]) app.state.ds.wait_for_ready(timeout=3.0) yield app.state.ds.close(timeout=2.0) # flushes counters app = FastAPI(lifespan=lifespan) ``` **Django:** ```py from django.apps import AppConfig import darkship, os class CoreConfig(AppConfig): name = "core" ds = None def ready(self): # runs once per worker process CoreConfig.ds = darkship.init(sdk_key=os.environ["DARKSHIP_SDK_KEY"]) ``` **Plain script:** ```py import darkship with darkship.init(sdk_key=KEY) as ds: # close() on exit ds.wait_for_ready(timeout=3.0) if ds.bool_variation("nightly-reindex", {"key": "job"}, False): reindex() ``` ### Evaluate flags ```py ctx = {"key": "usr_marta", "plan": "pro", "country": "BR", "orgId": "org_acme"} on = ds.bool_variation("new-checkout-flow", ctx, False) banner = ds.string_variation("promo-banner-text", ctx, "Welcome back") retries = ds.int_variation("max-retries", ctx, 3) ratio = ds.float_variation("sample-ratio", ctx, 0.1) limits = ds.json_variation("rate-limits", ctx, {"rps": 10, "burst": 20}) cutover = ds.date_variation("cutover-at", ctx, None) # aware datetime in UTC d = ds.get_boolean_details("new-checkout-flow", ctx, False) # EvaluationDetail(value=True, variation='on', reason='FALLTHROUGH', error_code=None, bucket=4127) ``` | Method | Alias | Returns | Flag type | | --- | --- | --- | --- | | `get_boolean_value(key, ctx, default)` | `bool_variation` | `bool` | boolean | | `get_string_value(...)` | `string_variation` | `str` | string | | `get_int_value(...)` | `int_variation` | `int` | int | | `get_float_value(...)` | `float_variation` | `float` | float | | `get_json_value(...)` | `json_variation` | `Any` (dict, list, ...) | json | | `get_date_value(...)` | `date_variation` | `datetime` (UTC) | date | | `get_*_details(...)` | `*_variation_detail` | `EvaluationDetail` | same as above | Also: `all_flags(ctx)` returns `{flag_key: EvaluationDetail}`. Reasons are `OFF`, `TARGET_MATCH`, `RULE_MATCH`, `FALLTHROUGH`, `NOT_READY` and `ERROR`; error codes are `FLAG_NOT_FOUND`, `TYPE_MISMATCH`, `PROVIDER_NOT_READY`, `MALFORMED_FLAG`, `INVALID_CONTEXT` and `GENERAL`. A percentage rollout adds `bucket` (0 to 9999) to the detail; there is no rollout reason. See the [reason table](https://darkship.tech/docs/sdk/javascript/#reasons). The evaluation is the same algorithm as in JavaScript and on the evaluation service, verified against shared conformance fixtures. A `user_id` bucketed in Python lands in the same bucket as in Node. ### Context A `dict` with a required `"key"` (the user id) and optional `"email"`, `"name"` and `"groups"` (a list of strings). Target lists match by `key`, `email` (normalised, case-insensitive) or `groups` (any element). Values may be `str`, `int`, `float`, `bool` or lists of strings. Missing attributes never match positive operators and nothing raises. Never put secrets in a context; in server mode it stays in your process. ```py ctx = { "key": "usr_marta", "email": "Marta@Acme.com", # matches ana@acme.com style lists, any casing "groups": ["beta-testers", "acme-staff"], "plan": "pro", } on = ds.bool_variation("ai-search-beta", ctx, False) ``` ### Events ```py ds.on("ready", lambda e: log.info("ruleset %s", e["version"])) ds.on("update", lambda e: log.info("changed: %s", e["changed"])) ds.on("error", lambda e: log.warning("%s: %s", e.code, e)) ``` Handlers run on the SDK's thread, so keep them short and thread-safe. ### Telemetry and privacy Identical to the other SDKs: per flag × variation × one-minute counters, flushed on an interval and at `close()`; no context and no user-level data. The field list is in the [JavaScript SDK page](https://darkship.tech/docs/sdk/javascript/#telemetry). The `sdk` field reads `python/`. ### Errors and defaults Evaluation methods do not raise. A missing flag, a type mismatch or a not-ready client all return the default. Programmer errors at init (`ValueError` for a missing or non-server key) are the only exceptions. `wait_for_ready` returns `False` on timeout. If the network drops after a ruleset was loaded, evaluation continues from it and an `error` event is emitted on each failed refresh, with exponential backoff and jitter. ### OpenFeature Install the extra and register the provider. It wraps the native client (pass your own with `DarkshipProvider(ds)`), so the two paths cannot diverge. ```py from openfeature import api from openfeature.evaluation_context import EvaluationContext from darkship.openfeature import DarkshipProvider api.set_provider_and_wait(DarkshipProvider(sdk_key=os.environ["DARKSHIP_SDK_KEY"])) client = api.get_client() client.get_boolean_value("new-checkout-flow", False, EvaluationContext("usr_marta", {"plan": "pro"})) ``` `targeting_key` is the darkship `key`; other attributes are targeting attributes. Reasons map to `STATIC` (off), `TARGETING_MATCH`, `SPLIT` (a percentage rollout decided), `DEFAULT` (fallthrough) and `ERROR`. Date flags resolve as an ISO-8601 string through `resolve_object_details`. Provider events: `READY`, `CONFIGURATION_CHANGED`, `ERROR`. ### Shutdown ```py import atexit atexit.register(lambda: ds.close(timeout=2.0)) # stops the thread, flushes counters (best effort) ``` ### Testing your code Unit tests shouldn't hit the network. Pass a `bootstrap` ruleset and disable the poller and telemetry: ```py import pytest, darkship @pytest.fixture def ds(): client = darkship.init( sdk_key="ds_srv_test_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", bootstrap=FIXTURE_RULESET, offline=True, telemetry=False, ) yield client client.close() ``` **offline=True** Offline mode never opens a socket: it serves the `bootstrap` ruleset and nothing else. Evaluation behaves exactly as in production, including bucketing. ### Versions The package is at `0.1.0`. Until 1.0, minor versions may include breaking changes, listed in the changelog. Python 3.10 or newer; the one runtime dependency is `httpx`. The evaluator is verified against the shared language-neutral conformance vectors. --- # OpenFeature provider URL: https://darkship.tech/docs/sdk/openfeature/ Description: Use darkship as an OpenFeature provider: registration and the reason, error code and event mapping. SDKs · OpenFeature ## OpenFeature provider Write your application against the vendor-neutral OpenFeature API and plug darkship in as the provider. It wraps the native JavaScript SDK and adds no behaviour of its own, so both paths give the same answers. ### When to use it - You already use OpenFeature, or want to keep the option to swap providers. - You want the standard hooks, evaluation-context and event APIs across several services. Use the [native SDK](https://darkship.tech/docs/sdk/javascript/) when you need the `get*Detail` variants with darkship-specific reasons and `bucket`, `date` flags as `Date`, or the `bootstrap` option. The provider is a thin mapping on top; it cannot return anything the native SDK couldn't. **Availability** The JavaScript package `@darkship/openfeature-provider` ships two providers, `DarkshipServerProvider` (import from `@darkship/openfeature-provider/server`, for Node) and `DarkshipWebProvider` (import from `@darkship/openfeature-provider/web`, for browsers). Python has its own provider in the [Python SDK](https://darkship.tech/docs/sdk/python/#openfeature) (`pip install "darkship[openfeature]"`). Other languages use the REST API. ### Install **Node.js:** ```bash $ npm install @openfeature/server-sdk @darkship/openfeature-provider ``` **Browser:** ```bash $ npm install @openfeature/web-sdk @darkship/openfeature-provider ``` ### Register the provider **Node.js:** ```ts import { OpenFeature } from '@openfeature/server-sdk'; import { DarkshipServerProvider } from '@darkship/openfeature-provider/server'; // alias: DarkshipProvider await OpenFeature.setProviderAndWait( new DarkshipServerProvider({ sdkKey: process.env.DARKSHIP_SDK_KEY! }), // ds_srv_... ); const client = OpenFeature.getClient(); const enabled = await client.getBooleanValue( 'new-checkout-flow', false, { targetingKey: 'usr_marta', plan: 'pro', country: 'BR' }, ); ``` **Browser:** ```ts import { OpenFeature } from '@openfeature/web-sdk'; import { DarkshipWebProvider } from '@darkship/openfeature-provider/web'; await OpenFeature.setContext({ targetingKey: user.id, plan: user.plan }); await OpenFeature.setProviderAndWait(new DarkshipWebProvider({ sdkKey: 'ds_cli_prod_Zk3...' })); const client = OpenFeature.getClient(); const enabled = client.getBooleanValue('new-checkout-flow', false); client.addHandler('PROVIDER_CONFIGURATION_CHANGED', () => rerender()); ``` The provider constructor accepts all native `init()` options (see [init options](https://darkship.tech/docs/sdk/javascript/#init-options)) plus `client` (reuse an existing SDK client), `hooks`, `initTimeoutMs` (server, default 3000) and `offReason` (`STATIC` by default; `DISABLED` if you want that reason for an environment switched off). The package depends on `@darkship/sdk`. With `setProviderAndWait`, OpenFeature waits for the provider's `READY` event, which maps to the SDK's `ready`. ### Mapping reference #### API | OpenFeature | darkship | | --- | --- | | Provider `metadata.name` | `darkship` | | `resolveBooleanEvaluation` | `getBooleanDetail` | | `resolveStringEvaluation` | `getStringDetail` | | `resolveNumberEvaluation` | `getIntDetail` or `getFloatDetail`, chosen by the flag's type | | `resolveObjectEvaluation` | `getJsonDetail`. Also used for `date` flags (see below). | | `EvaluationContext.targetingKey` | `context.key`, used for rollout hashing and `key` target lists | | Other context fields (`email`, `groups`, ...) | Context attributes: `email` and `groups` are matched by email and group target lists; the rest are available to targeting rules | | `variant` | The variation name (`variationKey`). `flagMetadata` carries `ruleId`, `bucket` and `flagType` | **Dates have no OpenFeature type** A `date` flag is resolved through `getObjectValue` and arrives as an object carrying an ISO-8601 string. Parse it yourself: `new Date(value.iso)`. Prefer the native SDK if you use date flags heavily. ```ts await client.getBooleanValue('ai-search-beta', false, { targetingKey: 'usr_marta', // becomes context.key email: 'Marta@Acme.com', // becomes the email attribute groups: ['beta-testers', 'acme-staff'], // becomes the groups attribute plan: 'pro', }); ``` #### Reasons | darkship reason | OpenFeature reason | | --- | --- | | `OFF` | `STATIC` | | `TARGET_MATCH`, `RULE_MATCH` | `TARGETING_MATCH`, or `SPLIT` when a percentage rollout decided (the detail has a `bucket`) | | `FALLTHROUGH` | `DEFAULT`, or `SPLIT` when a percentage rollout decided | | `DEFAULT` | `DEFAULT` | | `NOT_READY`, `ERROR` | `ERROR` (with an error code; your default is returned) | #### Error codes | darkship | OpenFeature `errorCode` | | --- | --- | | `FLAG_NOT_FOUND` | `FLAG_NOT_FOUND` | | `TYPE_MISMATCH` | `TYPE_MISMATCH` | | `INVALID_CONTEXT` | `INVALID_CONTEXT` | | `NOT_READY` (reason; the SDK's code is `PROVIDER_NOT_READY`) | `PROVIDER_NOT_READY` | | `MALFORMED_FLAG`, `GENERAL` | `GENERAL` | #### Provider events | SDK event | OpenFeature event | | --- | --- | | `ready`, `recovered` | `PROVIDER_READY` | | `update` | `PROVIDER_CONFIGURATION_CHANGED` (with `flagsChanged`) | | `stale` | `PROVIDER_STALE` | | `error` | `PROVIDER_ERROR` | ### Hooks and shutdown OpenFeature hooks (logging, telemetry, validation) work unchanged because they sit above the provider. On shutdown call `await OpenFeature.close()`; it calls the provider's `onClose`, which runs the SDK's `close()` and flushes counters. ```ts import { OpenFeature, type Hook } from '@openfeature/server-sdk'; const audit: Hook = { after: (ctx, details) => metrics.count('flag', { key: ctx.flagKey, variant: details.variant, reason: details.reason }), }; OpenFeature.addHooks(audit); ``` ### Parity with the native SDK The provider depends on `@darkship/sdk` and calls it; it has no evaluation code of its own. That is deliberate: native and OpenFeature paths cannot drift apart, and the package's parity test runs every shared conformance vector through the provider and compares it with the native SDK. There is no official OpenFeature conformance suite for JavaScript providers. If you see a different result between them for the same flag and context, please report it as a bug. --- # Plan gating with Darkship URL: https://darkship.tech/docs/recipes/plan-gating/ Description: Gate features by customer plan with typed flags, rules on a plan attribute and per-customer override lists. Billing stays the source of truth; enforcement stays on your server. Recipes · Plan gating ## Plan gating with Darkship Show or hide a feature by the customer's plan, give one customer an exception, and change either without a deploy. You need typed flags, one rule on a `plan` attribute and one override list. **Billing is the source of truth, enforcement is server-side** Darkship decides what a plan unlocks in your product; it does not know what the customer paid for. Your billing system (for example Stripe) stays the source of truth for the plan, and you pass that plan into the evaluation context. Always enforce on the server: a flag evaluated in the browser only changes what the interface shows, and a visitor can change anything their own browser sends. ### The model Three pieces of the [targeting model](https://darkship.tech/docs/concepts/targeting/) do all the work: - **Typed flags** carry the entitlement: a `boolean` for "can use CSV export", an `int` for "seats included", a `json` for a small bundle of limits. - **A rule on a `plan` context attribute** serves the plan's value: `plan in [pro, business]` serves `on`. Rules are read top to bottom and the first match wins. - **An override list** is an attribute-keyed target list (`matchBy: orgId`). It is checked before rules, so a customer on the list gets the listed variation whatever their plan is. The evaluation order is: environment switch, target lists, rules top to bottom, then the default (fallthrough). ### 1. Create typed flags | Flag key | Type | Variations | Default (fallthrough) | | --- | --- | --- | --- | | `export-csv` | boolean | `on` = true, `off` = false | `off` | | `max-seats` | int | `small` = 3, `team` = 10, `large` = 50 | `small` | | `audit-retention` | json | `short` = `{ "days": 7 }`, `long` = `{ "days": 90 }` | `short` | Make the safest value the default. A new or unknown plan then gets the most restrictive behaviour, and so does any evaluation that fails (your code passes the same safe default). ### 2. Add rules on the `plan` attribute On `export-csv`, in each environment, add one rule. A rule is an OR of groups; a group is an AND of conditions. One group with one condition is enough here: ```json { "id": "r_plan", "groups": [[{ "attribute": "plan", "op": "in", "values": ["pro", "business"] }]], "serve": { "variation": "on" } } ``` For `max-seats` use one rule per plan, most specific first: - `plan equals "business"` serves `large` - `plan equals "pro"` serves `team` - the fallthrough serves `small` (free and anything unknown) **Compare exactly** Attribute values other than `email` are compared exactly: `"Pro"` does not equal `"pro"`. Pass plan keys in one canonical form, the same one you use in your billing code. ### 3. Add a per-customer override list Sales promised a design partner CSV export on the Free plan. Do not change the rule; add a target list to the flag, in the environment where it applies: ```json { "id": "tl_partners", "matchBy": "orgId", "values": ["org_acme", "org_globex"], "variation": "on" } ``` `matchBy` can be `key`, `email`, `groups` or any attribute name, so you can key the list on your customer's organisation id rather than on individual users. Removing the customer from the list ends the exception. - **Prefer opaque ids to emails.** Lists hold identifiers of your end users in plaintext and are served to server keys only. An organisation id such as `org_acme` is enough and reveals nothing if it leaks. - **Lists have limits per plan.** The entry limits are placeholders until the plans are final; saving over the limit is refused with the count and the limit, nothing is truncated. - **One value, one variation.** The same value cannot map to two variations within one `matchBy`. Across lists, the order decides. ### 4. Evaluate with the plan in the context Pass the plan from your own billing data, together with the organisation id the override list is keyed on. Use the organisation id as `key` when entitlements are per organisation, so every member of an account gets the same answer. **Node.js:** ```ts import { ds } from './darkship'; export function entitlements(org: { id: string; plan: string }) { const ctx = { key: org.id, orgId: org.id, plan: org.plan }; // plan comes from billing, never from the request return { exportCsv: ds.getBooleanValue('export-csv', ctx, false), // safe default: off maxSeats: ds.getIntValue('max-seats', ctx, 3), auditDays: ds.getJsonValue<{ days: number }>('audit-retention', ctx, { days: 7 }).days, }; } // enforce on the server, in the handler that does the work app.get('/api/export.csv', (req, res) => { if (!entitlements(req.org).exportCsv) return res.status(402).json({ error: 'plan_required' }); // ...stream the CSV }); ``` **Python:** ```py from darkship_client import ds def entitlements(org): ctx = {"key": org.id, "orgId": org.id, "plan": org.plan} # plan comes from billing, never from the request return { "export_csv": ds.get_boolean_value("export-csv", ctx, False), # safe default: off "max_seats": ds.get_int_value("max-seats", ctx, 3), "audit_days": ds.get_json_value("audit-retention", ctx, {"days": 7})["days"], } @app.get("/api/export.csv") def export_csv(org=Depends(current_org)): if not entitlements(org)["export_csv"]: raise HTTPException(status_code=402, detail="plan_required") ... ``` #### What each customer gets | Organisation | Plan | Decided by | Value | Reason | | --- | --- | --- | --- | --- | | `org_acme` | free | target list `tl_partners` | `true` | `TARGET_MATCH` | | `org_initech` | pro | rule `r_plan` | `true` | `RULE_MATCH` | | `org_umbrella` | free | fallthrough | `false` | `FALLTHROUGH` | | `org_hooli` | (not passed) | fallthrough: a missing attribute never matches a positive operator | `false` | `FALLTHROUGH` | ### Changing a plan When a customer upgrades, your billing webhook updates your own record and the next evaluation uses the new plan; there is nothing to sync into darkship. When the rule itself changes (Pro now includes something), edit the flag in the console: the change reaches servers on their next poll, or within seconds if the SDK streams. Every change is in the audit log. ### Gating in the browser A client SDK evaluates remotely with the context you give it, so a user can send a different `plan`. Use client-side flags only to decide what to show. For anything that must be enforced, compute the entitlement on the server (as above) and send the result to the page, or call `allFlags(ctx)` on the server and pass it as the client `bootstrap`. **Outside the first release** Entitlements as a first-class object (a plan as a bundle of flag values, sync from your Stripe webhook, a "what does this customer have" view and plan-change history) are not part of the MVP. This recipe is the manual version of that idea and keeps working when it ships. ### Checklist - Every entitlement flag has the most restrictive value as its default, and your code passes the same default. - The plan in the context comes from your billing data, in one canonical spelling. - Overrides are keyed on an opaque organisation id, not on emails. - Enforcement happens in server code; the browser only reflects it. - Test the table above in a unit test with a [bootstrap snapshot](https://darkship.tech/docs/sdk/javascript/#init-options) so a rule edit that breaks a plan is caught before it ships. --- # REST API URL: https://darkship.tech/docs/api-reference/ Description: Interactive REST API reference for flags, environments, segments, SDK keys, tokens and webhooks, with parameters, examples and curl for every call. Reference · REST API v1 ## REST API Everything the console does is available over HTTPS and JSON: flags, targeting, environments, segments, keys, tokens and webhooks. Every endpoint below shows its parameters, request body, responses and a copyable curl command. **Generated from the OpenAPI document** The endpoint index below is rendered at build time from the OpenAPI 3.1 document published by the API, so it cannot drift from the implementation. To try calls, browse schemas and copy code samples, open the [interactive API explorer](https://darkship.tech/docs/api/). The machine-readable spec is served at `/openapi.json` (on this site and on the API host). Spec: `Darkship API` v1.0.0. ### Overview **Base URL**: `https://api.darkship.tech/v1` for management; `https://sdk.darkship.tech/v1` for the SDK data plane. **Format**: JSON only. Fields are `snake_case`, timestamps are ISO-8601 in UTC, ids are opaque strings. **Versioning**: Breaking changes only ship under a new major path. Additive changes (new fields, new endpoints) happen within `/v1`, so ignore fields you don't know. Deprecations are announced with `Deprecation` and `Sunset` headers and in the changelog. **Spec**: `/openapi.json`: OpenAPI 3.1, linted in CI with Spectral. It covers both hosts (the SDK data plane is the SDK data plane tag), names every request and response schema, and lists the permission each operation needs in `x-required-scope`. Generate a client in any language from it. ### Authentication Send a key in the `Authorization` header. Which key depends on the host and endpoint: | Credential | Prefix | Calls | Notes | | --- | --- | --- | --- | | API token | `ds_pat_` | Management API on `api` | Scoped permissions; personal or service. Created in the console or [here](https://darkship.tech/docs/api-reference/#tokens). | | Server SDK key | `ds_srv_` | `GET /v1/sdk/snapshot`, `GET /v1/sdk/stream`, `POST /v1/telemetry/counters` | Secret. One environment. | | Client SDK key | `ds_cli_` | `POST /v1/sdk/evaluate` only | Public by design. Other routes return 403. | ```bash $ curl https://api.darkship.tech/v1/projects/web-app/flags \ -H "Authorization: Bearer $DARKSHIP_API_TOKEN" ``` **Secrets** Keys are shown once and stored only as a hash. Put `ds_pat_` and `ds_srv_` values in a secret store, never in source control; the prefixes are registered with secret scanners. A token can never hold more permissions than the person who created it, and stops working if they leave the organisation. #### Scopes Every operation lists the permission it needs in `x-required-scope`. An API token carries a subset of these as its `scopes`; a console member gets them from their role (`GET /v1/roles` lists the catalogue). Operations marked session only cannot be called with an API token. The table is generated from the spec: | Permission | API tokens | Operations | | --- | --- | --- | | `api_token:manage` | no (session only) | `GET /v1/api-tokens`, `POST /v1/api-tokens`, `DELETE /v1/api-tokens/{tokenId}` | | `audit:read` | yes | `GET /v1/audit-log` | | `billing:manage` | no (session only) | `GET /v1/orgs/{orgId}/billing`, `POST /v1/orgs/{orgId}/billing/checkout`, `POST /v1/orgs/{orgId}/billing/portal` | | `environment:manage` | yes | `POST /v1/projects/{projectId}/environments`, `PUT /v1/projects/{projectId}/environments/order`, `PATCH /v1/projects/{projectId}/environments/{envId}`, `DELETE /v1/projects/{projectId}/environments/{envId}` | | `environment:read` | yes | `GET /v1/projects/{projectId}/environments`, `GET /v1/projects/{projectId}/environments/{envId}` | | `flag:delete` | yes | `DELETE /v1/projects/{projectId}/flags/{flagKey}`, `POST /v1/projects/{projectId}/flags/{flagKey}/archive`, `POST /v1/projects/{projectId}/flags/{flagKey}/restore` | | `flag:read` | yes | `GET /v1/projects/{projectId}/flags`, `GET /v1/projects/{projectId}/flags/{flagKey}`, `GET /v1/projects/{projectId}/flags/{flagKey}/history`, `GET /v1/projects/{projectId}/flags/{flagKey}/history/{historyId}`, `GET /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`, `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/evaluate-preview`, `GET /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/history`, `GET /v1/projects/{projectId}/segments`, `GET /v1/projects/{projectId}/segments/{segmentKey}`, `GET /v1/projects/{projectId}/segments/{segmentKey}/usage` | | `flag:write` | yes | `POST /v1/projects/{projectId}/flags`, `PATCH /v1/projects/{projectId}/flags/{flagKey}`, `POST /v1/projects/{projectId}/flags/{flagKey}/history/{historyId}/restore`, `PUT /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`, `PATCH /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`, `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/toggle`, `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/targets/import/preview`, `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/targets/import`, `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/history/{historyId}/restore` | | `member:manage` | no (session only) | `GET /v1/invitations`, `POST /v1/invitations`, `DELETE /v1/invitations/{invitationId}`, `PATCH /v1/members/{memberId}`, `DELETE /v1/members/{memberId}` | | `member:read` | no (session only) | `GET /v1/members` | | `org:manage` | no (session only) | `PATCH /v1/organizations/{orgId}`, `DELETE /v1/organizations/{orgId}`, `POST /v1/organizations/{orgId}/deletion`, `POST /v1/organizations/{orgId}/restore` | | `org:read` | yes | `GET /v1/organizations/{orgId}` | | `org:transfer` | no (session only) | `POST /v1/organizations/{orgId}/transfer-ownership` | | `project:manage` | yes | `POST /v1/projects`, `PATCH /v1/projects/{projectId}`, `DELETE /v1/projects/{projectId}` | | `project:read` | yes | `GET /v1/projects`, `GET /v1/projects/{projectId}` | | `sdk_key:manage` | yes | `POST /v1/projects/{projectId}/environments/{envId}/sdk-keys`, `POST /v1/sdk-keys/{keyId}/rotate`, `DELETE /v1/sdk-keys/{keyId}` | | `sdk_key:read` | yes | `GET /v1/sdk-keys`, `GET /v1/projects/{projectId}/environments/{envId}/sdk-keys` | | `segment:manage` | yes | `POST /v1/projects/{projectId}/segments`, `PATCH /v1/projects/{projectId}/segments/{segmentKey}`, `DELETE /v1/projects/{projectId}/segments/{segmentKey}`, `POST /v1/projects/{projectId}/segments/{segmentKey}/import/preview`, `POST /v1/projects/{projectId}/segments/{segmentKey}/import` | | `usage:read` | yes | `GET /v1/usage`, `GET /v1/projects/{projectId}/usage`, `GET /v1/projects/{projectId}/flags/{flagKey}/usage` | | `webhook:manage` | yes | `GET /v1/webhooks/event-types`, `GET /v1/projects/{projectId}/webhooks`, `POST /v1/projects/{projectId}/webhooks`, `GET /v1/webhooks/{id}`, `PATCH /v1/webhooks/{id}`, `DELETE /v1/webhooks/{id}`, `POST /v1/webhooks/{id}/rotate-secret`, `POST /v1/webhooks/{id}/test`, `GET /v1/webhooks/{id}/deliveries`, `GET /v1/webhooks/{id}/deliveries/{deliveryId}`, `POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver` | ### Conventions #### Pagination List endpoints take `limit` (1 to 200, default 50) and an opaque `cursor`, and return `data` plus `next_cursor` (`null` on the last page). #### Idempotency Mutations accept an `Idempotency-Key` header. The key is stored for 24 hours with a hash of the request body. Replaying the same request returns the original response; reusing the key with a different body returns `409`. #### Optimistic concurrency Flags and rulesets return an `ETag`. Send it back in `If-Match` when updating; if someone else changed the resource, you get `412 precondition_failed` instead of silently overwriting their edit. ### Errors Errors share one envelope. `code` is stable and machine-readable; `message` is for humans and may change. The `request_id` is also returned in the `X-Request-Id` header; include it when you contact support. ```json { "error": { "code": "flag_not_found", "message": "Flag 'checkout-v2' does not exist in project 'web-app'.", "request_id": "req_01J9ZK3Q8M4V", "details": [{ "field": "flag", "issue": "not_found" }], "docs_url": "https://docs.darkship.tech/errors/flag_not_found" } } ``` | Status | Meaning | | --- | --- | | 400 | Malformed request, e.g. invalid JSON. | | 401 | Missing or invalid credentials. | | 403 | Valid credentials, not allowed: insufficient scope, or wrong key type for the endpoint. | | 404 | Resource does not exist (or you cannot see it). | | 409 | Conflict: duplicate key, or Idempotency-Key reused with a different body. | | 412 | `If-Match` did not match the current ETag. | | 422 | Validation failed. See `details` for each field. | | 429 | Rate limited. Honour `Retry-After`. | | 5xx | Our fault. Retry idempotent requests with exponential backoff and jitter. | **Plans never break evaluation** Over-quota or past-due states restrict creating new things through this API (you'd see `402 plan_limit_reached` on create calls). Evaluation endpoints keep serving in every state. ### Rate limits Limits are per token (or per SDK key on the data plane) and per plan, and use separate buckets for management, ruleset fetch, evaluation and telemetry. Every response includes: ```http RateLimit-Limit: 600 RateLimit-Remaining: 598 RateLimit-Reset: 41 HTTP/1.1 429 Too Many Requests Retry-After: 17 ``` `RateLimit-Reset` is the number of seconds until the window resets. The numbers shown are placeholders until load data sets the plan limits. Data-plane limits are generous because a hit is a cache read. ### Endpoints A searchable index of every operation, rendered at build time (it also works without JavaScript). For the full interactive reference with schemas, a request console and code samples, use the [API explorer](https://darkship.tech/docs/api/). ### Account 8 endpoints The signed-in user (console session). - `GET /v1/me`: The signed-in user and their organizations - `GET /v1/me/token`: The API token making this call and its organization - `GET /v1/me/deletion`: Your account deletion request, if any - `POST /v1/me/deletion`: Schedule the deletion of your account (30-day regret window). Needs your typed email, your current password and, with MFA, a recent MFA sign-in. Refused while you are the only Owner of an organization. - `DELETE /v1/me/deletion`: Cancel the scheduled deletion of your account - `GET /v1/me/exports`: Your recent personal data exports - `POST /v1/me/exports`: Request a personal data export (JSON). A single-use download link is emailed when it is ready. - `POST /v1/me/exports/{exportId}/download`: Download a personal data export with the single-use token from the email (signed-in owner only) ### Organizations 8 endpoints Organizations you belong to: create, rename, delete and transfer ownership. - `GET /v1/organizations`: List the organizations you belong to - `POST /v1/organizations`: Create an organization (you become its Owner) - `GET /v1/organizations/{orgId}`: Get an organization - `PATCH /v1/organizations/{orgId}`: Rename an organization or change its slug - `DELETE /v1/organizations/{orgId}`: Schedule the deletion of an organization (Owner only). Needs the typed organization name, your current password and, with MFA enabled, a recent MFA sign-in. The organization becomes read-only for 30 days (SDK keys keep serving), then it is erased. - `POST /v1/organizations/{orgId}/transfer-ownership`: Transfer ownership to an existing member (Owner only; you become Admin). Needs a recent MFA sign-in when MFA is enabled. - `POST /v1/organizations/{orgId}/deletion`: Schedule the deletion of an organization (Owner only). Needs the typed organization name, your current password and, with MFA enabled, a recent MFA sign-in. The organization becomes read-only for 30 days (SDK keys keep serving), then it is erased. - `POST /v1/organizations/{orgId}/restore`: Restore an organization scheduled for deletion (Owner only, inside the 30-day regret window). ### Projects 5 endpoints Projects group flags, segments and environments. Creating one adds dev, staging and prod. - `GET /v1/projects`: List projects - `POST /v1/projects`: Create a project with the default dev/staging/prod environments - `GET /v1/projects/{projectId}`: Get a project - `PATCH /v1/projects/{projectId}`: Update a project - `DELETE /v1/projects/{projectId}`: Delete a project and everything in it ### Environments 6 endpoints Environments of a project (dev, staging, prod, ...). Each has its own flag configuration and SDK keys. - `GET /v1/projects/{projectId}/environments`: List a project's environments in order - `POST /v1/projects/{projectId}/environments`: Create an environment - `PUT /v1/projects/{projectId}/environments/order`: Set the display order of the environments - `GET /v1/projects/{projectId}/environments/{envId}`: Get an environment - `PATCH /v1/projects/{projectId}/environments/{envId}`: Update an environment's name or colour - `DELETE /v1/projects/{projectId}/environments/{envId}`: Delete an environment ### Flags 10 endpoints Typed feature flags. The type and key are fixed at creation; variations, name and tags can change. - `GET /v1/projects/{projectId}/flags`: List flags of a project - `POST /v1/projects/{projectId}/flags`: Create a flag (type is fixed at creation). Supports Idempotency-Key. - `GET /v1/projects/{projectId}/flags/{flagKey}`: Get a flag. Returns an ETag. - `PATCH /v1/projects/{projectId}/flags/{flagKey}`: Update flag metadata and variations. If-Match is honoured (412). Variation or client-side changes by a Developer need production confirmation. - `DELETE /v1/projects/{projectId}/flags/{flagKey}`: Delete a flag. Flags are soft-deleted (same as archive) so history and audit stay intact. Optional `reason` query. - `POST /v1/projects/{projectId}/flags/{flagKey}/archive`: Archive a flag: it leaves every snapshot and can be restored - `POST /v1/projects/{projectId}/flags/{flagKey}/restore`: Restore an archived flag - `GET /v1/projects/{projectId}/flags/{flagKey}/history`: Revision history of a flag (flag-level and per-environment), newest first - `GET /v1/projects/{projectId}/flags/{flagKey}/history/{historyId}`: One revision with its stored state - `POST /v1/projects/{projectId}/flags/{flagKey}/history/{historyId}/restore`: Restore the descriptive fields of a flag-level revision as a new revision ### Flag environments 9 endpoints Per-environment state of a flag: on/off, individual targets, rules, fallthrough and rollout. - `GET /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`: State of a flag in one environment: toggle, targets, rules, rollout. Returns an ETag. - `PUT /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`: Replace the environment configuration. If-Match is honoured (412); production edits by a Developer need confirmation (428). - `PATCH /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}`: Partially update the environment configuration (only the given fields change) - `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/toggle`: Turn the flag on or off in this environment - `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/evaluate-preview`: Evaluate a context against the saved configuration or a draft, with the shared evaluator. Nothing is stored. - `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/targets/import/preview`: Preview a paste/CSV import into a target list: added, duplicates, invalid, over limit - `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/targets/import`: Apply a paste/CSV import. Refused (422) when it exceeds the plan limit; entries are never truncated. Supports Idempotency-Key. - `GET /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/history`: Configuration revisions of this flag in this environment - `POST /v1/projects/{projectId}/flags/{flagKey}/environments/{envId}/history/{historyId}/restore`: Restore a stored configuration revision as a new revision ### Segments 8 endpoints Reusable audiences, project-wide. Every environment snapshot embeds them. - `GET /v1/projects/{projectId}/segments`: List segments of a project - `POST /v1/projects/{projectId}/segments`: Create a segment (project-wide; every environment snapshot embeds it). Supports Idempotency-Key. - `GET /v1/projects/{projectId}/segments/{segmentKey}`: Get a segment. Returns an ETag. - `PATCH /v1/projects/{projectId}/segments/{segmentKey}`: Update a segment. If-Match is honoured (412); a Developer needs production confirmation (428). - `DELETE /v1/projects/{projectId}/segments/{segmentKey}`: Delete a segment that no flag rule references. Optional `reason` query. - `GET /v1/projects/{projectId}/segments/{segmentKey}/usage`: Flags (per environment) whose rules reference the segment - `POST /v1/projects/{projectId}/segments/{segmentKey}/import/preview`: Preview a paste/CSV import into a segment list - `POST /v1/projects/{projectId}/segments/{segmentKey}/import`: Apply a paste/CSV import. Refused (422) beyond the plan limit; never truncates. Supports Idempotency-Key. ### SDK keys 5 endpoints Server (`ds_srv_`) and client (`ds_cli_`) keys for the data plane. Secrets are shown once. - `GET /v1/sdk-keys`: List SDK keys of the whole organization (metadata only) with project/environment keys and 7-day request counts - `GET /v1/projects/{projectId}/environments/{envId}/sdk-keys`: List SDK keys of an environment (metadata only) - `POST /v1/projects/{projectId}/environments/{envId}/sdk-keys`: Create an SDK key. The secret is returned once. - `POST /v1/sdk-keys/{keyId}/rotate`: Rotate an SDK key; the old one stays valid for the grace period - `DELETE /v1/sdk-keys/{keyId}`: Revoke an SDK key immediately ### API tokens 3 endpoints Scoped `ds_pat_` tokens for this API. Secrets are shown once. - `GET /v1/api-tokens`: List API tokens (own, or all for Owner/Admin) - `POST /v1/api-tokens`: Create a scoped API token. The secret is returned once. - `DELETE /v1/api-tokens/{tokenId}`: Revoke an API token ### Members 4 endpoints Organization members, their roles and scopes, and the role catalogue. - `GET /v1/members`: List organization members - `PATCH /v1/members/{memberId}`: Change a member's role or scope - `DELETE /v1/members/{memberId}`: Remove a member - `GET /v1/roles`: Role catalogue: label, description, scopability and the permissions each role holds ### Invitations 4 endpoints Invite people by email; they join with the role and scope you choose. - `GET /v1/invitations`: List invitations - `POST /v1/invitations`: Invite someone by email - `POST /v1/invitations/accept`: Accept an invitation - `DELETE /v1/invitations/{invitationId}`: Revoke a pending invitation ### Webhooks 11 endpoints Signed HTTP callbacks for flag, segment and environment changes, with a delivery log. - `GET /v1/webhooks/event-types`: Event types you can subscribe to, the payload version and the signature tolerance - `GET /v1/projects/{projectId}/webhooks`: List webhook endpoints of a project - `POST /v1/projects/{projectId}/webhooks`: Create a webhook endpoint. The signing secret is returned once. - `GET /v1/webhooks/{id}`: Get a webhook endpoint with its health - `PATCH /v1/webhooks/{id}`: Update URL, subscriptions or status (re-enabling resets the failure streak) - `DELETE /v1/webhooks/{id}`: Delete a webhook endpoint and its delivery log - `POST /v1/webhooks/{id}/rotate-secret`: Rotate the signing secret; the old one signs too during the overlap window - `POST /v1/webhooks/{id}/test`: Send a signed webhook.test event now and report the result (at most 3 in flight per organization) - `GET /v1/webhooks/{id}/deliveries`: Delivery log of an endpoint (status, attempts, last response status and latency) - `GET /v1/webhooks/{id}/deliveries/{deliveryId}`: One delivery with every attempt and the event payload - `POST /v1/webhooks/{id}/deliveries/{deliveryId}/redeliver`: Queue a new delivery of the same event (same event id) ### Audit log 1 endpoint Who changed what and when, newest first. - `GET /v1/audit-log`: Query the audit log (newest first, cursor pagination) ### Usage 3 endpoints Requests and evaluations over time, per organization, project and flag. - `GET /v1/usage`: Organization request usage for a calendar month (fetches + remote evaluations) - `GET /v1/projects/{projectId}/usage`: Requests and flag evaluations over time, plus the top flags - `GET /v1/projects/{projectId}/flags/{flagKey}/usage`: Evaluations of one flag by variation over time ### Billing 5 endpoints Plan, subscription, Stripe Checkout and Customer Portal, and the public plan catalogue. - `GET /v1/orgs/{orgId}/billing`: Plan, subscription status, usage against limits and invoices - `POST /v1/orgs/{orgId}/billing/checkout`: Start a Stripe Checkout Session (subscription) - `POST /v1/orgs/{orgId}/billing/portal`: Open the Stripe Customer Portal (payment method, invoices, plan change, cancellation) - `GET /v1/plans`: Public plan catalogue (prices and limits are placeholders until `pricing_is_placeholder` is false) - `POST /v1/webhooks/stripe`: Stripe webhook receiver (signature verified, idempotent by event id) ### Platform 1 endpoint Public platform status: maintenance, announcements, read-only mode. - `GET /v1/platform/status`: Public platform status: maintenance banner, announcements, read-only mode and sign-up availability ### SDK data plane 6 endpoints Served by the evaluation service (`eval` host), not the management API. Server keys download the ruleset snapshot (or subscribe to changes) and evaluate in-process; client keys ask for evaluated values only. Use these routes to write an SDK for a language we do not ship. - `GET /v1/sdk/snapshot`: Download the ruleset snapshot of the key's environment (server keys) - `GET /v1/sdk/stream`: Subscribe to ruleset changes over server-sent events (server keys) - `POST /v1/sdk/evaluate`: Evaluate the client-visible flags for a context (client keys) - `POST /v1/sdk/evaluate/{flagKey}`: Evaluate one client-visible flag for a context (client keys) - `POST /v1/telemetry/counters`: Report aggregate evaluation counters (server keys) - `POST /v1/sdk/telemetry`: Report aggregate evaluation counters (deprecated alias) --- # API explorer URL: https://darkship.tech/docs/api/ Description: Interactive REST API reference: every management and SDK data-plane operation, with schemas, examples, required scopes and a request console. Reference · OpenAPI 3.1 ## API explorer Generated from the same OpenAPI document the API publishes. Conventions, authentication and errors are explained in the [REST API guide](https://darkship.tech/docs/api-reference/); download the spec as [openapi.json](https://darkship.tech/openapi.json). --- # Webhooks URL: https://darkship.tech/docs/webhooks/ Description: Event catalogue, payload schema, retry policy and HMAC-SHA256 signature verification for darkship webhooks. Reference · Webhooks ## Signed webhooks Get an HTTPS POST when a flag, its targeting or a key changes. Every delivery is signed, retried until you acknowledge it, and safe to replay, as long as you verify the signature and deduplicate on the event id. ### How delivery works - You register an HTTPS endpoint in the console (**Webhooks**) or with [`POST /v1/webhooks`](https://darkship.tech/docs/api-reference/#whk-create), choose events, and receive a signing secret (`whsec_...`) **once**. - When an event happens, our delivery worker POSTs a JSON body to your URL with the signature headers below. Success is **any 2xx** within **10 seconds**. - Anything else (a non-2xx, a timeout, a connection error) is retried with exponential backoff and jitter. Delivery is **at-least-once**: expect duplicates. - Endpoints must be HTTPS and resolve to public addresses. Redirects to private ranges are refused. ### Event catalogue | Event | Fires when | Notable `data` fields | | --- | --- | --- | | `flag.created` | A flag is created. | `flag` | | `flag.updated` | Name, description, tags or variations change. | `flag_key`, `changes` | | `flag.deleted` | A flag is archived or deleted. | `flag_key`, `archived` | | `flag.environment.updated` | Targeting, default variation or on/off changes in an environment. | `flag_key`, `environment`, `change`, `ruleset_version` | | `sdk_key.rotated` | A key rotation starts. | `key_id`, `new_key_id`, `rotation_ends_at` | | `sdk_key.revoked` | A key is revoked. | `key_id`, `reason` | Payloads contain ids, the actor, a before/after summary and the ruleset version. They never contain secrets, key values or evaluation context. #### Payload explorer sample data Event typeflag.createdflag.updatedflag.deletedflag.environment.updatedsdk_key.rotatedsdk_key.revoked ```json {} ``` ### Payload schema | Field | Type | Description | | --- | --- | --- | | `id` | string | Event id, `evt_...`. **Stable across retries**; use it to deduplicate. | | `type` | string | One of the events above. | | `created_at` | string | ISO-8601 UTC time the change was committed. | | `org_id`, `project`, `environment` | string | Scope. `environment` is absent for project-level events. | | `actor` | object | `{ type: "user" | "token" | "system", id, name }` | | `data` | object | Event-specific, with a before/after summary where applicable. | ### Request headers ```http POST /darkship HTTP/1.1 Host: hooks.example.com Content-Type: application/json User-Agent: darkship-webhooks/1 Darkship-Signature: t=1761661928,v1=3b5a7e0f9d4c...e21a Darkship-Event-Id: evt_01J9ZK3Q8M4V Darkship-Delivery-Id: dlv_01J9ZM2R5T7W ``` **Darkship-Signature**: Timestamp `t` (Unix seconds) and one or more `v1` HMAC signatures. See below. **Darkship-Event-Id**: Same id as `id` in the body. Identical across retries. **Darkship-Delivery-Id**: Unique per attempt. Useful for support and the delivery log. ### Retries and failure handling Respond `2xx` quickly, before doing slow work: queue the event and process it later. If you can't, we retry. The schedule below is illustrative; exact timings are placeholders that will be tuned. **+0 s**Attempt 1, immediately after the changeattempt 1 **+10 s**Attempt 2attempt 2 **+1 min**Attempt 3attempt 3 **+5 min**Attempt 4attempt 4 **+30 min**Attempt 5attempt 5 **+2 h … +12 h**Attempts 6 to 8, growing to hours, then give upfinal - Each interval has random jitter so a recovering endpoint isn't hit by every queued event at once. - An endpoint that fails continuously is **automatically disabled** and your organisation is emailed. Re-enable it in the console after fixing the cause. - Every attempt (status, latency, truncated response) is in the delivery log, [also via the API](https://darkship.tech/docs/api-reference/#whk-deliveries), until your plan's retention window expires. **At-least-once means duplicates** A retry can arrive after your first attempt actually succeeded (for example, you replied slowly). Deduplicate on `Darkship-Event-Id`, and make handlers idempotent. Order is not guaranteed across events; use `created_at` and the `ruleset_version` to reason about which is newer. ### Verify the signature Anyone can POST to your URL. Verify every delivery before trusting it. - **Parse the header**Read `t` and every `v1` value from `Darkship-Signature`. During secret rotation there are two `v1` values. - **Check the timestamp**Reject if `|now − t|` is more than **5 minutes**. This is the replay window; keep your server clock in sync (NTP). - **Recompute**`HMAC_SHA256(secret, t + "." + raw_body)`, hex-encoded. Use the **raw bytes** of the body exactly as received. - **Compare in constant time**Accept if any `v1` matches. Never use `==`. **The raw body, not the parsed one** Re-serialising parsed JSON changes whitespace and key order, and the signature will not match. In Express use `express.raw({ type: 'application/json' })` for this route; in Flask use `request.get_data()`. **Node.js:** ```ts import { createHmac, timingSafeEqual } from 'node:crypto'; const TOLERANCE_SECONDS = 5 * 60; export function verifyDarkship(rawBody: Buffer | string, header: string, secret: string, now = Date.now() / 1000): boolean { const parts = header.split(',').map((p) => p.trim().split('=')); const t = Number(parts.find(([k]) => k === 't')?.[1]); const sigs = parts.filter(([k]) => k === 'v1').map(([, v]) => v); if (!Number.isFinite(t) || sigs.length === 0) return false; if (Math.abs(now - t) > TOLERANCE_SECONDS) return false; // replay window const expected = createHmac('sha256', secret).update(`${t}.`).update(rawBody).digest(); return sigs.some((hex) => { const got = Buffer.from(hex, 'hex'); return got.length === expected.length && timingSafeEqual(got, expected); }); } // Express app.post('/darkship', express.raw({ type: 'application/json' }), async (req, res) => { if (!verifyDarkship(req.body, req.get('Darkship-Signature') ?? '', process.env.DARKSHIP_WEBHOOK_SECRET!)) { return res.sendStatus(400); } const eventId = req.get('Darkship-Event-Id')!; if (await seen(eventId)) return res.sendStatus(200); // duplicate: ack and skip enqueue(JSON.parse(req.body.toString())); // do real work off the request path res.sendStatus(202); }); ``` **Python:** ```py import hashlib import hmac import time TOLERANCE_SECONDS = 5 * 60 def verify_darkship(raw_body: bytes, header: str, secret: str, now: float | None = None) -> bool: parts = [p.strip().split("=", 1) for p in header.split(",") if "=" in p] t = next((v for k, v in parts if k == "t"), None) sigs = [v for k, v in parts if k == "v1"] if t is None or not t.isdigit() or not sigs: return False if abs((now if now is not None else time.time()) - int(t)) > TOLERANCE_SECONDS: return False # replay window signed = t.encode() + b"." + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return any(hmac.compare_digest(expected, s) for s in sigs) # Flask @app.post("/darkship") def darkship_webhook(): raw = request.get_data() # raw bytes, before any parsing if not verify_darkship(raw, request.headers.get("Darkship-Signature", ""), WEBHOOK_SECRET): return "", 400 if already_processed(request.headers["Darkship-Event-Id"]): return "", 200 # duplicate: ack and skip queue.enqueue(process_event, raw) return "", 202 ``` The SDKs expose the same check as a helper so you don't have to maintain it yourself. #### Try it: signature checker #### Webhook signature checker pure JS, runs locally Signing secret Raw body{"id":"evt_01J9ZK3Q8M4V","type":"flag.environment.updated","data":{"flag_key":"new-checkout-flow"}} Darkship-Signature header ### Rotating the secret Rotate the endpoint's secret in the console. For an overlap window both secrets sign every delivery and the header carries two values, `t=...,v1=,v1=`. Deploy the new secret to your receiver, and when the window ends only the new signature is sent. Because the verifier accepts any matching `v1`, nothing breaks during the switch. ### Testing tips - **Send a test event.** [`POST /v1/webhooks/{id}/test`](https://darkship.tech/docs/api-reference/#whk-test) or the console button delivers a signed `flag.updated` through the real pipeline. - **Expose localhost** with a tunnel while developing. The URL must be public HTTPS; we refuse private addresses and plain HTTP. - **Test your verifier** with the checker above, including the stale and tampered cases, and add them to your unit tests. - **Replay from the log.** Open a delivery in the console to see exactly what we sent and what you returned. | Symptom | Likely cause | | --- | --- | | Signature never matches | Verifying the parsed-and-re-serialised body; wrong secret (test vs production endpoint); secret has trailing whitespace. | | Fails only some of the time | Two secrets during rotation and you compared only the first `v1`. | | Valid signatures rejected as old | Server clock drift beyond five minutes; fix NTP rather than widening the window. | | Endpoint was disabled | Sustained failures. Fix, then re-enable and use the log to see what was missed. |