ZuploZuplo
LoginStart for Free
  • Documentation
  • API Reference
Introduction
Getting Started
    Develop in the portal
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
    Develop locally with the CLI
      1 - Setup Your Gateway2 - Rate Limiting3 - API Key Auth4 - Deploy5 - Dynamic Rate LimitingDynamic MCP Server - Quickstart
Concepts
Development
    CORSEnvironment VariablesBranch-Based DeploymentsTroubleshootingGitOps vs TerraformCustom Code
    Testing
    Local Development
    Guides
      Advanced Path MatchingAPI VersioningOpenAPI Server URLsConvert URLs to OpenAPIOpenAPI Extension DataFormat Validation WarningsPath Modification ScriptsOpenAPI OverlaysCanary Routing for EmployeesGeolocation Backend RoutingUser-Based Backend RoutingTransform Route ParametersBypass a PolicyTesting GraphQL QueriesHealth ChecksPerformance TestingTroubleshooting Slow ResponsesNon-Standard PortsHandling FormDataS3 Signed URL UploadsCheck IP AddressLazy Load ConfigurationFeature Flags & GatingSharing Code Across ProjectsBackstage IntegrationGitHub Action Automation
Policies
Handlers
API Keys
Rate Limiting
Caching
MCP Server
MCP Gateway
AI Gateway
Developer Portal
Monetization
GraphQL
Deploying & Source Control
Analytics
Observability
Networking & Infrastructure
Account Management
Programming API
Build with AI
Zuplo CLI
Migration Guides
Platform LimitsVersion Support PolicySecuritySupportTrust & ComplianceChangelog
powered by Zudoku
Guides

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, 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.

Choose a pattern

PatternBest forLatency cost per request
Live evaluation + cachePer-user or per-plan targetingOne provider call per user per TTL window
Background loadingGlobal flags, kill switches, flag definition filesNone in the steady state; refreshes run out of band
Provider SDK with local evaluationComplex targeting rules, many gates per requestNone 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). 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.

  • An API key or token from your flag provider, stored as a secret environment variable. Where to find each one:

    VariableProviderWhere to find itSecret?
    STATSIG_SERVER_KEYStatsigConsole → Settings → API Keys → Server Secret KeyYes
    STATSIG_CLIENT_KEYStatsigConsole → Settings → API Keys → Client SDK KeyNo
    POSTHOG_PROJECT_TOKENPostHogProject Settings → Project ID → project tokenNo
    FLAGSMITH_ENVIRONMENT_KEYFlagsmithEnvironment settings → client-side environment keyNo
    GROWTHBOOK_CLIENT_KEYGrowthBookSDK Connections → client keyNo

    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 so subsequent requests for the same user skip the network call.

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

Code
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:

Code
{ "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:

Code
{ "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 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).

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

Code
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.

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.

Code
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.

Code
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. Bundle the SDK once, then import it from the bundled path:

TerminalCode
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 so analytics never add latency to a response:

Code
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 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, 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 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 for the caching trade-offs.
  4. Store keys in environment variables. Use secret environment variables 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 (as in the Statsig SDK example) so analytics never add latency to a request.

Next steps

  • BackgroundLoader — the primitive behind pattern 2
  • MemoryZoneReadThroughCache and ZoneCache — the caching layers
  • Custom code policies — how inbound policies are wired and run
  • Lazy Load Configuration — the general config-caching pattern this guide builds on
  • Curate the tools an upstream exposes — static MCP tool curation to combine with flags
Edit this page
Last modified on August 20, 2026
Lazy Load ConfigurationSharing Code Across Projects
On this page
  • Choose a pattern
  • Prerequisites
  • Pattern 1: Live evaluation with caching
    • Wire up the policy
    • Verify the gate
  • Pattern 2: Background loading for global flags
  • More providers
    • PostHog
    • Flagsmith
    • Statsig, fully local
    • What about LaunchDarkly?
  • Gate an MCP server with flags
  • Best practices
  • Next steps
TypeScript
JSON
JSON
TypeScript
TypeScript
TypeScript
TypeScript