Most .NET SDKs get the HTTP layer right. Where they fall short is fit: models that don't encode optionality in their types, error handling that hands back a status code and a string body to parse yourself, and patterns that sit beside the application instead of composing with it.
The new APIMatic C# SDK is a complete redesign focused on closing that gap. It targets netstandard2.0, so it runs on .NET Framework 4.6.1 through every .NET Core release and modern .NET, and it's authored entirely in C# 14. Models are immutable record types with type-level nullability. The client is flat and plugs into IHttpClientFactory and IOptions<T>. Errors are typed per endpoint, so the IDE can show you the shape before you write a catch clause.
What follows is the design behind those choices, with examples drawn from a real generated SDK.
Goals Behind the Rebuild
There's a meaningful difference between an SDK that works and one that works well within its ecosystem. The first gets the HTTP calls right. The second integrates naturally with dependency injection, plays well with existing error-handling patterns, uses the type system to make misuse hard, and stays useful as the application around it grows.
The rebuild stands on five principles, each a non-negotiable for a world-class C# SDK.
Handwritten and idiomatic
The code reads like a senior .NET engineer wrote it after studying the API, not a mechanical translation of a spec into C# syntax. It composes naturally with the rest of the .NET ecosystem: async patterns that chain cleanly, construction that fits the DI model developers already use.
Modern
The previous generation was authored in C# 7.3. This one is C# 14 throughout. Developers working in modern .NET shouldn't have to step backward when they reach for an API client.
Type-level fidelity
The model layer encodes optionality, identity, and shape in the type itself, not in convention or runtime checks. required properties are enforced at construction. Nullable annotations track absence at the boundary. Open-ended values preserve the original string alongside typed constants. A developer reading a model can see what must be supplied and what may be absent before writing a single call.
Reliable and testable
When things go wrong (a network hiccup, a rate limit, a 5xx), the SDK responds predictably and gives the caller enough information to act. Built-in resilience handles transient cases, and abstractions at the right boundaries let developers write unit tests without live credentials or network access.
Extensible and usable
Authentication strategies, retry policies, and custom middleware plug into the core without disrupting existing call sites. Adding functionality is additive. The endpoint structure and error model are learnable once and predictable everywhere.
A Model Layer That Means What It Says
The type on a property should tell you whether that property can be absent. That's the rule the SDK follows everywhere in its model layer, and it shapes every decision: which keyword to use, which type to reach for, whether an accessor gets a ? or doesn't.
Records with explicit contracts. Every API response model is an immutable record. Properties marked required are always present and enforced at construction. Properties typed with ? may be absent, and [JsonIgnore] ensures they're omitted from serialized output rather than written as explicit nulls.
The code throughout this post is from an SDK we generated for the Twilio API.
public record Channel
{
/// <summary>
/// The unique string that identifies the Channel resource.
/// </summary>
[JsonPropertyName("sid")]
public required string Sid { get; init; }
/// <summary>
/// The visibility of the channel. Can be <c>public</c> or <c>private</c>.
/// See <see cref="ChannelChannelType"/> for all typed constants.
/// </summary>
[JsonPropertyName("type")]
public required ChannelChannelType Type { get; init; }
/// <summary>
/// The human-readable name of the channel, up to 64 characters. Optional.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("friendly_name")]
public string? FriendlyName { get; init; }
/// <summary>
/// The date and time in GMT when the resource was created,
/// in <see href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601</see> format.
/// </summary>
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
[JsonPropertyName("date_created")]
public DateTimeOffset? DateCreated { get; init; }
}
Omitting Sid at construction is a compiler error. Accessing channel.FriendlyName without a null check is a compiler warning. Both checks fire before the application runs.
Every property also carries an XML doc comment with its description, value constraints, and a link to the API reference, all surfaced as IDE hover text.
StringEnum<T> for open-ended values. A closed enum breaks silently when the API introduces a new value the SDK doesn't know about. StringEnum<T> keeps typed constants for known values and preserves any unknown value the API returns in .Value rather than discarding it.
public sealed record ConversationState : StringEnum<ConversationState>
{
public static readonly ConversationState Initializing = new("initializing");
public static readonly ConversationState Active = new("active");
public static readonly ConversationState Inactive = new("inactive");
public static readonly ConversationState Closed = new("closed");
}
Comparison against typed constants works like regular equality. The else branch handles any state the API adds later, no SDK update required.
if (conversation.State == ConversationState.Active)
Console.WriteLine("Conversation is active.");
else if (conversation.State == ConversationState.Closed)
Console.WriteLine("Conversation is closed.");
else
Console.WriteLine($"Unrecognized state: {conversation.State?.Value}");
Union types for polymorphic fields. When an API field accepts multiple schema shapes (OpenAPI oneOf), the SDK models it as a discriminated union. Implicit conversion operators let either variant be assigned directly, and TryGet* methods expose which shape is active.
public record InvestmentAccountWithAllDetails
{
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly Optional<UsbankInvestment> _usbankInvestmentValue;
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private readonly Optional<CitiBankInvestment> _citiBankInvestmentValue;
private InvestmentAccountWithAllDetails(
Optional<UsbankInvestment> usbankInvestmentValue,
Optional<CitiBankInvestment> citiBankInvestmentValue)
{
_usbankInvestmentValue = usbankInvestmentValue;
_citiBankInvestmentValue = citiBankInvestmentValue;
}
public static InvestmentAccountWithAllDetails UsbankInvestment(UsbankInvestment value) =>
new(Optional<UsbankInvestment>.Some(value), default);
public static InvestmentAccountWithAllDetails CitiBankInvestment(CitiBankInvestment value) =>
new(default, Optional<CitiBankInvestment>.Some(value));
public bool TryGetUsbankInvestment(out UsbankInvestment value) =>
_usbankInvestmentValue.TryGetValue(out value);
public bool TryGetCitiBankInvestment(out CitiBankInvestment value) =>
_citiBankInvestmentValue.TryGetValue(out value);
public static implicit operator InvestmentAccountWithAllDetails(UsbankInvestment value) =>
UsbankInvestment(value);
public static implicit operator InvestmentAccountWithAllDetails(CitiBankInvestment value) =>
CitiBankInvestment(value);
}
// assign either variant without a factory call
InvestmentAccountWithAllDetails account = myUsbankInvestment;
// extract which shape is active
if (account.TryGetUsbankInvestment(out var usbank))
ProcessUsbank(usbank);
else if (account.TryGetCitiBankInvestment(out var citi))
ProcessCitiBank(citi);
No factory call needed to construct, no unchecked cast to decompose.
Error Handling Without Leaving the Editor
Most SDK error models give you a status code and a string body to parse yourself. This one generates a distinct error type per endpoint, derived from the API contract, and the IDE can show you its shape before you've written a catch clause.
The error type is endpoint-specific. Catching SdkException<CreateVerificationError> is precise: the type parameter identifies the endpoint, not a shared base class. Typing ex.Error. in the IDE reveals the complete set of TryGet* methods for this operation, each corresponding to an error the API contract defines for it.
try
{
var verification = await client.CreateVerification(serviceSid);
}
catch (SdkException<CreateVerificationError> ex)
{
if (ex.Error.TryGetRateLimitError(out var rateLimitError))
Console.WriteLine($"Rate limited (code {rateLimitError.Code}): {rateLimitError.Message}");
else if (ex.Error.TryGetRawError(out var raw))
Console.WriteLine($"Unexpected ({(int)raw.StatusCode}): {raw.ReadAsString()}");
}
The out parameter is a typed record. Code, Message, and MoreInfo (a link to the API reference for that error code) are all IDE-visible without opening a browser. TryGetRawError covers anything outside the contract; the RawError it yields exposes ReadAsString(), ReadAsBytes(), and ReadAsJson<T>() for direct response-body access.
The result counterpart puts the error type in the return signature. Every method has a *AsResult counterpart that returns ApiResult<TResponse, CreateVerificationError>. The error type is part of the return signature, visible at the call site before any handler is written.
var result = await client.CreateVerificationAsResult(serviceSid);
result.Match(
onSuccess: v => Console.WriteLine($"SID: {v.Sid}"),
onFailure: error =>
{
if (error.TryGetRateLimitError(out var rateLimited))
Console.WriteLine($"Rate limited (code {rateLimited.Code}): {rateLimited.Message}");
else if (error.TryGetRawError(out var raw))
Console.WriteLine($"({(int)raw.StatusCode}): {raw.ReadAsString()}");
}
);
Match guarantees one branch is always taken. var (isSuccess, response, error) = result works for code that prefers an imperative shape. Use whichever fits the call site.
Endpoints That Read Like HTTP
Every method on the client follows the same internal structure. Learn one, and the rest follow the same shape.
The implementation reads like the HTTP call it generates. Every Execute call maps its positional arguments to elements of the HTTP request: URL template, path substitution, query string, headers, method, body, success handler, error handler, auth scheme, cancellation.
_rawClient.Execute(
_server.MessagesApi("/v1/Conversations/{ConversationSid}/Messages"), // URL
[new TemplateParam("ConversationSid", conversationSid)], // path params
[new Param("Order", order), // query params
new Param("PageSize", pageSize),
new Param("Page", page),
new Param("PageToken", pageToken)],
[], // headers
HttpMethod.Get, // method
EmptyBody.Instance, // body
JsonResponse.Create<ListConversationMessageResponse>(), // success
RawErrorResponse.Instance, // error
[_accountSidAuthTokenScheme], // auth
ct); // cancellation
Nothing is implicit. Authentication schemes are configured once on the client and passed as a parameter at every call: uniform across endpoints, explicit at each site. Endpoints with distinct error shapes swap only the error handler. CreateVerification uses CreateVerificationErrorResponse.Instance instead of RawErrorResponse.Instance; everything else stays identical.
ApiResult<T, TError> for raw API access. Every method has a *AsResult counterpart returning ApiResult<T, RawError>, a discriminated union readonly struct that never throws. It carries StatusCode, Headers, and either the typed success response or the error body, resolved via Match.
// Execute — throws SdkException<TError> on non-2xx
var messages = await client.ListConversationMessage(conversationSid, ...);
// ExecuteResult — returns ApiResult, never throws
var result = await client.ListConversationMessageAsResult(conversationSid, ...);
result.Match(
onSuccess: response => Console.WriteLine($"{response.Messages?.Count} messages"),
onFailure: error => Console.WriteLine($"HTTP {error.StatusCode}: {error.ReadAsString()}")
);
ApiResult is also deconstructable and exposes StatusCode and Headers on both branches. Use Execute for normal application flows; use ExecuteResult when status codes, headers, or explicit error body inspection matter.
Decoupled by Design: The Architecture Behind APIMatic's C# SDK Core
The Core module is what every method you've seen so far flows through. It carries the authentication, resilience, serialization, and error-handling infrastructure that every generated SDK depends on. APIMatic engineers and maintains it once. The same production-grade Core ships with every SDK, regardless of which API is being wrapped.
One entry point, one contract
RawClient is the single orchestrator every API call flows through. It takes fully typed inputs (URL template, parameters, headers, method, body, auth) and returns ApiResult<TResponse, TError>: a discriminated union holding either a typed success response or a typed error, resolved at compile time. No casting, no untyped bags, no exceptions as control flow.
The components RawClient delegates to each hold a single typed contract and share no state. Any of them can be replaced or tested in isolation.
Built for performance
URI resolution, header assembly, retry gating, and response classification are direct dispatch over compile-time-known types, no per-call reflection or dynamic dispatch on the request path. The resilience pipeline is constructed once at startup and reused across every call. Responses stream directly from the network into the deserializer, so large payloads are never fully buffered in memory. And because the result type is a readonly struct, wrapping a response adds no heap allocation.
Retries without ceremony
Every eligible request runs through a Polly resilience pipeline. Retry eligibility is decided by the request body itself: JSON and form-urlencoded bodies can be replayed; multipart uploads cannot. The defaults cover 429s, 5xx responses, and network errors with exponential backoff and jitter. Every option is configurable.
Modern C# 14 on netstandard2.0
There's a tradeoff most .NET library authors accept without questioning: broad runtime compatibility or modern language features, pick one. We didn't pick.
netstandard2.0, authored in C# 14. The project targets netstandard2.0, so it runs on .NET Framework 4.6.1 through every .NET Core release and .NET 5 through 10. LangVersion is set to 14, with PolySharp as the bridge: a build-time source generator that emits the runtime shims C# 9 through 14 features need directly into the compiled assembly.
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>14</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.0" />
<PackageReference Include="Polly" Version="8.6.5" />
<PackageReference Include="System.Net.Http.Json" Version="10.0.0" />
<PackageReference Include="PolySharp" Version="1.15.0">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
PolySharp produces no runtime package reference, adds no public API surface, and stays invisible to consumers. The full C# 14 feature set (record types, required, init-only properties, nullable reference types, extension blocks) is available throughout the SDK at the netstandard2.0 floor.
Extension blocks. C# 14 replaces the static class + this-parameter pattern with proper extension blocks that group static and instance methods on the same type. DateTimeOffsetExtensions uses two blocks, one for static factory methods and one for instance formatting: a separation the old pattern couldn't express cleanly.
internal static class DateTimeOffsetExtensions
{
extension(DateTimeOffset) // static extension
{
public static DateTimeOffset FromIso8601(string dateString) =>
DateTimeOffset.Parse(dateString, CultureInfo.InvariantCulture, Styles);
}
extension(DateTimeOffset dateTimeOffset) // instance-based extension
{
public string ToIso8601() =>
dateTimeOffset.ToUniversalTime().ToString(Iso8601Format, CultureInfo.InvariantCulture);
}
}
Switch expressions. Pattern matching collapses value dispatch to a single return expression. In ProcessMultipleError, routing a status code to the right typed deserializer is one line.
internal static Task<ProcessMultipleError> Create(HttpResponseMessage response, CancellationToken ct) =>
(int)response.StatusCode switch
{
400 => FromJson<AnyOfAccountantManager>(response, ct).As(AsAnyOfAccountantManager),
401 or 403 => FromJson<ProblemDetails>(response, ct).As(AsProblemDetails),
402 => FromRawBody(response, ct).As(AsNoContent),
500 => FromScalar(response, ct, s => s).As(AsString),
501 => FromScalar(response, ct, long.Parse).As(AsLong),
>= 400 and <= 409 => FromRawBody(response, ct).As(AsNoContent),
>= 400 and <= 499 => FromJson<ProblemDetails>(response, ct).As(AsProblemDetails),
_ => FromRawBody(response, ct).As(AsFallback)
};
public sealed class SdkException<TError> : Exception
{
public required TError Error { get; init; }
}
HttpClient is injected, not owned. The same ecosystem conventions extend to HTTP client management. The constructor accepts an HttpClient directly rather than creating or managing one internally.
public TwilioApisClient(HttpClient httpClient, TwilioApisClientOptions options)
The SDK already depends on Microsoft.Extensions.Http, so AddTwilioApisClient integrates with the standard DI stack: itself authored with the same C# 14 extension block syntax the rest of the library uses.
// appsettings.json — "TwilioApis": { "AccountSidAuthToken": { "Username": "...", "Password": "..." } }
services.AddTwilioApisClient(options =>
configuration.GetSection("TwilioApis").Bind(options));
🧪 Try the C# SDK beta
The C# SDK is in early beta. Generate one for your API, run it against your application stack, and tell us where the integration experience should get sharper — your feedback shapes what ships as stable. Get started with the APIMatic CLI or send feedback to support@apimatic.io.

