HTML to PDF in C# and .NET
The .NET options split unusually sharply. The commercial libraries — IronPDF,
Aspose, Syncfusion — are capable and carry per-developer or per-deployment licence
fees that need approval before you can even prototype. The free ones are mostly
wrappers: wkhtmltopdf bindings such as DinkToPdf bring a native library you must
ship per architecture and marshal through P/Invoke, and PuppeteerSharp downloads
and drives a Chromium you then operate.
HttpClient needs none of that, and nothing to approve.
One-off render
No NuGet package.
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
var html = """
<!doctype html>
<html><head><style>
@page { size: A4; margin: 20mm; }
body { font: 14px/1.6 system-ui, sans-serif; color: #16181d; }
h1 { font-size: 28px; margin: 0 0 4px; }
</style></head>
<body>
<h1>Invoice 2026-0142</h1>
<p>Issued 2026-08-08 · Due 2026-09-07</p>
</body></html>
""";
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("RENDER_API_KEY"));
var payload = new StringContent(
JsonSerializer.Serialize(new { template = html }),
Encoding.UTF8,
"application/json");
var response = await client.PostAsync("https://renderpaper.com/v1/render", payload);
if (!response.IsSuccessStatusCode)
{
var message = await response.Content.ReadAsStringAsync();
throw new Exception($"render failed: {(int)response.StatusCode}: {message}");
}
await using var file = File.Create("invoice.pdf");
await response.Content.CopyToAsync(file);
Console.WriteLine("wrote invoice.pdf");
RENDER_API_KEY=rs_live_… dotnet run
Stored templates: send data, not markup
using System.Text;
using System.Text.Json;
const string templateId = "your-template-id";
var data = new
{
data = new
{
InvoiceNumber = "2026-0142",
Customer = new { Name = "Northwind Trading AB" },
LineItems = new[]
{
new { Description = "Consulting", Quantity = 12, Amount = 14400 },
new { Description = "Hosting", Quantity = 1, Amount = 4350 },
},
Total = 18750,
}
};
using var client = new HttpClient();
client.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("RENDER_API_KEY"));
var payload = new StringContent(JsonSerializer.Serialize(data), Encoding.UTF8, "application/json");
var response = await client.PostAsync(
$"https://renderpaper.com/v1/templates/{templateId}/render", payload);
response.EnsureSuccessStatusCode();
await using var file = File.Create("invoice.pdf");
await response.Content.CopyToAsync(file);
Console.WriteLine("wrote invoice.pdf");
The property names in the anonymous object are the names the template addresses,
so InvoiceNumber here is {{.InvoiceNumber}} there:
<h1>Invoice {{.InvoiceNumber}}</h1>
<p>{{.Customer.Name}}</p>
<table>
{{range .LineItems}}
<tr><td>{{.Description}}</td><td>{{.Quantity}}</td><td>{{.Amount}}</td></tr>
{{end}}
</table>
Note that System.Text.Json camel-cases nothing by default, which is what you want
here — the template expects the names exactly as written.
In ASP.NET Core, with a typed client
Register it once so the connection pool is reused and the key lives in configuration rather than in a controller:
// Program.cs
builder.Services.AddHttpClient("renderpaper", client =>
{
client.BaseAddress = new Uri("https://renderpaper.com/");
client.DefaultRequestHeaders.Add("X-API-Key", builder.Configuration["Renderpaper:ApiKey"]);
client.Timeout = TimeSpan.FromSeconds(60);
});
// InvoicesController.cs
[HttpGet("invoices/{id}/pdf")]
public async Task<IActionResult> Pdf(string id, CancellationToken ct)
{
var client = _factory.CreateClient("renderpaper");
var payload = JsonContent.Create(new { data = await _invoices.DataFor(id, ct) });
var response = await client.PostAsync($"v1/templates/{_templateId}/render", payload, ct);
if (!response.IsSuccessStatusCode)
{
return StatusCode(StatusCodes.Status502BadGateway, "could not render the document");
}
var stream = await response.Content.ReadAsStreamAsync(ct);
return File(stream, "application/pdf", $"invoice-{id}.pdf");
}
Passing the CancellationToken matters: a client that abandons the download stops
the render instead of leaving it running for nobody.
Why not IronPDF or Aspose
They are good, and for a team already paying for them there is little reason to change. The friction is commercial rather than technical: per-developer licensing means a proof of concept needs a purchasing conversation, and deployment counts have to be tracked. That is a real cost even when the cheque is affordable.
Why not DinkToPdf or wkhtmltopdf
DinkToPdf wraps the wkhtmltopdf native library through P/Invoke. Two costs
follow. The engine is a pre-flexbox WebKit fork, unmaintained for years, so modern
layouts collapse quietly. And the native library has to be present and correct for
every architecture you deploy to — including arm64, where the story is thin — which
is a class of deployment failure a managed dependency does not have.
Why not PuppeteerSharp
PuppeteerSharp gives you the real browser and therefore the real CSS. What it also gives you is a Chromium download on first run, a few hundred megabytes in the image, and process supervision inside a .NET application. Reasonable when documents must not leave your network; a lot of surface otherwise.
Try it without a key
https://renderpaper.com/sample.pdf is a real render, no account. The free tier is
50 documents a month with no card.