Skip to content
CastPDF

.NET HTML to PDF in C# without bundling a browser

To convert HTML to PDF in .NET, send your HTML (or a saved template ID) and an object of data to CastPDF with HttpClient and PostAsJsonAsync, then copy the response stream to a file. No Chromium download, no native DLLs, just async C#. It is the same PDF generation API behind every language, shown here on .NET 8.

  • Updated October 2026
  • 100 free PDFs a month

.NET HTML to PDF options compared

The .NET ecosystem offers everything from code-first layout libraries to full browser automation and a long list of commercial converters. They differ in three ways that matter later: whether you design in HTML or in C#, what has to be deployed next to your app, and how the licence works. Here is an even-handed summary.

Ways to make a PDF from .NET
OptionHow it worksWorth knowing
QuestPDFA fluent C# API: you compose rows, columns and tables in code, no HTML involvedPleasant to use and fast; its free Community licence has eligibility conditions and larger companies need a paid licence, so read the current terms
PuppeteerSharpA .NET port of Puppeteer that downloads and drives headless Chromium, then calls its PDF exportModern CSS; you ship a browser with the app and look after its memory, crashes and updates
wkhtmltopdf wrappersPackages such as DinkToPdf or Rotativa that call wkhtmltopdf through native libraries or the executableFamiliar and quick to start; the wkhtmltopdf repository was archived in January 2023, and native binaries complicate Linux containers
Commercial librariesPaid HTML to PDF converters that embed a rendering engine in your processOften polished, with vendor support; licence models and prices vary by vendor and deployment
CastPDFOne HTTPS request; Chromium with print features runs on our serversA network call per document and a monthly plan beyond the free allowance

If your documents are built by developers and rarely change shape, QuestPDF is a joy, provided its licence fits your company. The picture changes when a designer owns the layout in HTML, as with the warehouse packing list on this page, or when every container image has to carry Chromium and its fonts. Then a single HTTP call from code you already write keeps deployment simple: no browser in the image and no native libraries to match against Linux distributions.

Your first packing list in C#

Create a test key in the dashboard (it starts with cpdf_test_) and set it as the environment variable CASTPDF_API_KEY, in your shell or in the launch profile of your project. Test renders are free, unlimited and watermarked. Create a console app with dotnet new console, replace Program.cs with the code below and run dotnet run. System.Net.Http.Json is part of the shared framework since .NET 5, so there is nothing to install.

Program.cs (.NET 8 console app)
using System.Net.Http.Headers;
using System.Net.Http.Json;

const string html = """
    <h1>Packing list {{ shipment.number }}</h1>
    <p>Order {{ shipment.order }} for {{ shipment.customer }}, {{ shipment.cartons }} cartons</p>
    <table>
      <thead><tr><th>SKU</th><th>Item</th><th>Qty</th><th>Carton</th></tr></thead>
      <tbody>
      {% for item in items %}
        <tr><td>{{ item.sku }}</td><td>{{ item.name }}</td><td>{{ item.qty }}</td><td>{{ item.carton }}</td></tr>
      {% endfor %}
      </tbody>
    </table>
    """;

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(75) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("CASTPDF_API_KEY"));

var payload = new
{
    html,
    data = new
    {
        shipment = new { number = "PL-55021", order = "SO-18840", customer = "Fjord Outdoor AS", cartons = 3 },
        items = new[]
        {
            new { sku = "TNT-2P-GRN", name = "Two-person tent, green", qty = 4, carton = 1 },
            new { sku = "MAT-INF-L", name = "Inflatable sleeping mat, large", qty = 8, carton = 2 },
            new { sku = "STV-GAS-MINI", name = "Compact gas stove", qty = 6, carton = 3 },
        },
    },
    mode = "print",
    filename = "packing-list-PL-55021",
};

using var response = await http.PostAsJsonAsync("https://api.castpdf.com/v1/pdf", payload);
if (!response.IsSuccessStatusCode)
{
    var problem = await response.Content.ReadFromJsonAsync<ErrorEnvelope>();
    throw new InvalidOperationException($"{(int)response.StatusCode} {problem?.Error?.Code}: {problem?.Error?.Message}");
}

await using (var file = File.Create("packing-list-PL-55021.pdf"))
{
    await response.Content.CopyToAsync(file);
}

var pages = response.Headers.TryGetValues("x-pages", out var values) ? string.Join(",", values) : "?";
Console.WriteLine($"Saved packing-list-PL-55021.pdf, {pages} page(s)");

record ErrorEnvelope(ApiError? Error);
record ApiError(string Code, string Message);

The HTML sits in a C# 11 raw string literal ("""), so quotes need no escaping and the Liquid braces stay as they are. Avoid putting Liquid inside an interpolated $"..." string: there, {{ is the escape for a single brace, and your {{ item.sku }} would reach the API as { item.sku }. PostAsJsonAsync serialises the anonymous object with web defaults, so the lower-case property names go out exactly as written. Because data is present, CastPDF expands the Liquid loop, and print mode repeats the table header when the list runs past one page.

Razor views and @page

If you build the HTML from a Razor view, print CSS rules that start with @, such as @page and @media print, must be written @@page and @@media print, or Razor treats them as code. Render the view to a string, then send it as html without data so it is used exactly as rendered.

Typed records, a saved template and a signed link

Anonymous objects are fine for a first test, but production code deserves types. The next example moves the packing list design into a saved template, edited in the dashboard as HTML and CSS with a live preview, while warehouse staff adjust sample values in Simple mode without code. In C#, records describe the request and the response, and the snake case naming policy added in .NET 8 maps TemplateId to template_id and ExpiresAt to expires_at for you.

PackingListLink.cs (.NET 8)
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;

var json = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};

using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(75) };
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("CASTPDF_API_KEY"));

var list = new PackingList(
    new Shipment("PL-55022", "SO-18852", "Kvist Garden Supply", Cartons: 2),
    new[]
    {
        new Item("HOSE-25M", "Garden hose, 25 m", Qty: 6, Carton: 1),
        new Item("RAKE-ALU", "Aluminium rake", Qty: 10, Carton: 2),
    });

var key = $"packing-list-{list.Shipment.Number}";
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.castpdf.com/v1/pdf")
{
    Content = JsonContent.Create(
        new PdfRequest(Environment.GetEnvironmentVariable("PACKING_TEMPLATE_ID")!, list, key, "url"),
        options: json),
};
request.Headers.Add("Idempotency-Key", key);

using var response = await http.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
    var problem = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(json);
    throw new InvalidOperationException($"{problem?.Error?.Code}: {problem?.Error?.Message}");
}

var link = await response.Content.ReadFromJsonAsync<PdfLink>(json);
Console.WriteLine($"{link!.Url} ({link.Pages} pages), expires {link.ExpiresAt:u}");

record PdfRequest(string TemplateId, PackingList Data, string Filename, string Response);
record PackingList(Shipment Shipment, Item[] Items);
record Shipment(string Number, string Order, string Customer, int Cartons);
record Item(string Sku, string Name, int Qty, int Carton);
record PdfLink(string Id, int Pages, long Bytes, string Url, DateTimeOffset ExpiresAt);
record ErrorEnvelope(ApiError? Error);
record ApiError(string Code, string Message);

The Idempotency-Key header is set on the HttpRequestMessage because it belongs to this one document, unlike the authorisation header shared by every call. If the warehouse system posts the same shipment twice, the second call returns the first packing list and nothing is counted again. The data object replaces the template's sample data in full, so it must contain every field the template reads.

In the reply, url is a signed link that downloads the PDF without a key until expires_at, convenient for a handheld scanner app or a link in the shipping portal. Keep the Id with the shipment: GET /v1/pdf/:id later returns a fresh link for the same stored file. The API reference lists every field of both calls.

Error handling and retries in C#

Errors use one JSON shape: an error object with a stable code, a readable message and a docs_url. The typed client below retries 409 (a request with this key is still rendering), 429 (waiting for Retry-After, which .NET parses into RetryAfter.Delta), 503 (exponential backoff) and transport failures. An HttpRequestMessage can be sent only once, so the loop creates a new one per attempt. A TaskCanceledException that is not caused by your own cancellation token means the HttpClient.Timeout expired, and that is retried too.

CastPdfClient.cs
using System.Net.Http.Headers;
using System.Net.Http.Json;

public sealed class CastPdfException : Exception
{
    public CastPdfException(int status, string? code, string? message)
        : base($"{status} {code}: {message}")
    {
        Status = status;
        Code = code;
    }

    public int Status { get; }
    public string? Code { get; }
}

public sealed class CastPdfClient
{
    private const string Endpoint = "https://api.castpdf.com/v1/pdf";
    private readonly HttpClient _http;

    public CastPdfClient(HttpClient http) => _http = http;

    public async Task<byte[]> RenderAsync(object payload, string idempotencyKey, CancellationToken ct = default)
    {
        const int maxAttempts = 5;
        for (var attempt = 1; ; attempt++)
        {
            using var request = new HttpRequestMessage(HttpMethod.Post, Endpoint)
            {
                Content = JsonContent.Create(payload),
            };
            request.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("CASTPDF_API_KEY"));
            request.Headers.Add("Idempotency-Key", idempotencyKey);

            HttpResponseMessage response;
            try
            {
                response = await _http.SendAsync(request, ct);
            }
            catch (Exception ex) when (ex is HttpRequestException || (ex is TaskCanceledException && !ct.IsCancellationRequested))
            {
                if (attempt == maxAttempts) throw;
                await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt)), ct);
                continue;
            }

            using (response)
            {
                var status = (int)response.StatusCode;
                if (response.IsSuccessStatusCode)
                {
                    return await response.Content.ReadAsByteArrayAsync(ct);
                }
                if ((status is 409 or 429 or 503) && attempt < maxAttempts)
                {
                    var delay = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(Math.Pow(2, attempt));
                    await Task.Delay(delay, ct);
                    continue;
                }

                var problem = await response.Content.ReadFromJsonAsync<ErrorEnvelope>(ct);
                throw new CastPdfException(status, problem?.Error?.Code, problem?.Error?.Message);
            }
        }
    }
}

public sealed record ErrorEnvelope(ApiError? Error);
public sealed record ApiError(string Code, string Message);

Register it once in an ASP.NET Core app with builder.Services.AddHttpClient<CastPdfClient>(c => c.Timeout = TimeSpan.FromSeconds(90)); and inject it where you need it. Catch CastPdfException and branch on Code: show a clear message for template_render_error, alert finance on spending_cap_reached, and leave everything else to your job runner's retry policy. Since the idempotency key travels with every attempt, a timeout can never produce two packing lists for one shipment.

Production checklist for .NET

  • Get clients from IHttpClientFactory or a typed client, not new HttpClient() per call. Creating clients per request can exhaust sockets, and the factory also rotates connections so DNS changes are picked up.
  • Check the timeout. HttpClient.Timeout defaults to 100 seconds, which covers a 30 second render, but many teams lower it globally to 30 seconds. Give the PDF client its own value of 60 seconds or more.
  • Stay async all the way. Calling .Result or .Wait() on these tasks blocks thread pool threads and can deadlock older ASP.NET apps. Await the call, or move long documents to a hosted background service or a queue.
  • If you add a resilience pipeline such as Polly or Microsoft.Extensions.Http.Resilience, limit retries to 409, 429, 503 and transport errors, and keep the same Idempotency-Key on every attempt.
  • Keep the key in user secrets during development and in environment variables or a vault in production. Never put it in Blazor WebAssembly or a MAUI client, where anyone can read it. Switch cpdf_test_ for a cpdf_live_ key when you launch.
  • Stream large PDFs with CopyToAsync into a file or the response body instead of ReadAsByteArrayAsync, so big documents do not land on the large object heap.
  • Stay within 50 pages and 40 MB per document; resize product photos before you embed them.

Common errors from C# code

Errors .NET developers see first, and the fix
Symptom or codeLikely causeFix
InvalidOperationException: The request message was already sentA retry reused the same HttpRequestMessageBuild a new request message for every attempt
TaskCanceledException with an inner TimeoutExceptionHttpClient.Timeout expired before the render finishedRaise the timeout to 60 seconds or more and retry with the same key
invalid_request (400)PascalCase or camelCase keys such as TemplateId or templateId reached the APIUse the snake case naming policy or [JsonPropertyName("template_id")]
template_render_error (422)Liquid passed through an interpolated string, so {{ arrived as a single braceUse a raw string literal without $; check details.line and details.column
invalid_api_key (401)GetEnvironmentVariable returned null in IIS or a container, so the header was emptySet the variable for the app pool or container, or bind it from configuration
rate_limited (429)Too many requests per minute (test keys allow 20)Wait for RetryAfter.Delta, then retry

The quickstart covers keys, starter templates and the dashboard editor. Working in a JVM shop as well? The Java page uses java.net.http.HttpClient for the same requests. For the shortest possible version, see the Node.js page.

FAQ

Common questions

Is QuestPDF free for commercial use?

It depends on your organisation. QuestPDF offers a free Community licence with eligibility conditions, and companies outside them need a paid licence. Check the current terms on its website, since they can change. CastPDF is a hosted service, so there is no library licence to track.

Is there a NuGet package for CastPDF?

No, and none is needed. HttpClient and System.Net.Http.Json ship with .NET, and the API is one JSON POST. The typed client on this page is under 80 lines you can own outright.

How do I return a PDF from an ASP.NET Core minimal API?

Call the API in your endpoint, then return Results.File with the bytes or stream, the content type application/pdf and a file name. In MVC, return File from the controller instead. The key never leaves the server.

Does it run in Azure Functions or AWS Lambda with .NET?

Yes. Because no browser is bundled, the deployment package stays small and cold starts stay short. Make sure the function timeout is longer than 60 seconds for long documents.

Can I keep using Razor views for my PDF layouts?

Yes. Render the view or component to an HTML string on the server, for example with the HtmlRenderer class in .NET 8, and send it as html without data. Remember to write print rules as @@page inside Razor files.

Can Blazor WebAssembly call the PDF API directly?

No, because the code runs in the visitor's browser and the key would be readable there. Have the Blazor app call your own server endpoint, which calls CastPDF and returns the file or a signed link.

Make your first PDF in 5 minutes

Pick a template, add your details and download your PDF. You get 100 free PDFs every month, and you don’t need a card.