# Feature flags

Release features gradually, target cohorts, run deterministic rollouts, and read flag variants from every Cohorly SDK.

Feature flags let you control product behavior without shipping a new app
version for every change. A flag belongs to one project, is evaluated for one
`distinct_id`, and can return a boolean, a variant key, and an optional JSON
payload.

## When to use feature flags

Use a flag when you need to:

- release a feature behind a kill switch;
- expose a feature to a cohort or a list of users;
- split traffic between variants for an experiment;
- configure a small value, such as a label, color, or limit, without a deploy.

Keep the flag decision close to the code that changes behavior. Use Cohorly
events and reports to measure the result of the release or experiment.

## Core terms

| Term             | Meaning                                                                                                                                                                                                                                                  |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Flag             | A project-scoped switch with an immutable key, a type, and an active state.                                                                                                                                                                              |
| Type             | A flag's purpose - `release`, `experiment`, `operational`, `kill_switch`, or `permission`. Descriptive only; it never changes evaluation.                                                                                                                |
| Variant          | A named value returned by a flag, with a rollout percentage and optional payload.                                                                                                                                                                        |
| Rule / Strategy  | The stored shape is a `Rule` (a cohort, exact distinct IDs, and/or a rollout percentage). The dashboard shows it as a "Strategy" - Standard, Specific users, Gradual rollout, or Cohort - classified from that shape; the wire shape never changes.      |
| Rollout          | The percentage of matching identities assigned to a flag or variant.                                                                                                                                                                                     |
| Override         | An exact distinct ID list used for targeted testing - the Specific users strategy.                                                                                                                                                                       |
| Archive / revive | Archiving turns a flag off for good, for now: it disappears from `POST /flags/evaluate` (SDKs see it like a deleted flag) and drops out of the default list and local evaluation. Reversible via revive; delete is only offered once a flag is archived. |
| Playground       | The dashboard's one-input tester: pick a Distinct ID, see enabled/disabled, the variant, the payload, and which Strategy matched.                                                                                                                        |
| Local evaluation | Server-side evaluation from definitions cached with a flag secret.                                                                                                                                                                                       |
| Exposure         | The `$feature_flag_called` event recorded when an SDK reports a flag read.                                                                                                                                                                               |

## Create a flag

Creating a flag is deliberately cheap - name, key, type, and an optional
description. Targeting happens afterward, on the flag's own page.

1. Open **Data > Flags** in the dashboard and select **Create flag**.
2. Enter a human-readable name and a lowercase key. Keys may contain letters,
   digits, `-`, and `_`, and cannot be changed after creation.
3. Pick a type - it only documents intent (a kill switch reads differently
   from an experiment), it never changes how the flag evaluates.
4. Save. The flag is created inactive with no strategies, and you land on its
   detail page.

## Targeting: strategies and the Playground

On a flag's page, the **Targeting** tab lists its Strategies top to bottom -
the first one that matches an identity decides the result. **Add strategy**
opens a picker with four kinds, same as the stored `rules[]` shape underneath:

- **Standard: everyone** - matches every identity that reaches it.
- **Specific users** - an exact Distinct ID list (the Override), capped at 50.
  A testing tool, not a substitute for a Cohort: it always matches
  unconditionally, at 100% rollout.
- **Gradual rollout** - a percentage of identities, hashed deterministically
  by Distinct ID so the same identity stays on the same side of the line.
- **Cohort** - identities in a saved [Cohort](/concepts#cohorts), optionally
  narrowed further by a rollout percentage. A cohort's property criteria
  include numeric (`gt`/`gte`/`lt`/`lte`) and version (`version_gte` and
  friends, comparing `major[.minor[.patch]]` component-wise, with a
  `-prerelease` suffix ordering below the same core version) operators, so
  "app >= 1.57.0" is one criterion - on iOS the semver property is
  `$ios_app_release` (`$ios_app_version` is the build number), on Android
  `$android_app_version`.

Each strategy card saves on its own explicit **Save**, so the tab never holds
a half-edited form. Reordering and removing a strategy apply immediately.

To turn a flag on for exactly one identity and confirm it: **Add strategy >
Specific users**, type the Distinct ID, **Save**, then open the **Playground**
tab, enter the same Distinct ID, and evaluate - it reports which strategy
matched by name (for example "matched strategy 1: Specific users"). The
**Activity** tab keeps a full audit trail of every change, and the header's
big **Enabled** switch plus its status sentence ("On for 3 specific users.",
"Off for everyone.") answer "is this on, and for whom" without opening a
single rule.

## How evaluation works

For a flag and a `distinct_id`, Cohorly evaluates the following sequence:

1. An inactive flag returns `enabled: false` with reason `inactive`.
2. Rules are checked in the order shown in the dashboard.
3. Every targeting condition configured on a rule must match. A rule with both
   a cohort and an exact ID list requires both; a rule with neither targets all
   identities that reach it.
4. The rule rollout is checked deterministically. If the identity falls
   outside the rollout, the rule does not match and evaluation continues with
   the next rule. The same identity remains in the same bucket until the rule
   or identity changes.
5. A named variant on the rule wins. Otherwise, the identity is assigned to a
   variant using the variant rollout percentages.
6. If no rule matches, the flag returns `enabled: false` with reason
   `no_match`.

The evaluation response includes a reason that is useful when debugging:

| Reason         | Meaning                                                          |
| -------------- | ---------------------------------------------------------------- |
| `inactive`     | The flag is turned off.                                          |
| `no_match`     | No rule matched the identity.                                    |
| `rule:<index>` | Rule `<index>` matched, using zero-based rule order.             |
| `archived`     | Playground only: the flag is archived, so SDKs never receive it. |

An empty rules list is fail-closed: the flag is disabled for everyone. A newly
created flag has no strategies and starts inactive, on purpose - add a
strategy and enable it from the flag's own page when you are ready to target
it (see "Targeting: strategies and the Playground" above).

Remote evaluation resolves linked anonymous and identified IDs to the same
identity cluster. Local evaluation uses the raw `distinct_id` supplied to the
server SDK. Keep this distinction in mind when comparing client and server
results for a user who has logged in.

## Read flags in client applications

Client SDKs load the current identity's flags on initialization by default.
They cache results by identity, reload after `identify()` and `reset()`, and
retain the last successful result when a later reload fails. Unknown flags and
flags that have not loaded yet are safe off states.

### Flag persistence

Client SDKs persist the last successful evaluation per identity, so a restart
or an offline launch still has flag values to serve. The `flagPersistence`
option controls the policy (the names match Mixpanel's `variantLookupPolicy`):

| Policy                                       | Behavior                                                                                                                                                                                                                                                                                                                                                                |
| -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `"persistenceUntilNetworkSuccess"` (default) | Serves a valid persisted cache immediately - no startup flicker - until the first successful network evaluation replaces it and re-persists. If the network keeps failing, the cache keeps serving. Trades freshness for offline availability: an expired cache still serves up to 7 days past `stored_at` (the wider of `flagCacheTtlMs` and 7 days bounds staleness). |
| `"networkFirst"`                             | Prefers the network; until the first successful evaluation (or whenever it fails), a still-valid persisted cache is served - strictly within `flagCacheTtlMs` of `stored_at`. The cache is still written on every success.                                                                                                                                              |
| `"networkOnly"`                              | Never reads or writes the persisted cache. Flags come only from network responses in this session; a failed evaluation resolves to the getter's fallback.                                                                                                                                                                                                               |

The cache is scoped by `distinct_id` and expires: `flagCacheTtlMs` (default
86400000 ms, 24 hours) bounds how fresh a persisted assignment must be -
`networkFirst` serves it only within the TTL, while
`persistenceUntilNetworkSuccess` still serves an expired cache up to 7 days
stale and discards anything older. On iOS the option is `flagCacheTTL`, a
`TimeInterval` in seconds (default 86400); Android and the JavaScript SDKs
use `flagCacheTtlMs` in milliseconds. A cache written for another identity
is discarded on read and never served. A legacy cache written by a pre-TTL
SDK version carries no write timestamp - its age cannot be proven, so it is
served indefinitely under either persistent policy (the graceful upgrade of
the pre-feature behavior) until the first successful reload rewrites it in
the stamped format; `persistedAt` stays absent for it. `identify()` and
`reset()` drop the previous identity's in-memory flags and remove its
persisted cache before fetching the new identity's, under every policy - a
stale cache can never leak across users on a shared device.

### Flag source and details

Every read can report where the value came from. `getFeatureFlagDetails(key)`
returns the full result:

```ts
const details = cohorly.getFeatureFlagDetails("checkout-redesign");
// {
//   key: "checkout-redesign",
//   value: "new",            // variant key, or the enabled boolean
//   enabled: true,
//   payload: { ... },
//   source: "network" | "persistence" | "fallback",
//   persistedAt?: 1735689600000,  // when source is "persistence" (absent for legacy caches)
//   ttlMs?: 86400000,             // only when source is "persistence"
// }
```

`source` is `"network"` for a successful evaluation this session,
`"persistence"` for the identity-scoped cache (within the policy's staleness
window - `persistedAt` reports the cache's real age, so staleness stays
observable; it is absent only for a legacy cache without `stored_at`), and
`"fallback"` when the flag is unknown so the caller's fallback was returned.
Every client SDK exposes `getFeatureFlagDetails` with the same contract:
`key`, `value`, `source`, and `persistedAt`/`ttlMs` on persistence-sourced
values. The JavaScript SDKs also return `enabled` and `payload` in the same
object; on iOS and Android those are available through `isFeatureEnabled()`
and `getFeatureFlagPayload()`.

`getFeatureFlag(key, fallback)` and `isFeatureEnabled(key, fallback)` also
accept an explicit fallback returned for unknown or unloaded flags. A read
that resolves to the fallback is a default, not an exposure - it emits no
`$feature_flag_called`.

### Web

```ts
import { init } from "@cohorly/web";

const cohorly = init({
  token: "YOUR_PROJECT_TOKEN",
  loadFeatureFlags: true,
  sendExposureEvents: true,
  flagPersistence: "persistenceUntilNetworkSuccess",
  flagCacheTtlMs: 24 * 60 * 60 * 1000,
});

const enabled = cohorly.isFeatureEnabled("checkout-redesign");
const value = cohorly.getFeatureFlag("checkout-redesign", "control");
const payload = cohorly.getFeatureFlagPayload("checkout-redesign");
const details = cohorly.getFeatureFlagDetails("checkout-redesign");

await cohorly.reloadFeatureFlags();
```

`isFeatureEnabled(key)` always returns a boolean. `getFeatureFlag(key)` returns
the variant key when the flag has variants, otherwise a boolean. Before the
first successful load it returns `false` (or the supplied fallback).
`getFeatureFlagPayload(key)` returns the variant payload or `null`.

Set `loadFeatureFlags: false` when the application wants to control the first
load manually. Set `sendExposureEvents: false` to disable automatic exposure
tracking.

### React

The React SDK re-exports the web client and adds hooks that re-render after a
successful flag reload:

```tsx
import { useFeatureFlag, useFeatureFlagPayload } from "@cohorly/react";

export function Checkout() {
  const value = useFeatureFlag("checkout-redesign");
  const payload = useFeatureFlagPayload("checkout-redesign");

  if (value === undefined) {
    return null;
  }

  const isNewCheckout = value === true || value === "new";

  return isNewCheckout ? (
    <NewCheckout payload={payload} />
  ) : (
    <LegacyCheckout />
  );
}
```

The hook value is `undefined` while flags are loading, then becomes a boolean
or variant key. The payload hook is `undefined` while loading and `null` when
the loaded flag has no payload. `useFeatureFlagDetails(key)` returns the same
`FlagDetails` object as `getFeatureFlagDetails()`, including the `source`.

### Next.js

`@cohorly/nextjs` re-exports the React API. If you use
`createCohorlyProxy()`, include `flags/evaluate` in `allowedPaths` whenever you
provide a custom allowlist. The default allowlist already includes this path.

```ts
import { createCohorlyProxy } from "@cohorly/nextjs/server";

export const { POST } = createCohorlyProxy({
  token: process.env.COHORLY_PROJECT_TOKEN!,
  allowedPaths: ["track", "engage", "alias", "flags/evaluate"],
});
```

See the [Next.js SDK guide](/sdks/nextjs) for the complete proxy setup.

### Mobile SDKs

| SDK          | Initialization options                                                                | Read and refresh methods                                                                                                       |
| ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| React Native | `loadFeatureFlags`, `sendExposureEvents`, `flagPersistence`, `flagCacheTtlMs`         | `getFeatureFlag`, `isFeatureEnabled`, `getFeatureFlagDetails`, `getFeatureFlagPayload`, `reloadFeatureFlags`, `onFeatureFlags` |
| iOS          | `loadFeatureFlags`, `sendExposureEvents`, `flagPersistence`, `flagCacheTTL` (seconds) | `getFeatureFlag`, `isFeatureEnabled`, `getFeatureFlagDetails`, `getFeatureFlagPayload`, `reloadFeatureFlags`, `onFeatureFlags` |
| Android      | `loadFeatureFlags`, `sendExposureEvents`, `flagPersistence`, `flagCacheTtlMs`         | `getFeatureFlag`, `isFeatureEnabled`, `getFeatureFlagDetails`, `getFeatureFlagPayload`, `reloadFeatureFlags`, `onFeatureFlags` |

Mobile clients persist the identity-scoped cache in their platform storage.
The first load is asynchronous. Render a safe loading state until the SDK has
returned a value.

Read the platform-specific [React Native](/sdks/react-native), [iOS](/sdks/ios),
and [Android](/sdks/android) references for initialization and type details.

## Read flags in server applications

Server SDKs evaluate flags remotely by default. Add a flag secret when you
want local evaluation for flags marked `localEvaluable`. The SDK polls the
definitions endpoint and evaluates local rules without sending a request for
each read. Flags that depend on cohorts, or are not present in the local
definitions, fall back to remote evaluation.

Use local evaluation for low-latency request paths. Use remote evaluation when
you need the server to make the decision from the latest state on every read.

### Node.js

```ts
import Cohorly from "@cohorly/node";

const cohorly = Cohorly.init(process.env.COHORLY_PROJECT_TOKEN!, {
  flagSecret: process.env.COHORLY_FLAG_SECRET,
  flagPollIntervalMs: 30000,
});

await cohorly.flagDefinitionsReady;

const enabled = await cohorly.flags.isFeatureEnabled(
  "checkout-redesign",
  "user-123",
);
const payload = await cohorly.flags.getFeatureFlagPayload(
  "checkout-redesign",
  "user-123",
);
```

Single server reads do not create exposure events unless the call opts in with
`{ sendExposureEvent: true }`. `getAllFlags()` never creates exposures. Stop
the definitions poller during process shutdown with `cohorly.shutdown()`.

### NestJS

Configure the same options in `CohorlyModule.forRoot()` and inject
`CohorlyService`:

```ts
CohorlyModule.forRoot({
  token: process.env.COHORLY_PROJECT_TOKEN!,
  flagSecret: process.env.COHORLY_FLAG_SECRET,
  flagPollIntervalMs: 30000,
});
```

The service exposes `isFeatureEnabled`, `getFeatureFlag`,
`getFeatureFlagPayload`, and `getAllFlags`. The raw `.flags` API is also
available when the lower-level client is needed.

### Python

```python
from cohorly import Cohorly

client = Cohorly(
    "YOUR_PROJECT_TOKEN",
    flag_secret="YOUR_FLAG_SECRET",
    flag_poll_interval_seconds=30.0,
)

enabled = client.is_feature_enabled("checkout-redesign", "user-123")
value = client.get_feature_flag("checkout-redesign", "user-123")
payload = client.get_feature_flag_payload("checkout-redesign", "user-123")
```

Python refreshes definitions lazily when a flag is read. It does not create a
background polling thread. Set `send_exposure_event=True` for a single read;
`get_all_flags()` never creates exposures.

### PHP

```php
use Cohorly\Cohorly;

$cohorly = Cohorly::getInstance('YOUR_PROJECT_TOKEN', [
    'flag_secret' => getenv('COHORLY_FLAG_SECRET'),
    'flag_poll_interval' => 30,
]);

$enabled = $cohorly->flags->isFeatureEnabled('checkout-redesign', 'user-123');
$payload = $cohorly->flags->getFeatureFlagPayload('checkout-redesign', 'user-123');
```

PHP flag reads fail safe: a failed evaluation returns `false`, `null`, or an
empty list and writes a debug log entry instead of throwing. Pass `true` as the
third argument to a single read to send an exposure event.

### Go

```go
package main

import (
    "context"
    "os"

    cohorly "github.com/cohorly-io/cohorly-go"
)

func readFlag(ctx context.Context) (bool, error) {
    client := cohorly.NewClient(
        os.Getenv("COHORLY_PROJECT_TOKEN"),
        cohorly.WithFlagSecret(os.Getenv("COHORLY_FLAG_SECRET")),
    )
    defer client.Close()

    return client.IsFeatureEnabled(ctx, "checkout-redesign", "user-123")
}
```

Use `WithExposureEvent()` on a single read when the decision should be
measured. `GetAllFlags()` never creates exposures. `Close()` stops the local
definitions poller.

## Exposure events

Client SDKs send an exposure event the first time an identity reads a given
flag/value pair in an identity session. The event is deduplicated, so repeated
renders do not inflate exposure counts. Server SDKs require per-call opt-in.

The event name and properties are stable across SDKs:

```json
{
  "event": "$feature_flag_called",
  "properties": {
    "distinct_id": "user-123",
    "$feature_flag": "checkout-redesign",
    "$feature_flag_response": "variant-b",
    "$feature_flag_source": "network"
  }
}
```

`$feature_flag_response` is the served variant key or a boolean for a simple
flag. `$feature_flag_source` reports the same provenance as
`getFeatureFlagDetails().source` - `"network"` or `"persistence"` - and when
the value came from persistence the event also carries
`$feature_flag_ttl_ms` and, when the served cache was written by a TTL-aware
SDK, `$feature_flag_persisted_at` (epoch ms the cache was written - absent
for a legacy cache without `stored_at`). Reads that resolve to the caller's
fallback are never
exposures. Deduplication is by flag/value pair per identity, so the same value
served first from persistence and later from the network does not emit twice.
`getAllFlags()` is intentionally not an exposure API. If you need to
measure a flag decision on the server, use one of the single-read methods with
exposure tracking enabled.

## Flag secrets and security

A project token authenticates ingestion and remote evaluation. A flag secret
is different: it only authorizes `GET /flags/local-evaluation` for server-side
definition polling.

- Keep the flag secret in a server-side secret manager.
- Never include it in browser or mobile bundles.
- Only an organization owner or superadmin can generate, rotate, or revoke it.
- The secret is shown only when it is created or rotated.
- Rotating a secret invalidates the previous secret immediately.
- Revoking a secret rejects future definition refreshes. Running server SDKs
  retain their last successful definitions by design; restart them or remove
  the secret from their configuration to force remote evaluation immediately.

## Release checklist

1. Choose a stable key that describes behavior, not a release date.
2. Start inactive or at a small rollout.
3. Test with an exact ID override.
4. Confirm the flag result and optional payload in the target client or server.
5. Increase the rollout gradually and watch the related Cohorly report.
6. Keep the flag active only while it controls a real decision.
7. Remove obsolete flags and their code after the rollout is complete.

## Troubleshooting

### The flag always returns false

Check that the flag is active and not archived, has at least one strategy, and
that the identity matches the intended Cohort or exact Distinct ID. An empty
strategy list and `no_match` are both disabled outcomes; use the Playground
tab to see the exact reason for one identity.

### A variant is not returned

Check that the variant rollout percentages total 100%, that the matching rule
names an existing variant when it specifies one, and that the caller uses
`getFeatureFlag()` rather than only `isFeatureEnabled()`.

### A client shows an old value

Call `reloadFeatureFlags()` after a dashboard change. Client SDKs retain the
last successful cache when a request fails, which prevents a transient network
error from unexpectedly disabling a live feature.

### Local evaluation is not taking effect

Confirm that the flag secret belongs to the same project, has not been rotated
or revoked, and that the flag is marked `localEvaluable`. Cohort rules and
unknown flags intentionally fall back to remote evaluation.

For request and response shapes, authentication, limits, and error codes, see
the [Feature Flags API reference](/api/flags).
