Command Line
.NET

Beta Versions

Certain WorkOS features may be available only in the beta version of the SDK. Beta versions have the -beta.* suffix, for example, 3.2.0-beta.1. For more information on how to use beta versions, refer to the README in the GitHub repository.

Packagist Version CI

The WorkOS library for .NET provides convenient access to the WorkOS API from applications using .NET. Supports .NET 8+

See the API Reference for .NET usage examples.

There are several options to install the WorkOS .NET SDK.

Via the NuGet Package Manager

nuget install WorkOS.net

Via the .NET CLI

dotnet add package WorkOS.net

Via the Package Manager Console

Install-Package WorkOS.net

To use the WorkOS client, you must provide an API key from the WorkOS dashboard.

WorkOSConfiguration.SetApiKey("sk_key123");

You can also optionally provide a custom HttpClient.

var client = new WorkOSClient(
    new WorkOSOptions
    {
        ApiKey = "sk_key123",
        HttpClient = ...,
    });
WorkOSConfiguration.WorkOSClient = client;

Client ID

SSO and User Management endpoints that accept a client_id parameter (for example, UserManagement.AuthenticateWithPassword and SSO.GetAuthorizationUrl) require the WorkOS Client ID. Provide it via WorkOSOptions.ClientId:

var client = new WorkOSClient(
    new WorkOSOptions
    {
        ApiKey = "sk_key123",
        ClientId = "client_01H...",
    });
WorkOSConfiguration.WorkOSClient = client;

Operations that require a Client ID throw InvalidOperationException if one was not configured, instead of silently sending an empty string to the API.

Most authentication flows start by redirecting the end-user to a WorkOS-hosted URL that the SDK builds for you. WorkOSClient exposes URL builders for AuthKit, SSO, and logout. These helpers build URLs locally and do not make a network call.

AuthKit

var url = WorkOSConfiguration.WorkOSClient.UserManagement.GetAuthorizationUrl(
    new UserManagementGetAuthorizationUrlOptions
    {
        RedirectUri = "https://example.com/callback",
        Provider = UserManagementAuthenticationProvider.Authkit,
        State = csrfToken,
    });

return Redirect(url);

If you are building a browser, mobile, CLI, or desktop app that should not hold an API key, use PublicWorkOSClient instead. It accepts a client_id only and exposes only public-safe URL builders:

var publicClient = new PublicWorkOSClient(
    new PublicWorkOSOptions { ClientId = "client_01H..." });

var url = publicClient.GetAuthorizationUrl(
    new AuthKitAuthorizationUrlOptions
    {
        RedirectUri = "myapp://callback",
        State = csrfToken,
    });

AuthKit with PKCE

For any client that cannot safely hold an API key, use PKCE. The SDK generates and returns the code_verifier alongside the URL. Store that verifier in your app’s session so you can pass it back on the token exchange step.

var result = publicClient.GetAuthorizationUrlWithPkce(
    new AuthKitAuthorizationUrlOptions
    {
        RedirectUri = "myapp://callback",
    });

HttpContext.Session.SetString("pkce_verifier", result.CodeVerifier);
return Redirect(result.Url);

SSO

For integrations where you already know the connection, organization, or provider, use the SSO builder:

var url = WorkOSConfiguration.WorkOSClient.SSO.GetAuthorizationUrl(
    new SSOGetAuthorizationUrlOptions
    {
        RedirectUri = "https://example.com/callback",
        Connection = "conn_01H...",
        State = csrfToken,
    });

PublicWorkOSClient also exposes GetSSOAuthorizationUrl and GetSSOAuthorizationUrlWithPkce for public clients.

Completing the login

After the user returns to your redirect URI, exchange the code for a user and session:

var authResponse = await WorkOSConfiguration.WorkOSClient.UserManagement
    .AuthenticateWithCodeAsync(
        new AuthenticateWithCodeOptions
        {
            Code = Request.Query["code"],
            // If you used PKCE, include the verifier you saved earlier.
            CodeVerifier = HttpContext.Session.GetString("pkce_verifier"),
        });

var user = authResponse.User;

Ending a WorkOS session can also require signing the user out of the upstream identity provider. Use GetLogoutUrl to build the redirect URL, then send the browser to it:

var logoutUrl = WorkOSConfiguration.WorkOSClient.UserManagement.GetLogoutUrl(
    new UserManagementGetLogoutUrlOptions
    {
        SessionId = sessionId,
        ReturnTo = "https://example.com/",
    });

Response.Cookies.Delete("wos_session");
return Redirect(logoutUrl);

If you manage sessions outside AuthKit, the same helper is available on PublicWorkOSClient:

var logoutUrl = publicClient.GetLogoutUrl(sessionId, returnTo: "https://example.com/");

The SDK ships JSON metadata for both Newtonsoft.Json (used by the runtime to talk to the WorkOS API) and System.Text.Json (STJ). All generated DTOs, enums, OneOf<T...> values, and webhook envelopes work under either serializer. Generated types rely on snake_case naming conventions rather than per-property attributes, so configure the serializer accordingly:

// Newtonsoft.Json
var newtonsoftSettings = new JsonSerializerSettings
{
    ContractResolver = new DefaultContractResolver
    {
        NamingStrategy = new SnakeCaseNamingStrategy(),
    },
};
var newtonsoft = JsonConvert.DeserializeObject<Organization>(json, newtonsoftSettings);

// System.Text.Json
var stjOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    Converters = { new WorkOSStringEnumConverterFactory() },
};
var stj = System.Text.Json.JsonSerializer.Deserialize<Organization>(json, stjOptions);

[!NOTE] The polymorphic event envelope discriminators (for example EventSchema) are currently wired up for Newtonsoft.Json only; deserializing event envelopes into their variant subclasses requires Newtonsoft.

Enum forward compatibility is identical on both stacks: an enum value the SDK hasn’t seen before deserializes to the type’s Unknown member instead of throwing.

Most services on WorkOSClient are generated from the WorkOS OpenAPI specification (Organizations, UserManagement, DirectorySync, SSO, AuditLogs, Events, Webhooks, Vault, etc.). In addition, the SDK ships a handful of hand-maintained services that are not generated from the OpenAPI specification:

Service Purpose
client.Passwordless Magic-link / passwordless session helpers.
client.Actions AuthKit Actions signing-secret verification and payload signing.
client.Session Sealed-session management, cookie helpers, and JWT validation.

The Vault service is generated, but its local envelope-encryption helpers (Vault.EncryptAsync / Vault.DecryptAsync, which perform AES-256-GCM locally) are hand-maintained additions.

These services are fully supported and are accessed the same way as generated services:

var payload = client.Actions.ConstructAction(rawBody, signatureHeader, signingSecret);
var session = await client.Session.AuthenticateAsync(cookieValue, cookiePassword);

The runtime client refreshes one bulk configuration for the API key’s environment and evaluates boolean flags locally. It does not call the API during evaluation. Use one long-lived instance per environment, shared across application requests. Runtime code is hand-maintained separately from generated REST services.

using var client = new WorkOSClient(new WorkOSOptions { ApiKey = "sk_..." });
await using var flags = client.CreateFeatureFlagsRuntimeClient(
    new FeatureFlagsRuntimeOptions
    {
        PollingInterval = TimeSpan.FromSeconds(60),
        RequestTimeout = TimeSpan.FromSeconds(10),
    });

// Bound startup waiting. Cancellation cancels this wait, not background polling.
using var startup = new CancellationTokenSource(TimeSpan.FromSeconds(15));
await flags.StartAsync(startup.Token);

var context = new FeatureFlagContext(
    organizationId: "org_acme",
    userId: "user_jp");
bool enabled = flags.IsEnabled("new-checkout", context, defaultValue: false);
IReadOnlyDictionary<string, bool> allFlags = flags.GetAllFlags(context);

StartAsync starts polling once and waits for a validated configuration. Without one, IsEnabled returns its caller-provided fallback (false by default) and GetAllFlags returns an empty map. WaitUntilReadyAsync waits without starting polling. RefreshAsync explicitly refreshes and propagates any error to its caller; manual and background refreshes cannot overlap.

The runtime requests V2 configuration through the SDK transport, reusing its API key, custom base URL, HTTP client and retry configuration. The refresh timeout includes time queued behind another refresh, transport retries and reading the response. A refresh that times out while queued sends no request and leaves the active refresh’s status unchanged. Like the Node runtime client, polling defaults to 30 seconds, clamps configured intervals to at least 5 seconds, and applies 10% jitter in either direction. Failed polls use exponential backoff starting at one second and capped at 60 seconds by default, before jitter; the base delay is never shorter than the polling interval. Jittered delays have a one-second floor. Request timeouts and backoff limits must be at least one millisecond. Supply TimeProvider to control polling delays and refresh deadlines in tests. Failures retain the entire last successful snapshot. A successful empty configuration removes previously cached flags. HTTP 401/403 stops background polling permanently: Status.IsPollingStopped becomes true and is reported to any Progress observer, while the last good snapshot stays usable. Alert on it, then correct credentials/access and create a new runtime.

Evaluation follows the server’s ordered rule semantics: disabled flags serve off_value; for enabled flags, the first matching rule wins, otherwise default_value applies. All conditions in a rule must match. Exact IDs are case-sensitive. Empty conditions and unknown rule kinds/operators never match. Malformed responses or unsupported payload versions fail the entire refresh instead of partially replacing a good cache. A flag whose served value is not a boolean, such as a future multivariate variant, has no answer: IsEnabled returns its caller-provided fallback and GetAllFlags omits it, while other flags evaluate normally.

The context describes the subjects for this evaluation. The runtime does not look up organization memberships for a user; pass the relevant organization ID yourself. Custom targets can be passed as customTargets: new Dictionary<string, string> { ["project"] = "project_123" }. Contexts and configuration snapshots are immutable, and local evaluation supports concurrent readers during refreshes.

To see why a flag evaluates the way it does, flags.GetFlag("new-checkout") (or TryGetFlag on a FeatureFlagsConfiguration) returns the flag’s configuration as received: enabled state, default and off values, and its rules in order with their conditions. Rule kinds and operators this SDK does not evaluate are shown as received and never match.

Bootstrap and observability

Persist flags.Configuration?.ToJson() in your own storage and restore it at startup, scoped to the same environment/API key. A bootstrap snapshot is immediately usable before polling succeeds:

string cachedJson = await LoadCachedConfigurationAsync(); // Your storage.
await using var flags = client.CreateFeatureFlagsRuntimeClient(
    new FeatureFlagsRuntimeOptions
    {
        BootstrapConfiguration = FeatureFlagsConfiguration.FromJson(cachedJson),
    });
await flags.StartAsync(); // Immediately ready; polls in the background.

To keep that cache fresh, persist each new snapshot from ConfigurationChanged. It is raised after a successful refresh installs a configuration that differs from the previous one, including the first load and the first refresh after an outdated bootstrap; identical refreshes and failures raise nothing. ChangedFlags lists the added, removed and modified slugs.

Save from a single background loop so an older snapshot can never finish saving after a newer one. The handler only hands off the latest JSON, and a burst of changes coalesces into one save. Subscribe before StartAsync:

var pending = Channel.CreateBounded<string>(
    new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
flags.ConfigurationChanged += (_, change) => pending.Writer.TryWrite(change.Current.ToJson());

var saving = Task.Run(async () =>
{
    await foreach (var json in pending.Reader.ReadAllAsync())
    {
        try
        {
            await SaveCachedConfigurationAsync(json); // Your storage.
        }
        catch (Exception exception)
        {
            LogSaveFailure(exception); // Your logging; the next change saves again.
        }
    }
});

await flags.StartAsync();
// On shutdown: pending.Writer.Complete(); await saving;

Handlers run one at a time in refresh order on the thread pool, never during a refresh, and their exceptions cannot stop polling. Like Progress reports, events may arrive after disposal.

Bootstrap JSON must be the complete versioned runtime configuration, not a /flag_targets response. Invalid bootstrap JSON throws before runtime creation; handle it as a cache miss. The runtime deliberately does not own distributed cache storage or impose an expiry policy. Monitor configuration freshness for your application’s requirements.

Status gives one immutable view of readiness, the current snapshot, LastSuccessfulRefresh and LastError. Bootstrap-only readiness has no successful network-refresh timestamp. RefreshCount, FailedRefreshCount and LastRefreshAttempt cover every finished refresh, polled or manual; refreshes canceled by their caller or by disposal are not counted. An optional IProgress<FeatureFlagsRuntimeStatus> observer receives refresh results independently of polling. The runtime calls Report one at a time in refresh order, and reports may arrive after disposal. Implement IProgress<T> directly to handle reports in that order; Progress<T> posts each callback to its captured synchronization context, or to the thread pool without one, so those callbacks can overlap or complete out of order. Observer exceptions do not interrupt polling.

Dispose the runtime asynchronously before its WorkOSClient, with await using or by awaiting DisposeAsync(). DisposeAsync cancels and joins background polling and all active or queued manual refreshes; the runtime implements IAsyncDisposable only so shutdown does not block a calling synchronization context. It does not dispose the supplied SDK client or a caller-owned HttpClient. After disposal, IsEnabled returns its fallback and GetAllFlags returns an empty map, so a late evaluation during shutdown cannot fail a request; Status and Configuration remain readable for persisting the last snapshot. Lifecycle methods throw ObjectDisposedException.

Every REST endpoint in the WorkOS OpenAPI spec has a typed wrapper on one of the services under WorkOSClient, and you should usually prefer those. When you need to call an endpoint the SDK does not yet expose, you can drop down to WorkOSClient.MakeAPIRequest<T> or WorkOSClient.MakeRawAPIRequest.

Both methods take a WorkOSRequest:

Property Purpose
Method HttpMethod.Get, HttpMethod.Post, etc.
Path Absolute path starting with /, for example /organizations.
Options Any BaseOptions subclass. The SDK serializes it as query params for GET/DELETE requests or as a JSON body for mutating requests.
AccessToken Optional bearer token to send instead of the API key.
WorkOSHeaders Optional WorkOS-specific headers to include on the request.
RequestOptions Per-call overrides such as ApiKey, IdempotencyKey, or MaxRetries.

BaseOptions is a marker base class. For direct calls, you define your own subclass with the fields you want to serialize.

Typed response

public class CustomListOptions : BaseOptions
{
    public string? OrganizationId { get; set; }

    public int? Limit { get; set; }
}

var request = new WorkOSRequest
{
    Method = HttpMethod.Get,
    Path = "/some/new/endpoint",
    Options = new CustomListOptions
    {
        OrganizationId = "org_01H...",
        Limit = 50,
    },
};

var response = await WorkOSConfiguration.WorkOSClient
    .MakeAPIRequest<MyCustomResponse>(request);

Raw response

If you need access to headers, status code, or a non-JSON body, use the raw variant and handle the HttpResponseMessage yourself:

using var response = await WorkOSConfiguration.WorkOSClient
    .MakeRawAPIRequest(request);

response.EnsureSuccessStatusCode();
var retryAfter = response.Headers.RetryAfter;
var body = await response.Content.ReadAsStringAsync();

Per-request overrides

Generated service methods and raw requests both accept RequestOptions:

var org = await WorkOSConfiguration.WorkOSClient.Organizations.CreateAsync(
    new OrganizationsCreateOptions { Name = "Acme" },
    new RequestOptions
    {
        MaxRetries = 0,
    });

The SDK automatically adds an Idempotency-Key header to POST requests when one is not provided explicitly via RequestOptions.IdempotencyKey.

[!NOTE] The WorkOS API currently honors Idempotency-Key only on the Create Audit Log Event endpoint (AuditLogs.CreateEventAsync). Other endpoints accept the header but do not deduplicate requests, so a retried mutation elsewhere can still create a duplicate.

All non-2xx responses from the WorkOS API raise subclasses of WorkOS.ApiException. The SDK maps well-known status codes to specific exception types so you can catch exactly what you care about:

HTTP status Exception type
401 AuthenticationException
404 NotFoundException
422 UnprocessableEntityException
429 RateLimitExceededException
5xx ServerException
other ApiException

Every exception exposes a StatusCode property and carries the raw response body in Exception.Message.

try
{
    var org = await client.Organizations.GetAsync("org_01H...");
}
catch (NotFoundException)
{
    // organization does not exist
}
catch (RateLimitExceededException)
{
    // back off per Retry-After and retry
}
catch (ApiException ex)
{
    Console.Error.WriteLine($"WorkOS API error {(int)ex.StatusCode}: {ex.Message}");
    throw;
}

Retry behavior

The SDK automatically retries requests that fail with a 429 (rate limit) or 5xx (server error) response, a network error, or a timeout. Retries use exponential backoff with full jitter and honor the Retry-After header when present. By default the SDK retries up to 2 times; you can change this via WorkOSOptions.MaxRetries or disable retries entirely by setting it to 0.

Running the test suite

Run all tests with the following command:

dotnet test test/WorkOSTests/WorkOSTests.csproj

Run tests for a specific framework with the following command:

dotnet test test/WorkOSTests/WorkOSTests.csproj -f net8.0

Mocking the SDK in your own tests

The SDK follows the Azure SDK mocking guidelines:

  • Every generated service class (UserManagementService, SSOService, OrganizationsService, DirectorySyncService, etc.) exposes a parameterless constructor. (The hand-maintained SessionService requires a WorkOSClient.)
  • Service accessors on WorkOSClient are declared virtual, and generated service methods are virtual where the SDK surface permits overriding.

That lets mocking libraries such as Moq or NSubstitute substitute the SDK without requiring custom interfaces.

using Moq;
using WorkOS;

var userManagement = new Mock<UserManagementService>();
userManagement
    .Setup(s => s.GetAsync(
        It.IsAny<string>(),
        It.IsAny<RequestOptions>(),
        It.IsAny<CancellationToken>()))
    .ReturnsAsync(new User { Id = "user_123", Email = "marceline@example.com" });

var client = new Mock<WorkOSClient>(new WorkOSOptions { ApiKey = "sk_test" });
client.Setup(c => c.UserManagement).Returns(userManagement.Object);

var sut = new UserController(client.Object);

Prefer constructor-injecting WorkOSClient into your own classes rather than reaching for WorkOSConfiguration.WorkOSClient directly. That keeps mocked clients isolated to the test under test.

For our SDKs WorkOS follows a Semantic Versioning process where all releases will have a version X.Y.Z (like 1.0.0) pattern wherein Z would be a bug fix (I.e. 1.0.1), Y would be a minor release (1.1.0) and X would be a major release (2.0.0). We permit any breaking changes to only be released in major versions and strongly recommend reading changelogs before making any major version upgrades.

WorkOS has features in Beta that can be accessed via Beta releases. We would love for you to try these and share feedback with us before these features reach general availability (GA). To install a Beta version, please follow the installation steps above using the Beta release version.

Note: there can be breaking changes between Beta versions. Therefore, we recommend pinning the package version to a specific version. This way you can install the same version each time without breaking changes unless you are intentionally looking for the latest Beta version.

We highly recommend keeping an eye on when the Beta feature you are interested in goes from Beta to stable so that you can move to using the stable version.