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_KEYenvironment variable ordotnet 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.
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:
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
dotnet add package OpenAIA 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.
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:
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 theStatusproperty. 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
NetworkTimeoutproperty ofOpenAIClientOptions(inherited fromClientPipelineOptions). 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.
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:
| Model | Price | Context |
|---|---|---|
claude-sonnet-5 | $0.30 / $0.30 | 1M |
claude-opus-5-5 | $0.30 / $0.30 | 1M |
gpt-5.6-sol | $0.30 / $0.30 | 1M |
gpt-5.6-luna | $0.10 / $0.10 | 1M |
glm-5.3-flash | $0.04 / $0.04 | 128K |
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.
Related articles
- Prompt Injection: What It Is and How to Protect a Bot and an AgentHow prompt injection works through user input and third-party documents, and which measures limit the damage: data isolation, tool permissions, key limits.
- Your Own Neural Network Proxy on Node.js: Keep the API Key Off the FrontendA small Express server between your frontend and the model API: the key stays on the server, and models, request size and rate are capped.
- DeepSeek API in Python: Requests, Streaming, JSON and Error HandlingReady code for DeepSeek V4.1 Flash and V4 Pro on the OpenAI SDK: a request, streaming, JSON parsing and careful error handling.
- Prompt Engineering Examples: 12 Ready-Made Templates for WorkTwelve prompt templates for everyday tasks: what each one says, why it is built that way, and what a single API call costs.