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.
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-Keyonly 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-maintainedSessionServicerequires aWorkOSClient.) - Service accessors on
WorkOSClientare declaredvirtual, 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.