Guide
Generate a PDF from HTML in .NET
You have an ASP.NET Core endpoint, some HTML (an invoice, a policy document, a packing slip), and you need to return a PDF. The .NET answers each have a catch: PuppeteerSharp downloads a full Chromium on first run, DinkToPdf wraps an unmaintained native library that crashes under concurrent load, and the polished commercial options bill per server. The lighter path is one HttpClient call.
Minimal API endpoint
The PDFPipe endpoint is POST https://api.pdfpipe.xyz/v1/pdf. Send a JSON body with your HTML and options, attach a bearer token, and you get back raw application/pdf bytes. Register a named HttpClient in Program.cs so the connection pool is shared across requests.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("pdfpipe", client =>
{
client.BaseAddress = new Uri("https://api.pdfpipe.xyz");
client.Timeout = TimeSpan.FromSeconds(30);
client.DefaultRequestHeaders.Authorization = new("Bearer",
Environment.GetEnvironmentVariable("PDFPIPE_API_KEY"));
});
var app = builder.Build();
app.MapGet("/invoice/{id}", async (string id, IHttpClientFactory factory) =>
{
var html = $$"""
<html>
<body style="font-family: sans-serif; padding: 40px">
<h1>Invoice #{{id}}</h1>
<p>Amount due: $420.00</p>
</body>
</html>
""";
var client = factory.CreateClient("pdfpipe");
var response = await client.PostAsJsonAsync("/v1/pdf", new
{
html,
options = new { format = "A4" }
});
if (!response.IsSuccessStatusCode)
{
var detail = await response.Content.ReadAsStringAsync();
return Results.Problem($"PDF render failed: {detail}", statusCode: 502);
}
var stream = await response.Content.ReadAsStreamAsync();
return Results.Stream(stream, "application/pdf",
fileDownloadName: $"invoice-{id}.pdf");
});
app.Run();Results.Stream forwards the bytes without buffering the full document in memory, and IHttpClientFactory handles connection pooling so this scales with your normal request load.
MVC controller version
If you prefer the traditional MVC layout, inject the factory through the constructor. The render call is the same.
public class InvoiceController : Controller
{
private readonly HttpClient _pdf;
public InvoiceController(IHttpClientFactory factory)
=> _pdf = factory.CreateClient("pdfpipe");
public async Task<IActionResult> Download(string id)
{
var html = $"<html><body><h1>Invoice #{id}</h1></body></html>";
var response = await _pdf.PostAsJsonAsync("/v1/pdf", new
{
html,
options = new { format = "A4" }
});
response.EnsureSuccessStatusCode();
var bytes = await response.Content.ReadAsByteArrayAsync();
return File(bytes, "application/pdf", $"invoice-{id}.pdf");
}
}Resilience with Polly
Renders are idempotent, so the standard resilience handler is safe to add. One line on the named client gives you retries with backoff and a circuit breaker.
builder.Services.AddHttpClient("pdfpipe", /* ... */)
.AddStandardResilienceHandler(); // Microsoft.Extensions.Http.ResilienceRendering from a URL
If the document is already hosted at a URL, pass that instead of inlining HTML.
await client.PostAsJsonAsync("/v1/pdf", new
{
url = "https://example.com/report/42",
options = new { format = "A4" }
});Why not PuppeteerSharp or DinkToPdf?
| Concern | In-process rendering | HttpClient to PDFPipe |
|---|---|---|
| Deploy size | PuppeteerSharp pulls ~300 MB Chromium at first run | Nothing beyond your app |
| Stability | DinkToPdf's native wkhtmltox crashes under concurrent load | Plain HTTP, no native interop |
| Licensing | Commercial libraries bill per server or per developer | Flat API pricing |
| Azure / containers | Chromium needs sandbox flags and extra packages | Runs on any tier, including consumption plans |
Keep the API key in user secrets locally and your secret store in production. The options object also accepts other page sizes (for example Letter), margins, and landscape orientation, documented in full on the docs page.
Paste your own HTML into the live playground on the home page to see the PDF render instantly, then read the docs for every option.
See pricing →