Claude API in C# and .NET: HttpClient, OpenAI SDK and ASP.NET Core

Connect Claude to a .NET application two ways: plain HttpClient and the official OpenAI package. Streaming, error handling, timeouts and a minimal API on ASP.NET Core.

4 min · · development

If you write C#, you can connect to the Claude API without a separate Anthropic SDK: Zukko accepts OpenAI-format requests at https://api.zukko.pro/v1, so both a plain HttpClient and the official OpenAI NuGet package work. Below are both options, streaming with correct handling of the last chunk, errors and timeouts, and finally a minimal API on ASP.NET Core. The examples target .NET 8 and newer.

Key and environment variables

A key of the form fetch_... is created in the Zukko dashboard. The full key is shown only once, so save it right away. Don't keep the key in code:

  • locally, use the ZUKKO_API_KEY environment variable or dotnet user-secrets set "Zukko:ApiKey" "fetch_...";
  • on a server, use an environment variable (for ASP.NET Core configuration the name is Zukko__ApiKey) or your platform's secret store.

Option 1: HttpClient and System.Text.Json

No third-party packages: HttpClient, System.Net.Http.Json and JsonDocument ship with .NET.

bash
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var apiKey = Environment.GetEnvironmentVariable("ZUKKO_API_KEY")
    ?? throw new InvalidOperationException("ZUKKO_API_KEY is not set");

using var http = new HttpClient { BaseAddress = new Uri("https://api.zukko.pro/v1/") };
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);

var request = new
{
    model = "claude-sonnet-5",
    max_tokens = 500,

Note the BaseAddress with a trailing slash and the relative path chat/completions without a leading slash: that way the final address is https://api.zukko.pro/v1/chat/completions. With /chat/completions, HttpClient would drop /v1.

In a real application, don't create an HttpClient per request: use a single instance or IHttpClientFactory.

Streaming with HttpClient

With "stream": true the response arrives as data: {...} lines in SSE format, and the last line is data: [DONE]. An important detail: before [DONE] the server sends a chunk with usage and an empty choices array. Code that immediately takes choices[0] crashes on it, so we check the array length:

bash
var streamRequest = new HttpRequestMessage(HttpMethod.Post, "chat/completions")
{
    Content = JsonContent.Create(new
    {
        model = "claude-sonnet-5",
        max_tokens = 500,
        stream = true,
        messages = new[] { new { role = "user", content = "Write a haiku about the garbage collector." } }
    })
};

using var streamResponse = await http.SendAsync(streamRequest, HttpCompletionOption.ResponseHeadersRead);
streamResponse.EnsureSuccessStatusCode();
using var reader = new StreamReader(await streamResponse.Content.ReadAsStreamAsync());

HttpCompletionOption.ResponseHeadersRead lets you read the stream immediately instead of waiting for the whole response to load into memory.

Option 2: the official OpenAI .NET SDK

bash
dotnet add package OpenAI

A custom API address is set via OpenAIClientOptions.Endpoint, as described in the "Using a custom base URL" section of the package README. ApiKeyCredential lives in the System.ClientModel namespace, which is easy to forget.

bash
using System.ClientModel;
using OpenAI;
using OpenAI.Chat;

var client = new ChatClient(
    model: "claude-sonnet-5",
    credential: new ApiKeyCredential(Environment.GetEnvironmentVariable("ZUKKO_API_KEY")!),
    options: new OpenAIClientOptions { Endpoint = new Uri("https://api.zukko.pro/v1") });

ChatCompletion completion = await client.CompleteChatAsync(
    new SystemChatMessage("Answer briefly."),
    new UserChatMessage("How does a record differ from a class in C#?"));

Console.WriteLine(completion.Content[0].Text);
Console.WriteLine($"Tokens: {completion.Usage.InputTokenCount} in, {completion.Usage.OutputTokenCount} out");

Streaming in the SDK

CompleteChatStreamingAsync returns AsyncCollectionResult<StreamingChatCompletionUpdate>, which you iterate with await foreach, without an extra await before the call:

bash
await foreach (StreamingChatCompletionUpdate update in client.CompleteChatStreamingAsync(
    new UserChatMessage("List the five SOLID principles, one line each.")))
{
    if (update.ContentUpdate.Count > 0)
    {
        Console.Write(update.ContentUpdate[0].Text);
    }
    if (update.Usage is not null)
    {
        Console.WriteLine($"\nTotal tokens: {update.Usage.TotalTokenCount}");
    }
}

The ContentUpdate.Count > 0 check is the guard against the final update without text: it only carries Usage.

Options, errors and timeouts

  • The answer limit is set with new ChatCompletionOptions { MaxOutputTokenCount = 800 } as the second argument together with the message list.
  • The SDK throws HTTP errors as ClientResultException; the status code is in the Status property. Zukko codes: 401 invalid key, 402 insufficient balance, 403 model not allowed for the key, 413 request too large, 429 too many requests, 503 provider unavailable.
  • According to the README, the SDK retries 408, 429, 500, 502, 503 and 504 up to three times with exponential backoff.
  • The network operation timeout is set with the NetworkTimeout property of OpenAIClientOptions (inherited from ClientPipelineOptions). For long answers increase it, for example to three minutes.

ASP.NET Core: a minimal API

According to the README, SDK clients are thread-safe and can be registered as singletons, which reuses HTTP connections. Create the project with dotnet new web, then dotnet add package OpenAI.

bash
using System.ClientModel;
using OpenAI;
using OpenAI.Chat;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton(_ => new ChatClient(
    model: builder.Configuration["Zukko:Model"] ?? "claude-sonnet-5",
    credential: new ApiKeyCredential(builder.Configuration["Zukko:ApiKey"]
        ?? throw new InvalidOperationException("Zukko:ApiKey is not set")),
    options: new OpenAIClientOptions
    {
        Endpoint = new Uri("https://api.zukko.pro/v1"),
        NetworkTimeout = TimeSpan.FromMinutes(3),

Test it: curl -X POST localhost:5000/ask -H "Content-Type: application/json" -d '{"question":"What is DI?"}' (check the port in the dotnet run output). The second route returns text as it is generated. If the frontend needs proper SSE, wrap each fragment in data: ...\n\n and escape line breaks inside the fragment, for example by serializing it to JSON.

Models and cost

The model is set by the model string (in the ASP.NET Core example, by the Zukko:Model setting). Zukko prices per 1M tokens, input/output:

ModelPriceContext
claude-sonnet-5$0.30 / $0.301M
claude-opus-5-5$0.30 / $0.301M
gpt-5.6-sol$0.30 / $0.301M
gpt-5.6-luna$0.10 / $0.101M
glm-5.3-flash$0.04 / $0.04128K

The current list is at GET https://api.zukko.pro/v1/models or on the "Models" page in the dashboard. Example calculation: the /ask endpoint handles 10,000 questions a month, averaging 400 input and 300 output tokens. On claude-sonnet-5: 10,000 × (400 + 300) × $0.30 / 1,000,000 = $2.10 a month. Test which model answers your users best on your own tasks: run the same set of real questions through two or three models and compare the answers.

FAQ

Why does the SDK return 404?

Almost always it's the address. For the OpenAI SDK, Endpoint must be https://api.zukko.pro/v1, with /v1. With HttpClient, check the trailing slash on BaseAddress and the absence of a leading slash on the relative path.

Do I need the Anthropic package to work with Claude?

No. The gateway accepts the OpenAI format for any model, including Claude, and converts the request itself. If you do need the Anthropic format (/v1/messages), see the Messages API reference; the difference between formats is covered in choosing a format.

How do I cap the application's spending?

Create a separate key for the application with daily and monthly dollar limits and a list of allowed models; see key limits. Also cap MaxOutputTokenCount in every request.

How do I pass conversation history?

Pass the whole message list: a SystemChatMessage, then alternating UserChatMessage and AssistantChatMessage. The API stores nothing between requests, so keep the history yourself and trim old turns: they are billed as input tokens on every request.

zukko

Pay 25×
less

for Claude Code & ChatGPT

One fetch_* key and base URL api.zukko.pro — for Cursor, Claude Code, Codex, and any SDK.

Get API keyDocumentation

api.zukko.pro · fetch_*

Claude API in C# and .NET: HttpClient and OpenAI SDK · Zukko