Renderpaper
Log in Start free

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.

Last updated 2026-08-08.