C# SDK Reference
TL;DR: Install
Featureflip.Client, callclient.BoolVariation("flag-key", context, false), and you’re evaluating flags — synchronous, noawaitin your hot path.
The Featureflip.Client NuGet package provides a thread-safe client for evaluating feature flags in .NET applications. It multi-targets .NET 8.0, .NET 10.0, and .NET Standard 2.0, so the same package works across modern .NET, .NET Framework 4.6.1+, and any other runtime that supports .NET Standard 2.0. Real-time updates arrive over SSE streaming, with a polling fallback when streaming is unavailable.
The SDK is designed as a process-wide singleton. FeatureflipClient.Get(sdkKey) returns handles backed by a shared, refcounted client, so you can resolve it from anywhere — including misregistered DI scopes — without opening duplicate streaming connections. The companion Featureflip.Client.Extensions.DependencyInjection package wires this up correctly for ASP.NET Core with services.AddFeatureflip(...), and the same singleton survives the entire application lifetime.
Variation methods (BoolVariation, StringVariation, IntVariation, DoubleVariation, JsonVariation<T>) read from a ConcurrentDictionary and return synchronously — no await required in your controller actions or hot paths. Beyond Microsoft.Extensions.Logging.Abstractions and System.Text.Json, the SDK has no direct dependencies, keeping the deployed footprint small.
Lifetime
Section titled “Lifetime”The Featureflip C# SDK is designed to be used as a singleton across your application. Obtain the client via the static factory:
var client = FeatureflipClient.Get("your-sdk-key");Calling Get() multiple times with the same SDK key returns handles that share a single underlying client — the SDK maintains a process-wide cache keyed by SDK key. You cannot accidentally open duplicate streaming connections by constructing multiple clients.
If you’re using Microsoft.Extensions.DependencyInjection, the AddFeatureflip extension method registers the client correctly as a singleton:
services.AddFeatureflip("your-sdk-key");Even if you hand-roll a scoped or transient DI registration, the factory’s deduplication ensures all resolves share one underlying client — the misregistration is neutralized, not catastrophic.
Disposal
Section titled “Disposal”Dispose() is refcounted: when multiple handles share one cached client, disposing one handle does not shut down the shared background tasks. The real shutdown runs only when the last handle is disposed. In a typical application this means the client lives for the entire process lifetime and is cleaned up automatically at shutdown.
Requirements
Section titled “Requirements”The SDK multi-targets the following frameworks:
| Target | Minimum Version |
|---|---|
| .NET | 8.0 |
| .NET | 10.0 |
| .NET Standard | 2.0 (.NET Framework 4.6.1+, .NET Core 2.0+) |
Installation
Section titled “Installation”dotnet add package Featureflip.ClientFor ASP.NET Core dependency injection support, also install the extensions package:
dotnet add package Featureflip.Client.Extensions.DependencyInjectionConfiguration
Section titled “Configuration”The FeatureFlagOptions class accepts the following options:
| Option | Type | Default | Description |
|---|---|---|---|
BaseUrl | string | "https://eval.featureflip.io" | Base URL of the Evaluation API |
ConnectTimeout | TimeSpan | 5s | Timeout for establishing connections |
ReadTimeout | TimeSpan | 10s | Timeout for reading responses |
Streaming | bool | true | Enable SSE for real-time flag updates |
PollInterval | TimeSpan | 30s | Polling interval (used when streaming is disabled) |
FlushInterval | TimeSpan | 30s | Interval between event flush batches |
FlushBatchSize | int | 100 | Maximum number of events per flush batch |
InitTimeout | TimeSpan | 10s | Maximum time to wait for initial flag fetch |
WaitForInitialization | bool | false | Block the constructor until flags are loaded |
StartOffline | bool | false | Start the client even if initial flag loading fails |
SnapshotStore | ISnapshotStore? | null | Where the client saves its last configuration and starts from it when the first fetch fails. See Starting during an outage |
MaxSnapshotAge | TimeSpan | 24h | Oldest snapshot the client will start from. Must be positive |
WrapperName | string? | null | For libraries that wrap the SDK: a name appended to the User-Agent after the SDK’s own token |
WrapperVersion | string? | null | Version sent with WrapperName |
Example configuration
Section titled “Example configuration”var options = new FeatureFlagOptions{ BaseUrl = "https://eval.featureflip.io", Streaming = true, PollInterval = TimeSpan.FromSeconds(60), FlushInterval = TimeSpan.FromSeconds(15), InitTimeout = TimeSpan.FromSeconds(30), WaitForInitialization = true,};Initialization
Section titled “Initialization”Create a client by providing an SDK key and optional options:
using var client = FeatureflipClient.Get( "sdk-dev-abc123", new FeatureFlagOptions { WaitForInitialization = true });SDK key resolution
Section titled “SDK key resolution”If sdkKey is null, the client falls back to the FEATUREFLIP_SDK_KEY environment variable. A FeatureFlagInitializationException is thrown if neither is available.
Initialization behavior
Section titled “Initialization behavior”The WaitForInitialization option controls whether the constructor blocks:
false(default): The constructor returns immediately and loads flags in the background. Evaluations return default values until initialization completes.true: The constructor blocks until flags are loaded orInitTimeoutexpires.
The StartOffline option allows the client to start even if initialization fails. The client will return default values and retry in the background.
IsInitialized
Section titled “IsInitialized”bool initialized = client.IsInitialized;Returns true once the client has successfully loaded flag data, and false after Dispose() — a closed handle serves the caller’s default from every accessor.
Starting during an outage
Section titled “Starting during an outage”By default a client that cannot reach the Evaluation API at startup serves your default values until the API comes back, and with WaitForInitialization = true the call to Get() throws FeatureFlagInitializationException. Set SnapshotStore to let a new process start from the last configuration it saved instead. The option is off unless you set it. For how this fits with the rest of the failure behavior, see Reliability.
using Featureflip.Client;
var client = FeatureflipClient.Get( "sdk-dev-abc123", new FeatureFlagOptions { WaitForInitialization = true, SnapshotStore = new FileSnapshotStore("/var/lib/featureflip/snapshot.json"), MaxSnapshotAge = TimeSpan.FromHours(6), });How it behaves:
- The snapshot is read only when the initial fetch fails or times out. A successful fetch always wins, and the snapshot is never consulted afterward.
- A start from a snapshot counts as initialized, so
IsInitializedistrueandWaitForInitializationAsync()completes. When the fetch fails and no usable snapshot exists, the client is also initialized and serves your defaults instead of throwing or staying pending.StartupInfosays why. If you relied on catchingFeatureFlagInitializationExceptionto fail fast, checkclient.StartupInfo.Sourceinstead, because with a snapshot store configuredGet()no longer throws for a failed fetch. Without a store the behavior is unchanged. - After a start from a snapshot the client keeps retrying the live API and replaces the snapshot’s values as soon as it connects. Handlers registered on
FlagsChangedat that moment run for what changed between the two. Initialization runs whileGet()executes (or in the background whenWaitForInitializationisfalse), so a handler you add afterGet()returns can miss that first change. ReadStartupInfoafter registering if you need to know. - The client writes the full configuration after each full update it applies (the first fetch, a poll, a stream resync). Once it is connected live, single-flag updates write it too. Writes run on the thread pool and never block evaluation or initialization.
- While the client stays connected and healthy, it refreshes the stored copy at most every 5 minutes even when nothing changed, so a quiet configuration does not age past
MaxSnapshotAge. Changes are written right away. - The store read is bounded by
InitTimeout. If the fetch times out and then the store read hangs too, initialization finishes after twiceInitTimeout, serving defaults. A store that ignores its cancellation token is still bounded, and a late result is discarded. - The option is read on the first
Get()for an SDK key. Later calls with the same key reuse the existing client, as described under Lifetime, and log a warning if they pass a differentSnapshotStoreinstance or a differentMaxSnapshotAge. Leaving the options out does not warn. MaxSnapshotAgemust be positive. A zero or negative value throwsFeatureFlagInitializationExceptionfromGet(), including a laterGet()for a key that already has a client. There is no value that disables expiry.- With a
SnapshotStoreset,InitTimeoutmust also be positive, because it bounds the store read. A zero or negative value throwsFeatureFlagInitializationExceptionthe same way. Without a store its behavior is unchanged.
A snapshot contains your targeting rules, including any customer attribute values used in them. On Unix, FileSnapshotStore creates the file readable by its owner only (mode 0600), even if a file with broader permissions was already there. On .NET 8 and later it asks the runtime for that mode. Runtimes that load the netstandard2.0 build (.NET Core, .NET 5, 6 and 7, Mono and Unity) call open(2) directly with mode 0600 on Linux and on macOS x64. On any other Unix platform, or if that call cannot be bound, the file follows the process umask and the store logs one warning per process, so keep the directory private there. On Windows the file keeps the permissions of its directory. The store does not call fsync before replacing the file, so a power loss can leave an empty file. The client reads that as snapshot-invalid, logs a warning and serves defaults until the next write.
Container disks are usually ephemeral, so a file written inside the image or container layer is gone after a redeploy. Point the path at a mounted volume:
services: api: volumes: - featureflip-snapshot:/var/lib/featureflipvolumes: featureflip-snapshot:On Kubernetes, mount a PersistentVolumeClaim, or an emptyDir if you only need the file to survive container restarts within a pod, at the same path.
FileSnapshotStore won’t create the parent directory, so it must already exist. Check the owner too. Docker creates a new named volume owned by root unless the image already has that directory, so a process running as a non-root user fails every write and logs a warning each time. Create the directory in your Dockerfile and hand it to that user, for example RUN mkdir -p /var/lib/featureflip && chown app /var/lib/featureflip.
With dependency injection, set the same options in the configure callback, for example services.AddFeatureflip("sdk-dev-abc123", o => o.SnapshotStore = new FileSnapshotStore(path)).
StartupInfo
Section titled “StartupInfo”client.StartupInfo reports where the flag data came from. It returns an immutable StartupInfo, so read the property again to see a change:
| Property | Values | Meaning |
|---|---|---|
Source | StartupSource.None, Snapshot, Live | Live: the initial fetch succeeded, or the client has since connected after starting from Snapshot or None. Snapshot: the client started from the store. None: no data yet, so evaluations return your defaults (also the value of default(StartupSource)) |
Reason | "snapshot", "snapshot-expired", "snapshot-invalid", or null | Set when a snapshot was considered, and cleared once the client is live. snapshot: it was used. snapshot-expired: it was older than MaxSnapshotAge. snapshot-invalid: the stored text could not be used. That covers a garbage, empty or cut-off file, a snapshot written for a different SDK key or base URL, and a timestamp more than 60 seconds in the future. An empty store sets no reason |
SnapshotAgeSeconds | long? | Age of the snapshot that was used or rejected for age, in whole seconds |
var info = client.StartupInfo;if (info.Source != StartupSource.Live){ logger.LogWarning( "Flags did not load from the API: {Source} {Reason} {Age}", info.Source, info.Reason, info.SnapshotAgeSeconds);}Custom stores
Section titled “Custom stores”To share one snapshot across processes, implement ISnapshotStore. It stores text: GetAsync returns the string last stored, or null when there is none, and PutAsync replaces it. Both may be called from a background thread. This sketch uses Redis:
using Featureflip.Client;using StackExchange.Redis;
public sealed class RedisSnapshotStore : ISnapshotStore{ private const string Key = "featureflip:snapshot:production"; private readonly IDatabase _db;
public RedisSnapshotStore(IConnectionMultiplexer redis) => _db = redis.GetDatabase();
public async Task<string?> GetAsync(CancellationToken cancellationToken) { RedisValue value = await _db.StringGetAsync(Key); return value.HasValue ? value.ToString() : null; }
public Task PutAsync(string text, CancellationToken cancellationToken) => _db.StringSetAsync(Key, text);}The store never needs to parse the text. The SDK wraps the configuration in its own envelope, with a timestamp and a hash of the SDK key and base URL, and checks all of it on read. Text written for a different environment is rejected as snapshot-invalid and never served, which is why one store instance can safely be shared by clients for several SDK keys. Still give each environment its own key or path. Two environments sharing one slot overwrite each other’s snapshot.
A store that throws, or returns a faulted task, is treated as having no snapshot or as a failed write, and the client logs a warning. The interface returns Task<string?>, so a store cannot hand back a non-text value.
Evaluating Flags
Section titled “Evaluating Flags”All evaluation methods accept a flag key, an evaluation context, and a default value. If the flag is not found or an error occurs, the default value is returned.
Variation<T>()
Section titled “Variation<T>()”Generic evaluation method that works with any type:
var context = new EvaluationContext { UserId = "u-123" };bool enabled = client.Variation("dark-mode", context, false);Signature:
T Variation<T>(string key, EvaluationContext context, T defaultValue)BoolVariation()
Section titled “BoolVariation()”bool enabled = client.BoolVariation("dark-mode", context, false);StringVariation()
Section titled “StringVariation()”string plan = client.StringVariation("pricing-tier", context, "free");IntVariation()
Section titled “IntVariation()”int limit = client.IntVariation("rate-limit", context, 100);DoubleVariation()
Section titled “DoubleVariation()”double ratio = client.DoubleVariation("rollout-ratio", context, 0.5);JsonVariation<T>()
Section titled “JsonVariation<T>()”Deserializes a JSON flag value to the specified type:
var banner = client.JsonVariation<BannerConfig>( "banner-config", context, new BannerConfig { Text = "Welcome", Color = "#000" });Evaluation Details
Section titled “Evaluation Details”VariationDetail<T>()
Section titled “VariationDetail<T>()”Returns an EvaluationDetail<T> object with the evaluated value and metadata about the evaluation decision.
var detail = client.VariationDetail("dark-mode", context, false);
Console.WriteLine(detail.Value); // TrueConsole.WriteLine(detail.Reason); // RuleMatchConsole.WriteLine(detail.RuleId); // "rule-abc"Console.WriteLine(detail.ErrorMessage); // nullSignature:
EvaluationDetail<T> VariationDetail<T>(string key, EvaluationContext context, T defaultValue)EvaluationDetail<T>
Section titled “EvaluationDetail<T>”| Property | Type | Description |
|---|---|---|
Value | T | The evaluated flag value |
Reason | EvaluationReason | Why this value was returned |
RuleId | string? | The ID of the matched targeting rule |
ErrorMessage | string? | Error message if evaluation failed |
EvaluationReason
Section titled “EvaluationReason”| Value | Description |
|---|---|
RuleMatch | A targeting rule matched the context |
Fallthrough | No rules matched; the fallthrough variation was served |
FlagDisabled | The flag is disabled; the off variation was served |
FlagNotFound | The flag key does not exist |
Error | An error occurred during evaluation |
Evaluation Context
Section titled “Evaluation Context”The EvaluationContext class provides built-in properties and custom attributes for targeting rules and percentage rollouts:
var context = new EvaluationContext{ UserId = "u-123", Email = "user@example.com", Country = "US",};
// Add custom attributescontext.Set("plan", "pro");context.Set("team_size", 50);Built-in properties
Section titled “Built-in properties”| Property | Type | Rule attribute name | Description |
|---|---|---|---|
UserId | string? | user_id or userId | Unique user identifier, used for percentage rollouts |
Email | string? | email | User email address |
Country | string? | country | User country code |
A targeting rule or a rollout’s Bucket by reads a built-in property only under the exact attribute name in the table. Any other spelling, such as UserId or Email, is a custom attribute and is missing unless you set it with Set().
Custom attributes
Section titled “Custom attributes”Use Set() to add custom key-value attributes. Use GetAttribute() to retrieve any attribute. UserId takes precedence over a custom attribute of the same name, and a custom attribute takes precedence over Email and Country. Custom attribute keys are case-sensitive, so a targeting rule on plan does not read an attribute set as Plan:
context.Set("plan", "enterprise");object? plan = context.GetAttribute("plan"); // "enterprise"Reacting to Flag Changes
Section titled “Reacting to Flag Changes”FlagsChanged
Section titled “FlagsChanged”Raised when flag configuration changes, carrying the keys of the affected flags. Use it to react to a change instead of polling a value — invalidating a cache, recomputing derived state, or logging a rollout.
client.FlagsChanged += (sender, e) =>{ Console.WriteLine($"flags changed: {string.Join(", ", e.ChangedKeys)}"); cache.Invalidate(e.ChangedKeys);};A flag is reported when it is created, deleted, redefined, when a segment its targeting rules reference changes, or when a flag it lists as a prerequisite changes — the last resolved transitively, since a prerequisite toggle changes what its dependents evaluate to without bumping their own version.
Updates are batched: one raise per update carrying every affected key, so a poll that changes 40 flags raises once. The event never fires for the initial flag load — a cold load is not a configuration change, so use WaitForInitializationAsync() for that — and never with an empty collection.
ChangedKeys is unordered and duplicate-free; do not depend on its ordering.
Signature:
event EventHandler<FlagsChangedEventArgs> FlagsChangedHandler contract
Section titled “Handler contract”Handlers run synchronously on an SDK background thread, never on the caller’s SynchronizationContext. Two consequences worth planning for:
- Keep handlers fast. Delivery runs inline on the thread that received the update, so a slow handler delays subsequent flag delivery and, on the streaming path, can stall long enough to trip the idle-connection watchdog into a reconnect. Hand slow work to a queue or
Task.Run. - UI apps must marshal. A WPF or WinForms handler that touches UI-owned objects directly will throw
InvalidOperationException. Marshal back through the dispatcher first.
A throwing handler is isolated: it neither stops the remaining handlers nor disturbs the polling and streaming loops, and the throw is logged at warning level.
Unsubscribe with -=. Registering the same handler delegate twice via += is a no-op — unlike standard multicast-delegate semantics, it still runs at most once per update and the first -= removes it. When several client handles share one underlying connection, each handle’s subscriptions are independent, so disposing one handle never revokes another’s handlers. Subscribing after disposal is a silent no-op rather than an ObjectDisposedException.
Cleanup
Section titled “Cleanup”The client implements IDisposable. Always dispose of the client when it is no longer needed to flush events and release resources.
Dispose()
Section titled “Dispose()”// Option 1: using declaration (recommended)using var client = FeatureflipClient.Get("sdk-key");
// Option 2: using blockusing (var client = FeatureflipClient.Get("sdk-key")){ // ...}
// Option 3: explicit disposalclient.Dispose();Flush()
Section titled “Flush()”Forces pending events to be sent to the server synchronously. Blocks for up to 5 seconds.
client.Flush();FlushAsync()
Section titled “FlushAsync()”Asynchronous version of Flush():
await client.FlushAsync(cancellationToken);Signature:
Task FlushAsync(CancellationToken cancellationToken = default)Dependency Injection
Section titled “Dependency Injection”The Featureflip.Client.Extensions.DependencyInjection package provides extension methods for registering the client with the ASP.NET Core DI container.
Register with SDK key
Section titled “Register with SDK key”builder.Services.AddFeatureflip("sdk-dev-abc123", options =>{ options.BaseUrl = "https://eval.featureflip.io"; options.Streaming = true; options.WaitForInitialization = true;});Register from configuration
Section titled “Register from configuration”builder.Services.AddFeatureflip( builder.Configuration.GetSection("Featureflip"));With appsettings.json:
{ "Featureflip": { "SdkKey": "sdk-dev-abc123", "BaseUrl": "https://eval.featureflip.io", "Streaming": true }}Register with options action
Section titled “Register with options action”builder.Services.AddFeatureflip(options =>{ options.SdkKey = "sdk-dev-abc123"; options.BaseUrl = "https://eval.featureflip.io"; options.WaitForInitialization = true;});Inject into services
Section titled “Inject into services”The client is registered as IFeatureflipClient (singleton):
public class MyService{ private readonly IFeatureflipClient _flags;
public MyService(IFeatureflipClient flags) { _flags = flags; }
public void DoWork() { var context = new EvaluationContext { UserId = "u-123" }; if (_flags.BoolVariation("new-feature", context, false)) { // feature is enabled } }}IFeatureflipClient
Section titled “IFeatureflipClient”The main interface for flag evaluation. Extends IDisposable.
public interface IFeatureflipClient : IDisposable{ event EventHandler<FlagsChangedEventArgs> FlagsChanged; bool IsInitialized { get; } T Variation<T>(string key, EvaluationContext context, T defaultValue); EvaluationDetail<T> VariationDetail<T>(string key, EvaluationContext context, T defaultValue); bool BoolVariation(string key, EvaluationContext context, bool defaultValue); string StringVariation(string key, EvaluationContext context, string defaultValue); int IntVariation(string key, EvaluationContext context, int defaultValue); double DoubleVariation(string key, EvaluationContext context, double defaultValue); T JsonVariation<T>(string key, EvaluationContext context, T defaultValue); void Flush(); Task FlushAsync(CancellationToken cancellationToken = default); Task WaitForInitializationAsync(CancellationToken cancellationToken = default);}FlagsChangedEventArgs
Section titled “FlagsChangedEventArgs”Payload for FlagsChanged.
public sealed class FlagsChangedEventArgs : EventArgs{ public IReadOnlyCollection<string> ChangedKeys { get; }}FeatureFlagInitializationException
Section titled “FeatureFlagInitializationException”Thrown when the client fails to initialize. This can occur when:
- The SDK key is missing or invalid
- The initial flag fetch times out (and
StartOfflineisfalse) - A network error prevents flag loading (and
StartOfflineisfalse)
See also
Section titled “See also”- C# Quickstart — get started in under 5 minutes
- Targeting & Segments — control which users see which variation
- Rollout Strategies — gradual percentage-based rollouts
- Evaluation API — REST API reference
- SDK Overview — compare server-side and client-side SDKs