Most .NET teams reach for a heavyweight SDK when they need LLM access, but a bare c# httpclient openai api call is often all you need for production control. This tutorial builds a minimal, resilient client from scratch—no NuGet packages beyond the framework itself.
Prerequisites
- .NET 6 SDK or newer (tested on .NET 8).
- An OpenAI API key (or a key for any OpenAI-compatible endpoint).
- Comfort with
async/awaitand basic JSON.
You do not need the OpenAI NuGet package. We use System.Net.Http and System.Text.Json, both in the shared framework.
Project Setup
Create a console app and trim the boilerplate:
dotnet new console -o OpenAiRaw
cd OpenAiRaw
Replace Program.cs with a skeleton that reads the key from an environment variable:
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY")
?? throw new InvalidOperationException("Set OPENAI_API_KEY");
var client = new HttpClient
{
BaseAddress = new Uri("https://api.openai.com/v1/")
};
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
Console.WriteLine("Client ready");
Run dotnet run. Expected output:
Client ready
Building the Request Payload
OpenAI’s chat endpoint expects a JSON body with model and messages. Define records to avoid stringly-typed code:
record ChatMessage(string Role, string Content);
record ChatRequest(string Model, ChatMessage[] Messages);
A minimal call sends a single user message:
var request = new ChatRequest(
Model: "gpt-3.5-turbo",
Messages: new[] { new ChatMessage("user", "Say hello in 5 words.") });
var json = JsonSerializer.Serialize(request);
var content = new StringContent(json, Encoding.UTF8, "application/json");
Sending Your First Chat Request
Post to chat/completions and inspect the raw response:
var response = await client.PostAsync("chat/completions", content);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync();
Console.WriteLine(responseJson);
Expected output (trimmed):
{
"id":"chatcmpl-...",
"object":"chat.completion",
"choices":[
{"index":0,"message":{"role":"assistant","content":"Hello! Hope you're well today."},"finish_reason":"stop"}
],
"usage":{"prompt_tokens":12,"completion_tokens":6,"total_tokens":18}
}
Parse it into typed shapes so the rest of your app stays clean:
record Choice(ChatMessage Message, string FinishReason);
record ChatResponse(string Id, Choice[] Choices);
var parsed = JsonSerializer.Deserialize<ChatResponse>(responseJson);
Console.WriteLine(parsed!.Choices[0].Message.Content);
Error Handling and Status Codes
A c# httpclient openai api integration fails in predictable ways: 401 for bad keys, 429 for rate limits, 5xx for upstream issues. Do not swallow EnsureSuccessStatusCode blindly. Read the error body:
if (!response.IsSuccessStatusCode)
{
var errBody = await response.Content.ReadAsStringAsync();
// OpenAI returns { "error": { "message": "...", "type": "..." } }
throw new ApplicationException($"API {response.StatusCode}: {errBody}");
}
For transient 429/5xx, wrap the call in a simple retry loop or use Polly if you already reference it. Set a explicit timeout:
client.Timeout = TimeSpan.FromSeconds(30);
Streaming Tokens with Server-Sent Events
Non-streaming calls block until the full completion finishes. For chat UIs, stream instead. Add Stream = true to the request:
record StreamRequest(string Model, ChatMessage[] Messages, bool Stream = true);
var streamReq = new StreamRequest("gpt-3.5-turbo",
new[] { new ChatMessage("user", "Count to 5 slowly.") });
var streamContent = new StringContent(
JsonSerializer.Serialize(streamReq), Encoding.UTF8, "application/json");
var streamResponse = await client.PostAsync("chat/completions", streamContent);
streamResponse.EnsureSuccessStatusCode();
Read the response stream line by line. OpenAI emits data: {json} chunks and a final data: [DONE].
using var stream = await streamResponse.Content.ReadAsStreamAsync();
using var reader = new StreamReader(stream);
while (!reader.EndOfStream)
{
var line = await reader.ReadLineAsync();
if (string.IsNullOrWhiteSpace(line)) continue;
if (!line.StartsWith("data:")) continue;
var payload = line["data:".Length..].Trim();
if (payload == "[DONE]") break;
var chunk = JsonSerializer.Deserialize<JsonElement>(payload);
var delta = chunk.GetProperty("choices")[0]
.GetProperty("delta").GetProperty("content").GetString();
if (delta is not null) Console.Write(delta);
}
Console.WriteLine();
Expected output prints tokens incrementally, e.g. 1... 2... 3... as they arrive.
Swapping Endpoints Without Rewriting Code
The request and response contracts above are stable across any OpenAI-compatible server. If you point BaseAddress at a gateway, the same c# httpclient openai api code works unchanged. For instance, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and applies automatic fallback when a provider is degraded, so you can repoint BaseAddress to https://api.n4n.ai/v1/ and keep the serialization logic intact.
Production Considerations
- HttpClient lifetime: In ASP.NET, inject
IHttpClientFactoryinstead of a static instance to avoid socket exhaustion. - Cancellation: Pass a
CancellationTokenfromPostAsyncto respect request timeouts and user navigation. - Model pinning: Store
modelin config, not code. The same payload works forgpt-4o,claude-3-*via a gateway, etc. - Logging: Log
response.Headers.GetValues("x-request-id")(OpenAI sends it) for support correlation.
A hardened factory registration looks like:
builder.Services.AddHttpClient("openai", c =>
{
c.BaseAddress = new Uri("https://api.openai.com/v1/");
c.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", builder.Configuration["OpenAiKey"]);
c.Timeout = TimeSpan.FromSeconds(30);
});
Then resolve IHttpClientFactory and create a client per logical call.
Wrap-Up
You now have a working c# httpclient openai api client that sends chat requests, parses typed responses, streams tokens, and handles errors—without external SDKs. That control matters when you need custom retries, metrics, or multi-provider routing. Keep the payload contracts in records, treat the network as hostile, and you can ship this into a service today.