Skip to content

C# SDK Reference

TL;DR: Install Featureflip.Client, call client.BoolVariation("flag-key", context, false), and you’re evaluating flags — synchronous, no await in 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.

View source on GitHub

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.

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.

The SDK multi-targets the following frameworks:

TargetMinimum Version
.NET8.0
.NET10.0
.NET Standard2.0 (.NET Framework 4.6.1+, .NET Core 2.0+)
Terminal window
dotnet add package Featureflip.Client

For ASP.NET Core dependency injection support, also install the extensions package:

Terminal window
dotnet add package Featureflip.Client.Extensions.DependencyInjection

The FeatureFlagOptions class accepts the following options:

OptionTypeDefaultDescription
BaseUrlstring"https://eval.featureflip.io"Base URL of the Evaluation API
ConnectTimeoutTimeSpan5sTimeout for establishing connections
ReadTimeoutTimeSpan10sTimeout for reading responses
StreamingbooltrueEnable SSE for real-time flag updates
PollIntervalTimeSpan30sPolling interval (used when streaming is disabled)
FlushIntervalTimeSpan30sInterval between event flush batches
FlushBatchSizeint100Maximum number of events per flush batch
InitTimeoutTimeSpan10sMaximum time to wait for initial flag fetch
WaitForInitializationboolfalseBlock the constructor until flags are loaded
StartOfflineboolfalseStart the client even if initial flag loading fails
SnapshotStoreISnapshotStore?nullWhere the client saves its last configuration and starts from it when the first fetch fails. See Starting during an outage
MaxSnapshotAgeTimeSpan24hOldest snapshot the client will start from. Must be positive
WrapperNamestring?nullFor libraries that wrap the SDK: a name appended to the User-Agent after the SDK’s own token
WrapperVersionstring?nullVersion sent with WrapperName
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,
};

Create a client by providing an SDK key and optional options:

using var client = FeatureflipClient.Get(
"sdk-dev-abc123",
new FeatureFlagOptions { WaitForInitialization = true }
);

If sdkKey is null, the client falls back to the FEATUREFLIP_SDK_KEY environment variable. A FeatureFlagInitializationException is thrown if neither is available.

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 or InitTimeout expires.

The StartOffline option allows the client to start even if initialization fails. The client will return default values and retry in the background.

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.

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 IsInitialized is true and WaitForInitializationAsync() 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. StartupInfo says why. If you relied on catching FeatureFlagInitializationException to fail fast, check client.StartupInfo.Source instead, because with a snapshot store configured Get() 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 FlagsChanged at that moment run for what changed between the two. Initialization runs while Get() executes (or in the background when WaitForInitialization is false), so a handler you add after Get() returns can miss that first change. Read StartupInfo after 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 twice InitTimeout, 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 different SnapshotStore instance or a different MaxSnapshotAge. Leaving the options out does not warn.
  • MaxSnapshotAge must be positive. A zero or negative value throws FeatureFlagInitializationException from Get(), including a later Get() for a key that already has a client. There is no value that disables expiry.
  • With a SnapshotStore set, InitTimeout must also be positive, because it bounds the store read. A zero or negative value throws FeatureFlagInitializationException the 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:

docker-compose.yml
services:
api:
volumes:
- featureflip-snapshot:/var/lib/featureflip
volumes:
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)).

client.StartupInfo reports where the flag data came from. It returns an immutable StartupInfo, so read the property again to see a change:

PropertyValuesMeaning
SourceStartupSource.None, Snapshot, LiveLive: 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 nullSet 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
SnapshotAgeSecondslong?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);
}

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.

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.

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)
bool enabled = client.BoolVariation("dark-mode", context, false);
string plan = client.StringVariation("pricing-tier", context, "free");
int limit = client.IntVariation("rate-limit", context, 100);
double ratio = client.DoubleVariation("rollout-ratio", context, 0.5);

Deserializes a JSON flag value to the specified type:

var banner = client.JsonVariation<BannerConfig>(
"banner-config",
context,
new BannerConfig { Text = "Welcome", Color = "#000" }
);

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); // True
Console.WriteLine(detail.Reason); // RuleMatch
Console.WriteLine(detail.RuleId); // "rule-abc"
Console.WriteLine(detail.ErrorMessage); // null

Signature:

EvaluationDetail<T> VariationDetail<T>(string key, EvaluationContext context, T defaultValue)
PropertyTypeDescription
ValueTThe evaluated flag value
ReasonEvaluationReasonWhy this value was returned
RuleIdstring?The ID of the matched targeting rule
ErrorMessagestring?Error message if evaluation failed
ValueDescription
RuleMatchA targeting rule matched the context
FallthroughNo rules matched; the fallthrough variation was served
FlagDisabledThe flag is disabled; the off variation was served
FlagNotFoundThe flag key does not exist
ErrorAn error occurred during evaluation

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 attributes
context.Set("plan", "pro");
context.Set("team_size", 50);
PropertyTypeRule attribute nameDescription
UserIdstring?user_id or userIdUnique user identifier, used for percentage rollouts
Emailstring?emailUser email address
Countrystring?countryUser 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().

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"

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> FlagsChanged

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.

The client implements IDisposable. Always dispose of the client when it is no longer needed to flush events and release resources.

// Option 1: using declaration (recommended)
using var client = FeatureflipClient.Get("sdk-key");
// Option 2: using block
using (var client = FeatureflipClient.Get("sdk-key"))
{
// ...
}
// Option 3: explicit disposal
client.Dispose();

Forces pending events to be sent to the server synchronously. Blocks for up to 5 seconds.

client.Flush();

Asynchronous version of Flush():

await client.FlushAsync(cancellationToken);

Signature:

Task FlushAsync(CancellationToken cancellationToken = default)

The Featureflip.Client.Extensions.DependencyInjection package provides extension methods for registering the client with the ASP.NET Core DI container.

builder.Services.AddFeatureflip("sdk-dev-abc123", options =>
{
options.BaseUrl = "https://eval.featureflip.io";
options.Streaming = true;
options.WaitForInitialization = true;
});
builder.Services.AddFeatureflip(
builder.Configuration.GetSection("Featureflip")
);

With appsettings.json:

{
"Featureflip": {
"SdkKey": "sdk-dev-abc123",
"BaseUrl": "https://eval.featureflip.io",
"Streaming": true
}
}
builder.Services.AddFeatureflip(options =>
{
options.SdkKey = "sdk-dev-abc123";
options.BaseUrl = "https://eval.featureflip.io";
options.WaitForInitialization = true;
});

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
}
}
}

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);
}

Payload for FlagsChanged.

public sealed class FlagsChangedEventArgs : EventArgs
{
public IReadOnlyCollection<string> ChangedKeys { get; }
}

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 StartOffline is false)
  • A network error prevents flag loading (and StartOffline is false)