- HTML to PDF APIConvert HTML and URLs into PDFsPDF Form Fields APIAdd fillable fields to any PDFExtract PDF Form Data APIEasily extract data from PDFsWatermark PDF APIAdd custom watermarks to PDFsProtect PDF APISecure your PDFs with passwordCompress PDF APIReduce file size without losing qualityFlatten PDF APIFlatten PDFs to make form permanentDigital Signature APISend documents for signing
- API DocumentationAPI DocumentationFull REST API referenceNodeJS SDKClient library for Node.jsJava SDKClient library for JavaC# SDKClient library for C#PHP SDKClient library for PHPPython SDKClient library for Python
- Pricing
- Contact Us

HTML to PDF in C#: From Razor Markup to a Streamed Invoice
Turn Razor markup or any HTML into a PDF from C# and stream it from an ASP.NET Core endpoint. Page setup, running headers, data templates and error handling, with every sample run before publishing.
If you write C#, you have probably already tried to turn a Razor view or a chunk of HTML into a PDF and discovered that .NET has no renderer of its own. This tutorial walks the whole path from one URL conversion to an ASP.NET Core endpoint that streams invoices to a browser, with page setup, running headers, data-driven templates, pages that need JavaScript to finish, and the failure cases.
The PDF itself is produced by the HTML to PDF API, so Chrome runs on PDFGate's servers and your application only exchanges HTTP messages with it. Each block of code below was built and executed before the page went live, and both ASP.NET endpoints answered real curl requests.
Key takeaways
- Two async calls do the work:
GeneratePdfAsyncrenders,GetFileAsyncdownloads aStream. No browser or PDF library in your deployment. - Set
PageSizeTypeexplicitly, the default is Ledger. Margins are CSS lengths, headers and footers are their own HTML with page-number classes. - A chart or a fetched table is only captured if the renderer waits for it. Have the page add a marker element and name it in
WaitForSelector. - In ASP.NET Core, register one
PdfGateClientas a singleton and return the stream withResults.File, or hand back a pre-signed link.
The options in .NET
.NET has no built-in HTML renderer, so every approach brings a browser engine along in some form. The common choices:
- wkhtmltopdf wrappers such as DinkToPdf and Rotativa. They are easy to start with but depend on an engine that was archived in 2023, so modern CSS such as grid and flexbox is unreliable and there are no security updates.
- PuppeteerSharp or Playwright for .NET. Real Chrome output, but you download a browser of a few hundred megabytes onto every server, keep it patched, and give it memory. Fine for a single app, painful across a fleet or in a container with a small image.
- Commercial libraries such as IronPDF, Syncfusion or Aspose. Good output, but a per-developer or per-server licence and a large dependency.
- A conversion API.Your process stays free of rendering engines and licences, and what comes back is Chrome's print output. You pay with one network round trip per document and a subscription.
What follows uses the API. The Puppeteer comparison is the place to look if you are deciding between that and hosting Chrome in your own cluster.
Install the .NET SDK
PdfGate.net is built for .NET Standard 2.0, which makes it usable from .NET 6, 8 and 10 and from .NET Framework 4.6.1 upwards. The package pulls in System.Text.Json and nothing else.
1dotnet add package PdfGate.netCreate a key in the dashboard. The prefix decides the environment. A test_ key sends every request to the sandbox, where output is watermarked and free, and a live_ key goes to production. Keep the key out of source and read it from PDFGATE_API_KEY or user secrets, as the samples do.
Convert a URL to PDF
A conversion is two awaits. GeneratePdfAsync renders the page and hands back a PdfGateDocumentResponse whose Id identifies the stored file. GetFileAsync opens that file as a Stream.
1using PdfGate.net;2using PdfGate.net.Models;34using var client = new PdfGateClient(5 Environment.GetEnvironmentVariable("PDFGATE_API_KEY")!);67PdfGateDocumentResponse page = await client.GeneratePdfAsync(new GeneratePdfRequest8{9 Url = "https://en.wikipedia.org/wiki/Portable_Document_Format",10 PageSizeType = GeneratePdfPageSizeType.A4,11});1213await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = page.Id }))14await using (var file = File.Create("wikipedia.pdf"))15{16 await pdf.CopyToAsync(file);17}18Console.WriteLine($"URL -> {page.Id} ({page.Size} bytes)");- Only public URLs can be rendered. Anything on
localhostor a private network comes back as a400without a render attempt, so for local pages send the HTML instead. Ledgeris what you get whenPageSizeTypeis left unset, which is rarely intended. The enum lists A0 to A6, Letter, Legal, Ledger and Tabloid, andWidthplusHeightin pixels handle custom sizes.- Wrap the stream and the output file in
await usingso both are released when the copy finishes. The 23 page Wikipedia article is about 1.4 MB, andSizetells you the byte count before you download. PdfGateClientowns anHttpClientand implementsIDisposable. One instance per application is the right shape, and every method has an overload that accepts aCancellationToken.
Convert an HTML string
When the document is yours, skip the URL and put the markup in Html. A raw string literal keeps a whole HTML file readable inside C#, and a <style> block is the easiest way to carry the CSS with it. Public stylesheets, images and web fonts referenced from the markup are fetched during the render.
1const string invoiceHtml = """2 <!DOCTYPE html>3 <html>4 <head>5 <meta charset="utf-8">6 <style>7 body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }8 h1 { color: #0055d4; }9 table { width: 100%; border-collapse: collapse; margin-top: 24px; }10 th { background: #0055d4; color: white; text-align: left; padding: 8px; }11 td { padding: 8px; border-bottom: 1px solid #e4e7eb; }12 .total { text-align: right; font-size: 18px; margin-top: 16px; }13 </style>14 </head>15 <body>16 <h1>Invoice #2026-0142</h1>17 <p>Billed to Acme GmbH, 21 October 2026</p>18 <table>19 <tr><th>Item</th><th>Qty</th><th>Price</th></tr>20 <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>21 <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>22 </table>23 <p class="total">Total EUR 99.00</p>24 </body>25 </html>26 """;2728PdfGateDocumentResponse invoice = await client.GeneratePdfAsync(new GeneratePdfRequest29{30 Html = invoiceHtml,31 PageSizeType = GeneratePdfPageSizeType.A4,32 Margin = new GeneratePdfPageMargin { Top = "20mm", Bottom = "20mm", Left = "15mm", Right = "15mm" },33 PrintBackground = true,34});3536await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = invoice.Id }))37await using (var file = File.Create("invoice.pdf"))38{39 await pdf.CopyToAsync(file);40}41Console.WriteLine($"HTML -> {invoice.Id} ({invoice.Size} bytes)");GeneratePdfPageMarginholds four strings in CSS units, so millimetres, inches and pixels are all valid.- Chrome strips background colours in print unless told otherwise, which turns a blue table header white.
PrintBackground = truekeeps them. - For a wide table, set
OrientationtoLandscape. For a page that only looks right with its screen stylesheet, setEmulateMediaTypetoScreen. - Raw string literals arrived with C# 11, so a project on .NET 6 needs a verbatim string or an embedded resource instead.
Every layout parameter is covered in page size, margins and orientation.
Headers, footers and page numbers
Running headers and footers are separate HTML fragments in Header and Footer, repeated on each page. The renderer looks for five class names inside them and fills in the content: date, title, url, pageNumber and totalPages. Reserve space with Margin.Top and Margin.Bottom, because a header with no top margin has nowhere to go and the request is refused.
1PdfGateDocumentResponse report = await client.GeneratePdfAsync(new GeneratePdfRequest2{3 Url = "https://en.wikipedia.org/wiki/Portable_Document_Format",4 PageSizeType = GeneratePdfPageSizeType.A4,5 Header = """6 <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">7 Portable Document Format, exported <span class="date"></span>8 </div>9 """,10 Footer = """11 <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">12 Page <span class="pageNumber"></span> of <span class="totalPages"></span>13 </div>14 """,15 Margin = new GeneratePdfPageMargin { Top = "25mm", Bottom = "20mm" },16});1718await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = report.Id }))19await using (var file = File.Create("header-footer.pdf"))20{21 await pdf.CopyToAsync(file);22}23Console.WriteLine($"Header/footer -> {report.Id}");- Chrome renders header templates at a very small default size, so declare
font-sizeinline. - The fragment is rendered as its own document. Styles from the page body do not reach it, which is why the sample inlines everything.
- Give the fragment horizontal padding equal to the page margins. It spans the full paper width, not the content width.
Logos, background colours and web fonts inside headers follow extra rules, described in headers and footers.
Templates with your own data
An invoice, a contract or a report starts as objects in your code. The simplest template in C# is an interpolated raw string, with string.Join for repeating rows and the usual format specifiers for numbers and dates.
1var order = new Order("2026-0142", "Acme GmbH",2[3 new OrderLine("API plan, Business", 1, 79.00m),4 new OrderLine("Extra documents", 2000, 0.01m),5]);67string rows = string.Join("\n", order.Lines.Select(line =>8 $"<tr><td>{line.Description}</td><td>{line.Quantity:N0}</td><td>EUR {line.Quantity * line.UnitPrice:N2}</td></tr>"));910string html = $"""11 <h1>Invoice #{order.Number}</h1>12 <p>Billed to {order.Customer}</p>13 <table>14 <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>15 {rows}16 </table>17 <p>Total EUR {order.Lines.Sum(l => l.Quantity * l.UnitPrice):N2}</p>18 """;1920PdfGateDocumentResponse templated = await client.GeneratePdfAsync(new GeneratePdfRequest21{22 Html = html,23 PageSizeType = GeneratePdfPageSizeType.A4,24});2526await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = templated.Id }))27await using (var file = File.Create("template.pdf"))28{29 await pdf.CopyToAsync(file);30}31Console.WriteLine($"Template -> {templated.Id}");3233record Order(string Number, string Customer, List<OrderLine> Lines);34record OrderLine(string Description, int Quantity, decimal UnitPrice);- Anything that came from a user must be encoded with
WebUtility.HtmlEncodebefore it goes into the string. A customer name containing<script>is otherwise rendered as markup. - For larger documents, use the templating you already have. In ASP.NET Core, render a Razor view to a string with
IRazorViewEngineand send that. Scriban and Handlebars.Net work the same way outside a web project. - Number and date formatting follow the current culture. Set
CultureInfoexplicitly if the server and the reader are in different countries.
For anything bigger than a receipt, the markup belongs in a Razor view, the same one that could serve the invoice as a web page. ASP.NET Core has no built-in way to render a view to a string from a minimal API, but IRazorViewEngine makes it a short service. The view takes a typed model, and @ output is HTML-encoded, so customer data cannot inject markup.
1// Views/Invoice/Invoice.cshtml2@model InvoiceModel3<h1>Invoice #@Model.Number</h1>4<p>Billed to @Model.Customer</p>5<table>6 <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>7 @foreach (var line in Model.Lines)8 {9 <tr>10 <td>@line.Description</td>11 <td>@line.Quantity.ToString("N0")</td>12 <td>EUR @((line.Quantity * line.UnitPrice).ToString("N2"))</td>13 </tr>14 }15</table>16<p>Total EUR @Model.Lines.Sum(l => l.Quantity * l.UnitPrice).ToString("N2")</p>1718// Program.cs19builder.Services.AddControllersWithViews();20builder.Services.AddSingleton<RazorRenderer>();2122// Renders Views/Invoice/Invoice.cshtml with a model, then converts the markup23app.MapGet("/invoices/{number}/razor-pdf", async (string number, RazorRenderer razor, PdfGateClient client) =>24{25 var model = new InvoiceModel(number, "Acme GmbH",26 [27 new InvoiceLine("API plan, Business", 1, 79.00m),28 new InvoiceLine("Extra documents", 2000, 0.01m),29 ]);3031 string html = await razor.RenderAsync("/Views/Invoice/Invoice.cshtml", model);3233 PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest34 {35 Html = html,36 PageSizeType = GeneratePdfPageSizeType.A4,37 });3839 Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = document.Id });40 return Results.File(pdf, "application/pdf", $"invoice-{number}.pdf");41});4243public record InvoiceModel(string Number, string Customer, List<InvoiceLine> Lines);44public record InvoiceLine(string Description, int Quantity, decimal UnitPrice);The renderer resolves the view by path, builds the ViewContext a controller would normally provide, and writes the output into a StringWriter. Register it as a singleton next to AddControllersWithViews.
1// Renders a Razor view to a string so it can be sent as the html parameter.2public sealed class RazorRenderer(3 IRazorViewEngine viewEngine,4 ITempDataProvider tempDataProvider,5 IServiceProvider serviceProvider)6{7 public async Task<string> RenderAsync<TModel>(string viewPath, TModel model)8 {9 var httpContext = new DefaultHttpContext { RequestServices = serviceProvider };10 var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());1112 var view = viewEngine.GetView(executingFilePath: null, viewPath, isMainPage: true).View13 ?? throw new FileNotFoundException($"View not found: {viewPath}");1415 var viewData = new ViewDataDictionary<TModel>(new EmptyModelMetadataProvider(), new ModelStateDictionary())16 {17 Model = model18 };1920 await using var writer = new StringWriter();21 var viewContext = new ViewContext(actionContext, view, viewData,22 new TempDataDictionary(httpContext, tempDataProvider), writer, new HtmlHelperOptions());2324 await view.RenderAsync(viewContext);25 return writer.ToString();26 }27}This endpoint was started and called like the others on the page, and its PDF matched the one from the interpolated string byte for byte. In an MVC project with controllers, inject the same renderer or render inside the controller with the view engine it already has.
Pages that render with JavaScript
Content that appears after the load event, a Chart.js canvas or data from a fetch(), is not in the PDF unless the renderer is told to wait. The pattern that works is a marker element the page inserts when it is done, plus WaitForSelector aimed at that marker. The sample fakes a slow data source with setTimeout.
1const string chartHtml = """2 <h1>Sales report</h1>3 <div id="chart">Loading…</div>4 <script>5 // Stands in for a chart library or a fetch() that finishes later6 setTimeout(() => {7 document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8 document.body.insertAdjacentHTML("beforeend", "<div id=\"chart-ready\"></div>");9 }, 800);10 </script>11 """;1213PdfGateDocumentResponse chart = await client.GeneratePdfAsync(new GeneratePdfRequest14{15 Html = chartHtml,16 WaitForSelector = "#chart-ready",17});1819await using (Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = chart.Id }))20await using (var file = File.Create("wait.pdf"))21{22 await pdf.CopyToAsync(file);23}24Console.WriteLine($"Wait -> {chart.Id}");- If the selector never shows up, the request ends after 30 seconds with a
422. You never get a half-rendered file. WaitForNetworkIdleis the fallback for pages you do not own, andDelayis a fixed pause in milliseconds for when nothing else works.- To change the page before capture, for instance to stop an animation or open a collapsed panel, put the script in
Javascript.
The trade-offs between these options are worked through in waiting for dynamic content.
Error handling
The SDK raises PdfGateException for any unsuccessful response. StatusCode is a nullable HttpStatusCode, ResponseBody is the raw JSON, and the exception message already summarises both. The sample provokes a failure with a loopback URL.
1try2{3 await client.GeneratePdfAsync(new GeneratePdfRequest { Url = "http://localhost:3000/report" });4}5catch (PdfGateException ex)6{7 Console.WriteLine($"API error {(int?)ex.StatusCode}: {ex.Message}");8}Four statuses cover almost every case you will see from C#:
400: a parameter the API rejected, with the field named in the message. The local URL above is one example, a malformedPageRangesis another.401: the key is missing, mistyped or has the wrong prefix.422: the fetch worked but the render did not, in practice almost always aWaitForSelectorwith no match.429: too many renders in flight at once for the plan. Back off briefly and retry.
Transport problems bypass this class and surface as HttpRequestException or TaskCanceledException, so a retry policy should catch those separately.
In ASP.NET Core
Register a single PdfGateClient in the service container and let minimal APIs inject it. Two delivery styles are shown. The first streams the file back through Results.File. The second asks for a short-lived download link via PreSignedUrlExpiresIn and returns JSON, so the bytes never pass through your server.
1using PdfGate.net;2using PdfGate.net.Models;34var builder = WebApplication.CreateBuilder(args);5builder.Services.AddSingleton(new PdfGateClient(builder.Configuration["PdfGate:ApiKey"]!));67var app = builder.Build();89// Returns the PDF directly10app.MapGet("/invoices/{number}/pdf", async (string number, PdfGateClient client) =>11{12 string html = $"<h1>Invoice #{number}</h1><p>Generated on {DateTime.UtcNow:d}</p>";1314 PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest15 {16 Html = html,17 PageSizeType = GeneratePdfPageSizeType.A4,18 });1920 Stream pdf = await client.GetFileAsync(new GetFileRequest { DocumentId = document.Id });21 return Results.File(pdf, "application/pdf", $"invoice-{number}.pdf");22});2324// Returns a temporary download link instead of the bytes25app.MapPost("/invoices/{number}/pdf-link", async (string number, PdfGateClient client) =>26{27 PdfGateDocumentResponse document = await client.GeneratePdfAsync(new GeneratePdfRequest28 {29 Html = $"<h1>Invoice #{number}</h1>",30 PreSignedUrlExpiresIn = 3600,31 });3233 return Results.Ok(new { document.Id, document.FileUrl, document.ExpiresAt });34});3536app.Run();PdfGate:ApiKeybelongs in user secrets during development and in the host's configuration store in production, never in a committedappsettings.json.Results.Filetakes ownership of the stream and disposes it once the response has been written, so nousingis needed there.- A pre-signed link requires file storage to be switched on for the account, otherwise the API answers
422. Lifetimes run from 60 seconds to 24 hours. - A render takes a few seconds and the call blocks until it is done. For bulk jobs, fan out with
Task.WhenAllbut stay under the plan's concurrency limit.
Without the SDK (HttpClient)
Under the SDK there is a single endpoint, POST /generate/pdf, that takes JSON and returns a PDF. HttpClient and PostAsJsonAsync reach it in a dozen lines if you prefer no dependency.
1using System.Net.Http.Headers;2using System.Net.Http.Json;34var apiKey = Environment.GetEnvironmentVariable("PDFGATE_API_KEY")!;56using var http = new HttpClient();7http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);89using HttpResponseMessage response = await http.PostAsJsonAsync(10 "https://api.pdfgate.com/generate/pdf",11 new12 {13 html = "<h1>Hello from HttpClient</h1>",14 pageSizeType = "a4",15 margin = new { top = "20mm", bottom = "20mm" },16 });1718if (!response.IsSuccessStatusCode)19{20 Console.WriteLine(await response.Content.ReadAsStringAsync());21 return;22}2324await using (var file = File.Create("http.pdf"))25{26 await response.Content.CopyToAsync(file);27}28Console.WriteLine("HttpClient -> http.pdf");- The success status is
201, not200. SendingjsonResponse: trueswaps the PDF body for the document record. - A
test_key must be pointed atapi-sandbox.pdfgate.comby hand. The SDK does that routing for you, raw HTTP does not. System.Net.Http.Jsonis in the shared framework from .NET 5 on, so nothing extra is referenced.
Where to go next
- Forms. With
EnableFormFieldsthe inputs in your HTML become editable PDF fields. See the fillable fields guide for the input types, then flatten the PDF in C# to lock them. - Behind a login. Pass a bearer token in
HttpHeadersor basic credentials inAuthentication, as described in authenticated pages. - Cookie banners. A click chain in
ClickSelectorChainSetupcloses them before the capture, covered in removing cookie banners. - Fonts. The renderer has Liberation and a colour emoji font. Anything else must be a web font, which custom fonts and images explains.
- Not writing C#? This walkthrough also exists for Java, Python, Node.js and PHP.
- Looking something up. Request fields are in the HTML to PDF API reference. Client methods not used here are in the C# SDK documentation.
Frequently asked questions
Which .NET versions does the SDK support?
PdfGate.net targets .NET Standard 2.0, so it runs on .NET 6, 8 and 10 as well as .NET Framework 4.6.1 or later. The samples use C# 11 raw string literals, which need .NET 7 or later; on older targets use verbatim strings.
How do I add page numbers to a PDF generated from C#?
Pass Header and Footer HTML with the pageNumber and totalPages CSS classes, and set Margin.Top and Margin.Bottom so there is room to draw them. The renderer fills the classes in on every page.
Can I use Razor views as the template?
Yes. Render the view to a string with IRazorViewEngine and send the string as Html. Scriban and Handlebars.Net work the same way outside a web project. Encode any user input with WebUtility.HtmlEncode first.
What happens when a request fails?
The SDK throws PdfGateException with the HTTP status in StatusCode and the API's JSON in ResponseBody. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.
The parameters used on this page, and the ones it skipped, are all on the HTML to PDF API page.