.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.
| Option | How it works | Worth knowing |
|---|---|---|
| QuestPDF | A fluent C# API: you compose rows, columns and tables in code, no HTML involved | Pleasant to use and fast; its free Community licence has eligibility conditions and larger companies need a paid licence, so read the current terms |
| PuppeteerSharp | A .NET port of Puppeteer that downloads and drives headless Chromium, then calls its PDF export | Modern CSS; you ship a browser with the app and look after its memory, crashes and updates |
| wkhtmltopdf wrappers | Packages such as DinkToPdf or Rotativa that call wkhtmltopdf through native libraries or the executable | Familiar and quick to start; the wkhtmltopdf repository was archived in January 2023, and native binaries complicate Linux containers |
| Commercial libraries | Paid HTML to PDF converters that embed a rendering engine in your process | Often polished, with vendor support; licence models and prices vary by vendor and deployment |
| CastPDF | One HTTPS request; Chromium with print features runs on our servers | A 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.
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.
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.
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
IHttpClientFactoryor a typed client, notnew 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.Timeoutdefaults 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
.Resultor.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 to409,429,503and transport errors, and keep the sameIdempotency-Keyon 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 acpdf_live_key when you launch. - Stream large PDFs with
CopyToAsyncinto a file or the response body instead ofReadAsByteArrayAsync, 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
| Symptom or code | Likely cause | Fix |
|---|---|---|
InvalidOperationException: The request message was already sent | A retry reused the same HttpRequestMessage | Build a new request message for every attempt |
TaskCanceledException with an inner TimeoutException | HttpClient.Timeout expired before the render finished | Raise 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 API | Use the snake case naming policy or [JsonPropertyName("template_id")] |
template_render_error (422) | Liquid passed through an interpolated string, so {{ arrived as a single brace | Use 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 empty | Set 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.