# Use Feature Flags to Gate Your API

Zuplo doesn't have a dedicated feature-flag policy — instead, every provider
works, because you evaluate flags in custom code. This guide shows the patterns
that keep flag checks fast on a gateway, with worked examples for Statsig,
PostHog, Flagsmith, and GrowthBook:

- **Live evaluation with caching** — call the provider's API on the request and
  cache the result per user for a short TTL. Use for flags targeted at
  individual users, plans, or tenants.
- **Background loading** — load flags that apply globally (kill switches,
  maintenance mode, flag definitions) with the
  [BackgroundLoader](../programmable-api/background-loader.mdx), which refreshes
  out of band instead of blocking requests.
- **Provider SDK with local evaluation** — evaluate flags in-process with no
  per-request provider call, at the cost of a heavier cold start. See
  [Statsig, fully local](#statsig-fully-local).

## Choose a pattern

| Pattern                            | Best for                                           | Latency cost per request                            |
| ---------------------------------- | -------------------------------------------------- | --------------------------------------------------- |
| Live evaluation + cache            | Per-user or per-plan targeting                     | One provider call per user per TTL window           |
| Background loading                 | Global flags, kill switches, flag definition files | None in the steady state; refreshes run out of band |
| Provider SDK with local evaluation | Complex targeting rules, many gates per request    | None after initialization, but heavier cold start   |

Most APIs mix these: a background-loaded global flag set plus a small number of
cached per-user checks on the routes that need them.

## Prerequisites

- A Zuplo project with an authentication policy on the routes you want to gate
  (for example [API key authentication](../policies/api-key-inbound.mdx)). The
  examples read `request.user`, which an inbound authentication policy populates
  — `request.user.sub` is the user identifier and `request.user.data` carries
  JWT claims or API key metadata such as the customer's `plan`. See
  [Request User](../programmable-api/request-user.mdx).
- An API key or token from your flag provider, stored as a secret
  [environment variable](./environment-variables.mdx). Where to find each one:

  | Variable                    | Provider   | Where to find it                                   | Secret? |
  | --------------------------- | ---------- | -------------------------------------------------- | ------- |
  | `STATSIG_SERVER_KEY`        | Statsig    | Console → Settings → API Keys → Server Secret Key  | Yes     |
  | `STATSIG_CLIENT_KEY`        | Statsig    | Console → Settings → API Keys → Client SDK Key     | No      |
  | `POSTHOG_PROJECT_TOKEN`     | PostHog    | Project Settings → Project ID → project token      | No      |
  | `FLAGSMITH_ENVIRONMENT_KEY` | Flagsmith  | Environment settings → client-side environment key | No      |
  | `GROWTHBOOK_CLIENT_KEY`     | GrowthBook | SDK Connections → client key                       | No      |

  New environment variables require a redeployment before your code sees them.

You only need the variables for the providers you use — skip the rest.

## Pattern 1: Live evaluation with caching

Every major provider exposes an HTTP endpoint that evaluates a flag for a user
and returns the result. Call it on the first request, then cache the result in a
[MemoryZoneReadThroughCache](../programmable-api/memory-zone-read-through-cache.mdx)
so subsequent requests for the same user skip the network call.

This example checks a Statsig gate and caches the result for 60 seconds:

```ts title="modules/feature-gate-policy.ts"
import {
  HttpProblems,
  MemoryZoneReadThroughCache,
  ZuploContext,
  ZuploRequest,
  environment,
} from "@zuplo/runtime";

const CACHE_NAME = "feature-flags";
const CACHE_TTL_SECONDS = 60;

async function isGateEnabled(
  gateName: string,
  userId: string,
  context: ZuploContext,
): Promise<boolean> {
  const cache = new MemoryZoneReadThroughCache<boolean>(CACHE_NAME, context);
  const cacheKey = `statsig:${gateName}:${userId}`;

  const cached = await cache.get(cacheKey);
  if (cached !== undefined) {
    return cached;
  }

  const response = await fetch("https://api.statsig.com/v1/check_gate", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "statsig-api-key": environment.STATSIG_SERVER_KEY,
    },
    body: JSON.stringify({
      gateName,
      user: { userID: userId },
    }),
  });

  if (!response.ok) {
    // Fail closed: if Statsig is unreachable, deny the feature rather
    // than exposing something that isn't rolled out. Choose deliberately —
    // see "Decide how to fail" below.
    context.log.error(`Statsig check failed: ${response.status}`);
    return false;
  }

  const result = await response.json();
  const enabled = result.value === true;
  cache.put(cacheKey, enabled, CACHE_TTL_SECONDS);
  return enabled;
}

export default async function policy(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const userId = request.user?.sub;
  if (!userId) {
    return HttpProblems.unauthorized(request, context);
  }

  const enabled = await isGateEnabled("new-beta-endpoint", userId, context);
  if (!enabled) {
    return HttpProblems.forbidden(request, context, {
      detail: "This feature is not enabled for your account",
    });
  }

  return request;
}
```

Only the first request for each user pays for the Statsig call; every request
after that reads from memory until the TTL expires. Keep the TTL short (30–120
seconds) so flag changes in the provider propagate quickly. To check several
gates, fire the `check_gate` calls in parallel with `Promise.all` and cache each
result under its own key.

### Wire up the policy

Register the module as a custom code policy in `config/policies.json`:

```json title="config/policies.json"
{
  "name": "feature-gate",
  "policyType": "custom-code-inbound",
  "handler": {
    "export": "default",
    "module": "$import(./modules/feature-gate-policy)"
  }
}
```

Then add it to the inbound policy chain of each route you want to gate, after
authentication:

```json title="config/routes.oas.json"
{
  "paths": {
    "/beta/insights": {
      "get": {
        "x-zuplo-route": {
          "policies": {
            "inbound": ["api-key-auth", "feature-gate"]
          }
        }
      }
    }
  }
}
```

### Verify the gate

Call the route twice — once with the flag off and once on:

- **Flag off** — the request returns `403` with an `application/problem+json`
  body: `"detail": "This feature is not enabled for your account"`.
- **Flag on** — the request passes through to the handler normally.

Because the example fails closed, a bad key or unreachable Statsig returns the
same 403 as a disabled flag. Watch the logs for the `Statsig check failed:`
error line to tell a provider outage apart from a legitimately denied request.

## Pattern 2: Background loading for global flags

Some flags apply to everyone: kill switches, maintenance mode, or a global
rollout. Others are cheapest to fetch as one shared definition file rather than
per-user evaluations. Either way, there's no reason to make a request wait on
that fetch — load it in the background instead.

The [BackgroundLoader](../programmable-api/background-loader.mdx) returns cached
data immediately when available, refreshes it asynchronously when the TTL
expires, and only blocks while the cache is empty on a cold start. Create it at
module level so the loader is shared across requests handled by the same worker
process ([isolate](../concepts/how-zuplo-works.mdx)).

GrowthBook is a natural fit here: its CDN returns the full feature definition
payload as a single cacheable JSON document.

```ts title="modules/global-flags-policy.ts"
import {
  BackgroundLoader,
  HttpProblems,
  ZuploContext,
  ZuploRequest,
  environment,
} from "@zuplo/runtime";

interface GrowthBookFeatures {
  [flagName: string]: { defaultValue: unknown };
}

const featureLoader = new BackgroundLoader<GrowthBookFeatures>(
  async () => {
    const response = await fetch(
      `https://cdn.growthbook.io/api/features/${environment.GROWTHBOOK_CLIENT_KEY}`,
    );
    if (!response.ok) {
      throw new Error(`Failed to load features: ${response.status}`);
    }
    const data = await response.json();
    return data.features;
  },
  {
    ttlSeconds: 60,
    loaderTimeoutSeconds: 5,
  },
);

export default async function policy(
  request: ZuploRequest,
  context: ZuploContext,
) {
  // Returns cached definitions immediately in the steady state;
  // the only blocking case is an empty cache on a cold start.
  const features = await featureLoader.get("features");

  if (features["api-maintenance-mode"]?.defaultValue === true) {
    return HttpProblems.serviceUnavailable(request, context, {
      detail: "This API is temporarily unavailable for maintenance",
    });
  }

  return request;
}
```

Register this module as a `custom-code-inbound` policy in `config/policies.json`
and add it to the routes you want to gate, exactly as in pattern 1.

The same pattern works for any provider payload that isn't user-specific — for
example a Statsig gate evaluated against a fixed global user, or the Flagsmith
environment document. One BackgroundLoader per provider keeps each payload on
its own refresh cadence.

:::note

The `defaultValue` shortcut covers simple on/off flags. Flags with targeting
rules or percentage rollouts need an evaluator — either evaluate remotely
(pattern 1) or pass the definitions to the provider's JavaScript SDK.

:::

## More providers

The live-evaluation pattern is the same everywhere; only the endpoint, headers,
and response shape change. Swap the fetch call inside the cached helper — and,
for providers like PostHog that evaluate all flags in one call, cache the whole
map keyed by user instead of a boolean keyed by gate and user.

### PostHog

PostHog evaluates **all** flags for a user in one call, so cache the whole map
per `distinct_id`. Use your project token — it's safe for this public endpoint,
though you should still store it in an environment variable.

```ts
const response = await fetch("https://us.i.posthog.com/flags?v=2", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    api_key: environment.POSTHOG_PROJECT_TOKEN,
    distinct_id: userId,
    person_properties: { plan: request.user?.data?.plan },
  }),
});

const data = await response.json();
// Cache the whole map: cache.put(`posthog:${userId}`, data.flags, 60)
// data.flags["new-checkout"] = { key, enabled, variant, metadata: { payload } }
const enabled = data.flags?.["new-checkout"]?.enabled === true;
const variant = data.flags?.["new-checkout"]?.variant; // multivariate flags
```

Use `https://eu.i.posthog.com` if your PostHog project is hosted in the EU.

### Flagsmith

Flagsmith's edge API evaluates flags for an identity with a simple GET. Use the
client-side environment key (not a server-side SDK key) — it's designed to be
public.

```ts
const response = await fetch(
  `https://edge.api.flagsmith.com/api/v1/identities/?identifier=${encodeURIComponent(userId)}`,
  {
    headers: { "X-Environment-Key": environment.FLAGSMITH_ENVIRONMENT_KEY },
  },
);

const data = await response.json();
const flag = data.flags?.find((f) => f.feature.name === "new-checkout");
const enabled = flag?.enabled === true;
const value = flag?.feature_state_value; // remote config value
```

### Statsig, fully local

If you need many gates evaluated per request with zero provider calls, use
Statsig's `@statsig/serverless-client` SDK, which is built for short-lived
worker processes. Unlike the HTTP API examples above (which use the server
secret key), the serverless client evaluates on-device and authenticates with
the publishable client SDK key.

Zuplo runs a custom JavaScript engine rather than Node.js, so third-party
packages must be bundled into your project before you can import them — see
[Node Modules](../programmable-api/node-modules.mdx). Bundle the SDK once, then
import it from the bundled path:

```bash
npm install @statsig/serverless-client
npx tsdown ./node_modules/@statsig/serverless-client --format esm --platform browser --out-dir ./modules/third-party/statsig
```

Initialize the client once at module level so the configuration download happens
on cold start, not per request. Evaluate gates locally, and flush exposure
events with
[`context.waitUntil`](../programmable-api/zuplo-context.mdx#waituntil) so
analytics never add latency to a response:

```ts title="modules/statsig-local-policy.ts"
import {
  HttpProblems,
  ZuploContext,
  ZuploRequest,
  environment,
} from "@zuplo/runtime";
import { StatsigServerlessClient } from "./third-party/statsig/index.mjs";

const client = new StatsigServerlessClient(environment.STATSIG_CLIENT_KEY);
// Kicks off the config download once per worker process, not per request
const ready = client.initializeAsync();

export default async function policy(
  request: ZuploRequest,
  context: ZuploContext,
) {
  await ready;
  const enabled = client.checkGate("new-beta-endpoint", {
    userID: request.user?.sub,
  });
  context.waitUntil(client.flush());

  if (!enabled) {
    return HttpProblems.forbidden(request, context, {
      detail: "This feature is not enabled for your account",
    });
  }
  return request;
}
```

### What about LaunchDarkly?

LaunchDarkly is the least HTTP-friendly option. Its server-side SDK holds a
persistent streaming connection open and relies on Node.js APIs that don't run
in Zuplo's engine, and LaunchDarkly doesn't document a plain-HTTP endpoint for
server-side evaluation. Its
[edge SDKs](https://launchdarkly.com/docs/sdk/server-side) evaluate locally but
assume platform-specific storage bindings (Cloudflare KV, Vercel Edge Config,
and similar), so they don't drop directly into a Zuplo project either. If
LaunchDarkly is your provider, the practical paths are evaluating flags in your
backend behind the gateway, or syncing flag state into a store your Zuplo code
reads with the patterns above.

## Gate an MCP server with flags

If you're fronting an MCP server with the
[MCP Gateway](../mcp-gateway/introduction.mdx), the same policies apply — MCP
routes run the standard inbound policy chain before the handler. Add your
feature-gate policy to the MCP route to control access per user or plan, for
example to make MCP access a paid-tier feature or to turn off a beta server.

For curating _which tools_ a server exposes, start with the static
[capability filtering](../mcp-gateway/capability-filtering.mdx) policy, which
needs no code. Feature flags complement it when the availability of the server
itself must change dynamically per user without a redeploy.

## Best practices

1. **Decide how to fail.** If the provider is unreachable, fail closed (deny the
   feature) for rollouts of risky functionality, or fail open (allow) for
   cosmetic features. The examples above fail closed and log the error.
2. **Keep per-user TTLs short.** 30–120 seconds balances provider traffic
   against flag-change propagation. Global definitions can tolerate longer.
3. **Cache the smallest useful value.** A boolean per gate per user stays well
   within memory limits; don't cache whole provider responses you don't need.
   Remember a Zuplo process has roughly 120 MB of memory — see
   [Lazy Load Configuration](./lazy-load-configuration-into-cache.mdx) for the
   caching trade-offs.
4. **Store keys in environment variables.** Use secret
   [environment variables](./environment-variables.mdx) for server keys like
   Statsig's server secret; never hardcode them.
5. **Log exposures in the background.** Providers use exposure events for
   analytics and experiments. Send them with
   [`context.waitUntil`](../programmable-api/zuplo-context.mdx#waituntil) (as in
   the Statsig SDK example) so analytics never add latency to a request.

## Next steps

- [BackgroundLoader](../programmable-api/background-loader.mdx) — the primitive
  behind pattern 2
- [MemoryZoneReadThroughCache](../programmable-api/memory-zone-read-through-cache.mdx)
  and [ZoneCache](../programmable-api/zone-cache.mdx) — the caching layers
- [Custom code policies](../policies/custom-code-inbound.mdx) — how inbound
  policies are wired and run
- [Lazy Load Configuration](./lazy-load-configuration-into-cache.mdx) — the
  general config-caching pattern this guide builds on
- [Curate the tools an upstream exposes](../mcp-gateway/how-to/curate-tools.mdx)
  — static MCP tool curation to combine with flags
