Azure AI Document Intelligence in C#: Invoice OCR Example
Last updated: November 9, 2026 · Built with Azure.AI.DocumentIntelligence 1.0.0 on .NET 10 (SDK 10.0.401)
Short answer: to read an invoice with OCR in C#, install Azure.AI.DocumentIntelligence 1.0.0, create a DocumentIntelligenceClient with your endpoint and key (or DefaultAzureCredential), and call AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-invoice", uri). The prebuilt invoice model returns typed fields such as VendorName, InvoiceDate and InvoiceTotal; read them through ValueString, ValueDate, ValueCurrency, ValueList and ValueDictionary. Code from older tutorials, including the first version of this one, does not compile on the GA package: AnalyzeDocumentContent is gone, several properties were renamed, and ValueObject never existed in the .NET SDK. The next section shows the exact errors and the fixes.
“AnalyzeDocumentContent could not be found”: the GA Breaking Changes
I pasted the code from the 2025 version of this article into a new project with the current package. The build output:
error CS0246: The type or namespace name 'AnalyzeDocumentContent' could not be found
error CS1061: 'DocumentField' does not contain a definition for 'ValueObject'
I then compiled the same code against every preview package (1.0.0-beta.1 to beta.3) and against GA to see where each name comes from:
| Beta code | GA 1.0.0 |
|---|---|
new AnalyzeDocumentContent { UrlSource = uri } | AnalyzeDocumentAsync(WaitUntil.Completed, modelId, uri) |
new AnalyzeDocumentContent { Base64Source = data } | AnalyzeDocumentAsync(WaitUntil.Completed, modelId, binaryData) |
AnalyzeDocumentAsync(wait, modelId, content) plus extra parameters | new AnalyzeDocumentOptions(modelId, uri or bytes) { Pages, Features, Locale, ... } |
doc.DocType | doc.DocumentType |
field.Type | field.FieldType |
field.ValueLong | field.ValueInt64 |
field.ValueObject (never compiled in any version) | field.ValueDictionary |
ValueObject fails on every package version. It is the property name in the REST JSON ("valueObject"), and the Python SDK’s name, so it ends up in C# samples that were never built. In .NET it has always been ValueDictionary. Beta.1 does not even have ValueList.
If you are coming from Azure.AI.FormRecognizer (DocumentAnalysisClient), that package still works against the older API versions. New features such as the 2024-11-30 models, batch analysis and searchable PDF output are only in Azure.AI.DocumentIntelligence. For new code, use the new package.
How I Tested Without an Azure Subscription
I don’t have an Azure subscription for this site, so I did not call the real service. Instead I wrote a 40-line ASP.NET Core app that implements the two REST calls the SDK makes and replays Microsoft’s own example responses for API version 2024-11-30 from the azure-rest-api-specs repository. It returns 202 with an Operation-Location, then running on the first poll, then Microsoft’s sample prebuilt-invoice result. The real SDK runs against it unchanged: same requests, same polling, same deserialization. What this cannot show you is recognition quality on your own documents. Use the free tier and Document Intelligence Studio for that.
Setup
dotnet add package Azure.AI.DocumentIntelligence
dotnet add package Azure.Identity # for DefaultAzureCredential
In the Azure portal, create a Document Intelligence resource and copy its endpoint and key from “Keys and Endpoint”. Keep them out of source code:
using Azure;
using Azure.AI.DocumentIntelligence;
var endpoint = Environment.GetEnvironmentVariable("AZURE_DOCUMENT_ENDPOINT")!;
var key = Environment.GetEnvironmentVariable("AZURE_DOCUMENT_KEY")!;
var client = new DocumentIntelligenceClient(new Uri(endpoint), new AzureKeyCredential(key));
Extract an Invoice: Working Code and Output
Operation<AnalyzeResult> operation = await client.AnalyzeDocumentAsync(
WaitUntil.Completed, "prebuilt-invoice", new Uri("https://example.com/invoice.pdf"));
AnalyzeResult result = operation.Value;
foreach (AnalyzedDocument invoice in result.Documents)
{
Console.WriteLine($"{invoice.DocumentType} (confidence {invoice.Confidence:F2})");
Console.WriteLine($" Vendor: {Str(invoice, "VendorName")}");
Console.WriteLine($" Invoice: {Str(invoice, "InvoiceId")}");
Console.WriteLine($" Date: {Date(invoice, "InvoiceDate"):yyyy-MM-dd}");
Console.WriteLine($" Total: {Amount(invoice.Fields, "InvoiceTotal")}");
if (invoice.Fields.TryGetValue("Items", out DocumentField? items)
&& items.FieldType == DocumentFieldType.List)
{
foreach (DocumentField item in items.ValueList)
{
IReadOnlyDictionary<string, DocumentField> f = item.ValueDictionary;
var description = f.TryGetValue("Description", out var d) ? d.ValueString : "?";
var quantity = f.TryGetValue("Quantity", out var q) ? q.ValueDouble : null;
Console.WriteLine($" {description,-20} qty {quantity,4} amount {Amount(f, "Amount")}");
}
}
}
static string Str(AnalyzedDocument doc, string name) =>
doc.Fields.TryGetValue(name, out var f) ? f.ValueString ?? f.Content ?? "" : "";
static DateTimeOffset? Date(AnalyzedDocument doc, string name) =>
doc.Fields.TryGetValue(name, out var f) ? f.ValueDate : null;
Output against the replayed Microsoft sample invoice:
Analyze + polling took 1370 ms, model prebuilt-invoice, API 2024-11-30
prebuilt:invoice (confidence 1.00)
Vendor: CONTOSO LTD.
Invoice: INV-100
Date: 2019-11-15
Due: 2019-12-15
Total: 110 (field type: number)
Due now: 610
Line items:
Consulting Services qty 2 amount 60
Document Fee qty 3 amount 30
Printing Fee qty 10 amount 10
Fields under 0.95 confidence: BillingAddress 0.947, CustomerAddress 0.947, CustomerName 0.949,
RemittanceAddress 0.946, ServiceAddress 0.946, ShippingAddress 0.946, VendorAddress 0.946
Tables: 3, first table 4 rows x 8 columns
Use the typed values, not Content. Content is the text as printed ("$110.00", "11/15/2019"). ValueDate is already a DateTimeOffset, so you are not parsing US date formats yourself.
Gotcha: Amounts Can Be “currency” or “number”
Look at field type: number in the output. Microsoft’s invoice schema for 2024-11-30 lists InvoiceTotal, SubTotal, AmountDue and UnitPrice as currency. Microsoft’s own example response for the same API version returns them as number. Code that only reads ValueCurrency gets null for one of them, and code that only reads ValueDouble gets null for the other. Handle both:
static decimal? Amount(IReadOnlyDictionary<string, DocumentField> fields, string name)
{
if (!fields.TryGetValue(name, out var f)) return null;
if (f.FieldType == DocumentFieldType.Currency && f.ValueCurrency is { } c) return (decimal)c.Amount;
if (f.FieldType == DocumentFieldType.Double && f.ValueDouble is { } d) return (decimal)d;
return null;
}
ValueCurrency also has CurrencyCode and CurrencySymbol. Store the code next to the amount if you process invoices in more than one currency.
Confidence Scores
Every field has a Confidence (a float?, so compare with 0.95f). In the sample, all seven address fields were below 0.95 while ids, dates and amounts were 0.96–0.97. That pattern is typical: multi-line text scores lower. Route low-confidence fields to a person rather than rejecting the whole document. Set the threshold per field from your own documents, not from a number in a blog post.
Local Files, Page Ranges and Add-on Features
For a file on disk or an upload, pass BinaryData. For anything beyond model and source, use AnalyzeDocumentOptions:
byte[] bytes = await File.ReadAllBytesAsync("invoice.pdf");
var options = new AnalyzeDocumentOptions("prebuilt-invoice", BinaryData.FromBytes(bytes))
{
Pages = "1-2",
Features = { DocumentAnalysisFeature.KeyValuePairs }
};
Operation<AnalyzeResult> operation = await client.AnalyzeDocumentAsync(WaitUntil.Completed, options);
Here is what the SDK actually sent, from the stub’s request log:
POST /documentintelligence/documentModels/prebuilt-invoice:analyze
?api-version=2024-11-30&pages=1-2&stringIndexType=utf16CodeUnit&features=keyValuePairs
body: {"base64Source":"JVBERi0xLjQg..."}
GET /documentintelligence/documentModels/prebuilt-invoice/analyzeResults/563ac0cb-...?api-version=2024-11-30
GET (1.0 s later, after Retry-After: 1) -> succeeded
Three useful details:
- Local bytes go up as base64 inside JSON, not as a raw stream. Expect the request to be about a third larger than the file.
- The .NET SDK asks for
stringIndexType=utf16CodeUniton its own, soSpan.OffsetandSpan.Lengthindex directly into a C#string. The REST API’s default istextElements, so offsets from a raw REST call or another SDK can differ for text with emoji or combining characters. Pagesis the cheapest optimization you have. You pay per page analyzed, and the service analyzes every page unless you limit it.
Long-Running Operations
WaitUntil.Completed blocks until the result is ready, polling with the Retry-After the service sends. For a web request that should not wait, start the operation and store its id:
Operation<AnalyzeResult> started = await client.AnalyzeDocumentAsync(WaitUntil.Started, options);
// Started after 5 ms, HasCompleted=False
await started.WaitForCompletionAsync(cancellationToken);
// Completed after 1029 ms
In a queue-based pipeline (an Azure Service Bus consumer or a queue-triggered Azure Function), process one message per document. Let the SDK’s built-in retries handle 429s, and keep the number of documents in flight below your resource’s limit: 15 analyze requests per second on S0 by default, 1 per second on the free tier.
Read, Layout and Tables
// Plain OCR text: prebuilt-read
var read = (await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-read", uri)).Value;
foreach (DocumentPage page in read.Pages)
foreach (DocumentLine line in page.Lines)
Console.WriteLine(line.Content);
// Tables and structure: prebuilt-layout
var layout = (await client.AnalyzeDocumentAsync(WaitUntil.Completed, "prebuilt-layout", uri)).Value;
foreach (DocumentTable table in layout.Tables)
{
Console.WriteLine($"Table {table.RowCount} x {table.ColumnCount}");
foreach (DocumentTableCell cell in table.Cells)
Console.WriteLine($" [{cell.RowIndex},{cell.ColumnIndex}] {cell.Kind}: {cell.Content}");
}
prebuilt-read is the cheapest model by far (see pricing below), so use it when you only need text. Use prebuilt-layout for tables, and a prebuilt model (invoice, receipt, idDocument and others) when one exists for your document type. The invoice result above already contained 3 tables, so you do not need a separate layout call for an invoice.
ASP.NET Core: Dependency Injection and Managed Identity
// dotnet add package Microsoft.Extensions.Azure
builder.Services.AddAzureClients(clients =>
{
var endpoint = new Uri(builder.Configuration["DocumentIntelligence:Endpoint"]!);
var key = builder.Configuration["DocumentIntelligence:Key"];
if (string.IsNullOrEmpty(key))
clients.AddDocumentIntelligenceClient(endpoint).WithCredential(new DefaultAzureCredential());
else
clients.AddDocumentIntelligenceClient(endpoint, new AzureKeyCredential(key));
});
builder.Services.AddScoped<InvoiceExtractor>();
app.MapPost("/invoices", async (IFormFile file, InvoiceExtractor extractor, CancellationToken ct) =>
{
await using var stream = file.OpenReadStream();
return Results.Ok(await extractor.ExtractAsync(stream, ct));
}).DisableAntiforgery();
public sealed class InvoiceExtractor(DocumentIntelligenceClient client)
{
public async Task<InvoiceData> ExtractAsync(Stream document, CancellationToken ct)
{
BinaryData bytes = await BinaryData.FromStreamAsync(document, ct);
Operation<AnalyzeResult> operation = await client.AnalyzeDocumentAsync(
WaitUntil.Completed, "prebuilt-invoice", bytes, ct);
AnalyzedDocument doc = operation.Value.Documents.FirstOrDefault()
?? throw new InvalidOperationException("No invoice found in the document.");
return new InvoiceData(
Vendor: doc.Fields.TryGetValue("VendorName", out var v) ? v.ValueString : null,
InvoiceId: doc.Fields.TryGetValue("InvoiceId", out var id) ? id.ValueString : null,
InvoiceDate: doc.Fields.TryGetValue("InvoiceDate", out var d) ? d.ValueDate : null,
Total: Amount(doc.Fields, "InvoiceTotal"),
Confidence: doc.Confidence);
}
// Amount(...) as above
}
public record InvoiceData(string? Vendor, string? InvoiceId, DateTimeOffset? InvoiceDate,
decimal? Total, float Confidence);
$ curl -F "file=@invoice.pdf" http://localhost:5201/invoices
{"vendor":"CONTOSO LTD.","invoiceId":"INV-100","invoiceDate":"2019-11-15T00:00:00+00:00","total":110,"confidence":1}
With no key in configuration, the client authenticates with DefaultAzureCredential: Managed Identity in Azure, your Visual Studio or Azure CLI login locally. Give the identity the Cognitive Services User role on the resource. Managed identity also requires a custom subdomain endpoint (https://<name>.cognitiveservices.azure.com/), not a regional one.
Gotcha: The SDK Logs Every Request at Information Level
With AddAzureClients, Azure SDK logging goes to ILogger. My test app wrote six log entries for one document (three requests and three responses, each with all its headers, about 40 lines). The key is redacted (Ocp-Apim-Subscription-Key:REDACTED), so it is not a leak, but in production it is noise and cost in your log store. Raise the category level:
"Logging": { "LogLevel": { "Default": "Information", "Azure.Core": "Warning" } }
Free Tier Limits That Change Your Test Results
From Microsoft’s service limits page:
| Free (F0) | Standard (S0) | |
|---|---|---|
| Pages analyzed per document | 2 (only the first two) | 2,000 |
| Max document size | 4 MB | 500 MB |
| Analyze requests per second | 1 | 15 (adjustable) |
| Pages per month | 500 free | pay per page |
The first row catches people out. On F0, a 10-page contract comes back with two pages of results and no error, so it looks like the model “missed” the rest. Test multi-page documents on S0, or limit your tests to two-page samples.
Pricing
Pay-as-you-go prices for East US, from Microsoft’s Retail Prices API on October 2, 2026 (check the pricing page for your region):
| Model | Per 1,000 pages | Per page |
|---|---|---|
| Read | $1.50 (first 1M pages), then $0.60 | $0.0015 |
| Layout, prebuilt (invoice, receipt, ID, …) | $10 | $0.01 |
| Custom extraction | $30 | $0.03 |
| Add-on features (high resolution, formulas, fonts) | +$6 | +$0.006 |
| Query fields | +$10 | +$0.01 |
So 10,000 two-page invoices a month on prebuilt-invoice cost about $200. The same volume through prebuilt-read costs about $30, but then you write the field extraction yourself. Prebuilt models are usually worth the difference. Commitment tiers bring prebuilt pages down to about $9–9.50 per 1,000 (20K and 100K pages/month tiers).
Checklist
Azure.AI.DocumentIntelligence1.0.0 or later. Old samples withAnalyzeDocumentContentwill not compile.- Typed values (
ValueDate,ValueCurrency/ValueDouble,ValueList,ValueDictionary), notContent. - Amounts handled as both currency and number.
- A per-field confidence threshold with human review below it.
Pagesset when you only need part of a document.DefaultAzureCredentialwith a custom subdomain endpoint in production.Azure.Corelogging at Warning.- Multi-page testing on S0, not F0.
FAQ
Is Azure Form Recognizer the same as Document Intelligence? Yes. The service was renamed. The new .NET package is Azure.AI.DocumentIntelligence; Azure.AI.FormRecognizer is the older one.
Why does AnalyzeDocumentContent not exist? It was a beta type. In 1.0.0 GA, pass the Uri or BinaryData directly, or use AnalyzeDocumentOptions.
Can I extract data from a PDF in C# without Azure? For plain text from digital PDFs, a local library such as PdfPig is enough. Document Intelligence earns its cost on scanned documents, photos, handwriting and field extraction (invoices, receipts, IDs).
Why does the free tier only return two pages? F0 analyzes only the first two pages of each document. That is a documented limit, not an error.
Can I combine it with Azure OpenAI? Yes. A common pattern is prebuilt-layout with markdown output feeding an LLM. See Azure OpenAI in C# and Semantic Kernel for the C# side.
Conclusion
The GA SDK is small and pleasant once you know the renamed types: one client, one AnalyzeDocumentAsync call, typed fields. The traps are in the details: tutorials written against the betas, amounts that arrive as either currency or number, a free tier that silently stops after two pages, and SDK logs that fill your log store. Start with a prebuilt model, limit pages, check confidence per field, and verify recognition quality on your own documents in the Studio before you write much code.
References: Azure.AI.DocumentIntelligence README · Document Intelligence service limits · Invoice model schema (2024-11-30) · REST API specification and examples
This article is part of the AI in .NET guide (LLMs, agents, RAG, ML.NET and AI coding tools).
great
Azure AI Document Intelligence offers a practical approach to automating the extraction of structured information from invoices, receipts, contracts, scanned documents, and other files. The article explains how prebuilt and custom AI models can identify text, tables, key-value pairs, and document structure, reducing the need for manual data entry.
The tutorial is also useful for developers who want to understand how AI-powered document processing can be integrated into C# applications. Concepts such as OCR, layout analysis, custom models, and automated extraction provide a strong foundation for exploring Claude Certified Developer Training.
The step-by-step Azure resource setup makes the topic more accessible for developers moving from AI concepts toward practical implementation. Learning how cloud-based AI services can process documents and return structured information is also relevant when exploring ChatGPT Developer Training and other modern AI development workflows.
The combination of document analysis, OCR, machine learning, and automated information extraction creates several opportunities for practical applications. These capabilities can be incorporated into projects involving intelligent document pipelines, invoice processing, form recognition, and data extraction, making the topic particularly relevant to AI Projects for Final Year.