.NETOpenAIC#Tutorial

How to Call the OpenAI API from C# (2026 Guide)

A step-by-step guide to calling the OpenAI API from C#: install the SDK, send your first chat completion, stream responses, and keep your API key safe in .NET.

How to Call the OpenAI API from C# (2026 Guide)

Calling the OpenAI API from C# takes about ten lines of code once you know which package to use. This guide walks through it from scratch — installing the SDK, sending your first chat completion, streaming the response, and (the part tutorials skip) keeping your API key out of your source code. By the end you’ll have a working C# console app talking to a model, and you’ll know which parts of that console app will fall over the moment real traffic hits it.

Which package to use

There are two solid choices in .NET, and they serve different needs:

  • OpenAI — the official OpenAI SDK for .NET. Use it when you’re talking specifically to OpenAI.
  • Microsoft.Extensions.AI — Microsoft’s provider-agnostic abstraction (IChatClient). Use it when you want to swap between OpenAI, Azure OpenAI, or other providers without rewriting your code. This is what the Microsoft Agent Framework builds on.

The trade-off is the usual one between a first-party client and an abstraction over it. The official SDK exposes everything OpenAI ships, on the day they ship it — audio, structured outputs, logprobs, service tiers. Microsoft.Extensions.AI exposes the intersection of what several providers support, which is smaller but portable, and gives you middleware hooks for caching, telemetry and function invocation that you’d otherwise write yourself.

A useful rule: if the model provider is a business decision you might revisit, code against IChatClient from day one, because retrofitting it across a codebase full of ChatClient references is genuinely tedious. If you are committed to OpenAI and want the newest surface area, use the official SDK. For a first call, start with the official SDK — the concepts transfer either way.

Step 1: Install and set your key

dotnet new console -n OpenAiDemo
cd OpenAiDemo
dotnet add package OpenAI

Never hard-code your API key. Put it in an environment variable (or user-secrets in development) so it never lands in source control:

setx OPENAI_API_KEY "sk-..."   # Windows; reopen the terminal afterwards

The “reopen the terminal afterwards” is not a footnote — setx writes to the registry and only new processes inherit it. Every developer hits this once: they set the key, run the app in the same shell, and get a null reference or a 401, then spend twenty minutes suspecting the SDK.

For anything beyond a scratch project, prefer .NET user-secrets over environment variables in development. Secrets live outside the repository in your user profile, they’re scoped per project rather than machine-wide, and they flow through IConfiguration exactly like the production secret store will, so your configuration code doesn’t change between environments.

Step 2: Your first chat completion

using OpenAI.Chat;

var client = new ChatClient(
    model: "gpt-4o-mini",
    apiKey: Environment.GetEnvironmentVariable("OPENAI_API_KEY"));

ChatCompletion completion = await client.CompleteChatAsync(
    "Explain what an API is, in one sentence.");

Console.WriteLine(completion.Content[0].Text);

Run it and you’ll get a one-sentence answer. That’s the whole round-trip: create a client, send a message, read the reply.

One detail worth internalising early: Content is a list of parts, not a string. For plain text replies there is one part and Content[0].Text is fine, but the shape exists because a response can carry other content kinds. Reaching for [0] without checking whether the list is empty is a small landmine — a response that finished as a refusal, or one that came back with tool calls instead of text, can leave you indexing into nothing.

Step 3: Add a system prompt and conversation

Real apps set behaviour with a system message and keep a conversation going by passing the message history:

List<ChatMessage> messages =
[
    new SystemChatMessage("You are a concise assistant that answers in bullet points."),
    new UserChatMessage("Give me three tips for writing clean C#."),
];

var completion = await client.CompleteChatAsync(messages);
Console.WriteLine(completion.Content[0].Text);

To continue the conversation, append the assistant’s reply and the next user message to messages and call again.

This is where the first real production surprise lives. The API is stateless: it remembers nothing between calls, so the entire conversation is re-sent and re-charged on every turn. A twenty-turn conversation doesn’t cost twenty times the first turn — the input grows on each turn, so total spend over the thread grows roughly with the square of its length. A long-running support chat that felt cheap in testing can quietly become the largest line on the bill.

The fix is not to stop keeping history, it’s to bound it. Keep the system message and the last few turns verbatim, and summarise everything older into a short block of text. Agent memory and state persistence covers the patterns; cost control covers the accounting.

Step 4: Stream the response

For anything user-facing, stream tokens as they arrive so the answer appears progressively instead of after a long pause:

await foreach (StreamingChatCompletionUpdate update
    in client.CompleteChatStreamingAsync("Write a haiku about C#."))
{
    foreach (var part in update.ContentUpdate)
        Console.Write(part.Text);
}

Streaming does not make the response finish sooner — total time to the last token is the same. What changes is that the user sees movement in a few hundred milliseconds instead of watching a spinner, and that difference is the single cheapest improvement you can make to how the app feels. (Why your .NET AI agent feels slow puts numbers around this.)

Streaming does change your error handling. A non-streaming call either succeeds or throws before you have anything. A streaming call can fail halfway through, after you’ve already written two paragraphs to the user’s screen, and the exception surfaces from the await foreach rather than from the call that started it. Decide up front whether a partial answer is acceptable in your UI or whether you need to buffer and only render on success — the answer differs between a chat window and a page that renders a legal disclaimer.

Also note that usage figures arrive at the end of a stream, on a final update, not on every chunk. If you are metering spend per request, read update.Usage when it becomes non-null rather than assuming the first update carries it. (For streaming all the way to a browser, see streaming agent responses to a web UI.)

The four finish reasons and what each one means for the calling code, with the usage counters underneath.Two properties most tutorials never mention, and every production caller needs.Stopfinished naturallythe only case where the text is completeLengthhit the output token ceiling, cut off mid-thoughtdo not parse it as JSON, and do not show it as finalContentFilterthe provider blocked itthere is no answer here to fall back onToolCallsit wants you to run a toolnot text to show a userUsage carries InputTokenCount, OutputTokenCount and TotalTokenCount. Log them from the first day: cost surprises are almost always input-side.
Only one of the four means the text you are holding is complete. Reading .Text and nothing else is why truncated JSON arrives at a deserialiser three layers from the cause, and why cost surprises are discovered on an invoice instead of in a log line.

Read the rest of the response, not just the text

ChatCompletion carries two properties that most tutorials never mention and every production caller needs.

FinishReason tells you why the model stopped. ChatFinishReason.Stop means it finished naturally. Length means it hit the output token ceiling and was cut off mid-thought — the text you got back is truncated, and if you’re parsing it as JSON it will fail to parse. ContentFilter means the provider blocked it. ToolCalls means it wants you to run a tool rather than show text to the user.

Usage gives you InputTokenCount, OutputTokenCount and TotalTokenCount for the call. Log these from the very first day. Cost surprises in LLM applications are almost always input-side — a prompt that grew, a retrieval step returning twenty chunks where five would do — and without per-request token counts you’re reduced to guessing from a monthly invoice.

if (completion.FinishReason == ChatFinishReason.Length)
{
    // The answer is truncated. Do not parse it, and do not show it as final.
    logger.LogWarning("Output truncated at {Tokens} tokens", 
        completion.Usage?.OutputTokenCount);
}

Controlling the call

ChatCompletionOptions is where you constrain behaviour:

var options = new ChatCompletionOptions
{
    MaxOutputTokenCount = 400,
    Temperature = 0.2f,
};

var completion = await client.CompleteChatAsync(messages, options);

MaxOutputTokenCount is a spending cap and a latency cap in one, because generation is sequential — a 1,200-token answer takes roughly four times as long to finish as a 300-token one. Set it deliberately, then handle the Length finish reason you just made more likely.

Temperature controls randomness. Low values (0 to 0.3) for classification, extraction and anything you’re going to parse; higher values for drafting and brainstorming. It is worth being clear-eyed about what low temperature does and doesn’t buy you: it makes output more consistent, not deterministic and not more accurate. If you need a guaranteed shape, constrain the format rather than the sampling — see structured output in .NET.

What actually goes wrong in production

Failed calls throw ClientResultException, which exposes the HTTP status. Four statuses account for nearly everything:

401 — bad or missing key. Usually the environment variable didn’t reach the process. Check the deployed environment, not your laptop.

429 — rate limited, or out of quota. These are different problems wearing the same status code, and the distinction matters: a genuine rate limit resolves with backoff, while insufficient_quota is a billing problem that retrying will never fix. Read the error body before you retry.

400 with a context length message — you sent more tokens than the model’s window allows. This is almost always accumulated conversation history or over-eager retrieval, and it appears suddenly in production because test conversations are short. Count tokens before you send when input length is variable.

Timeouts — the default network timeout can be shorter than a long generation takes. If long answers fail while short ones succeed, this is your culprit; raise NetworkTimeout on OpenAIClientOptions rather than trimming the prompt.

One behaviour catches people out: the SDK retries automatically. Client classes retry transient failures up to three additional times with exponential backoff before the exception ever reaches your code. That’s a sensible default, but it means a request you thought took eight seconds may have been three attempts, and it means wrapping the call in your own retry policy multiplies attempts rather than adding them. If you want Polly in charge, configure the SDK’s RetryPolicy accordingly instead of stacking one on top of the other.

Finally, treat the request as non-idempotent. If a call times out you do not know whether the model ran, so a blind retry can bill you twice and, in an agent that calls tools, can execute a side effect twice.

Create the client once

ChatClient is intended to be constructed once and shared. Creating a new one per request means a new HTTP handler per request, which leads to socket exhaustion under load — the same HttpClient lesson .NET taught everyone a decade ago, in new packaging. Register it as a singleton:

builder.Services.AddSingleton(_ => new ChatClient(
    model: "gpt-4o-mini",
    apiKey: builder.Configuration["OpenAI:ApiKey"]));

The client is thread-safe for concurrent calls, so one instance across your app is correct, not a bottleneck.

Step 5: Keep the key safe in production

Environment variables are fine locally, but in production prefer a secrets manager or, better, a keyless setup. If you deploy on Azure, use Azure OpenAI with DefaultAzureCredential and a managed identity so there’s no key at all — the app authenticates as itself. This is covered in the Azure deployment guide, and the security reasoning in securing AI agents.

The argument for keyless isn’t ceremony. An API key is a bearer token with no expiry and no user identity attached — anyone holding it is you, until someone notices and rotates it. Managed identity removes the credential from your configuration entirely, which removes the whole class of leak-through-logs, leak-through-config-dump and leak-through-committed-appsettings incidents.

Whichever you use, never send an API key to a browser or a mobile client. Calls to the model belong on your server, behind your own authentication, so that you control who can spend your quota.

When not to call the API directly

Direct SDK calls are the right choice for a single request-response with a model. They stop being the right choice at a few recognisable points. If you need the model to call functions and loop until a task is done, you want an agent abstraction rather than a hand-rolled while loop — see building a tool-using agent in C#. If you need to fall back to a second provider when the first is down, put a routing layer in front rather than try/catch at each call site (model routing and fallback). And if requests are long-running or bursty, queue them instead of holding an HTTP request open for thirty seconds.

Note: SDK class names (ChatClient, CompleteChatAsync) can change between versions. Verify against the official OpenAI .NET SDK for the version you install; the flow — client, messages, complete, stream — is stable.

Where to go next

Calling the API is the foundation. The interesting part is what you build on top:

Takeaway

Calling OpenAI from C# is a three-line round-trip with the official OpenAI SDK: create a ChatClient, send messages, read the reply — and stream for anything users see. The gap between that and something you can deploy is small but specific: register the client as a singleton, bound your conversation history before it bounds your budget, check FinishReason before trusting the text, log Usage from day one, and know that the SDK is already retrying underneath you. Keep your key in an environment variable locally and move to a keyless managed-identity setup in production. Once that plumbing works, everything else — tools, structured output, agents — is built on the exact same call.