Invalid Date

When generating PDFs in C#, developers typically reach for iTextSharp, QuestPDF, or FastReport.NET. However, iTextSharp (iText 5) reached end-of-life in 2023. Its successor, iText 7/8, is licensed under AGPLv3, which requires commercial projects that distribute the software as a service to purchase a paid Commercial License. QuestPDF is MIT-licensed and ergonomic, but it cannot render existing HTML layouts — if you want to turn a Razor view into a PDF, you need a separate conversion step.

FUNBREW PDF API lets you generate high-quality PDFs by posting HTML to an HTTP endpoint. No license costs, no extra server load, built-in Japanese font support — and integrating it into ASP.NET Core takes fewer than a hundred lines of code.

This guide walks you through integrating FUNBREW PDF API into an ASP.NET Core application using HttpClient with DI, Razor template rendering, BackgroundService-based async processing, HttpMessageHandler mocking for tests, and production best practices.

For other languages and frameworks, see the language quickstart guide. For error handling patterns, see the error handling guide.

Why API-Based PDF Generation?

The iTextSharp / iText 7 License Problem

iText 5 (iTextSharp) reached end-of-life in 2023. iText 7/8 core is licensed under AGPLv3. The AGPL "network use is distribution" clause means that if you offer iText-powered PDF generation as a service to end users, you must either open-source your entire application or purchase a Commercial License from iText Group. For proprietary SaaS products, that commercial license adds up quickly.

Library License Commercial Constraint
iTextSharp (iText 5) AGPL / EOL No longer maintained; paid license required
iText 7 / 8 AGPLv3 / Commercial Paid license required for proprietary products
QuestPDF MIT No restriction (no HTML rendering support)
FastReport.NET Commercial Paid; GUI-centric Designer
FUNBREW PDF API SaaS (subscription) No license issues; native HTML support

QuestPDF Cannot Render Existing HTML

QuestPDF uses a Fluent C# API to define PDF layouts programmatically. It excels at building structured documents in code, but it has no built-in HTML renderer. If you want to convert existing Razor views or HTML templates to PDF, you need a separate rendering pipeline. With an API-based approach, you pass the HTML string you already have directly to the endpoint.

Server Resource Savings

Libraries like iText and QuestPDF run PDF generation on your application server. Complex documents or high throughput can put significant pressure on CPU and memory. In containerized environments, you end up provisioning extra capacity specifically for PDF workloads.

With an API-based approach, rendering is offloaded to an external service. Your application only issues HTTP requests, keeping instance sizes smaller and scaling more predictable.

Japanese Font Support

Generating Japanese PDFs with iText or QuestPDF requires bundling fonts like Noto Sans JP, configuring glyph rendering, and handling CJK character spacing manually. FUNBREW PDF comes with Noto Sans JP pre-installed — simply set font-family: 'Noto Sans JP' in your CSS. For more details on Japanese font handling, see the Japanese font guide.

ASP.NET Core Project Setup

This guide targets .NET 8 (ASP.NET Core 8). No additional NuGet packages are required for the HTTP client — HttpClient is part of the standard library.

appsettings.json Configuration

Never hardcode API keys in source code. Read them from environment variables or a secrets manager.

// appsettings.json
{
  "FunbrewPdf": {
    "ApiUrl": "https://pdf.funbrew.cloud/api/v1/generate",
    "TimeoutSeconds": 60
  }
}
// appsettings.Development.json
{
  "FunbrewPdf": {
    "ApiKey": "sk-your-dev-api-key"
  }
}

In production, override with an environment variable:

FunbrewPdf__ApiKey=sk-your-production-api-key

Options Class

Use the IOptions<T> pattern to bind configuration in a type-safe way.

// FunbrewPdfOptions.cs
namespace MyApp.Pdf;

public sealed class FunbrewPdfOptions
{
    public const string SectionName = "FunbrewPdf";

    public string ApiKey { get; set; } = string.Empty;
    public string ApiUrl { get; set; } = "https://pdf.funbrew.cloud/api/v1/generate";
    public int TimeoutSeconds { get; set; } = 60;
}

PdfService Implementation

Encapsulate all PDF API logic in a service class with a dedicated interface, so it can be mocked in tests.

// IPdfService.cs
namespace MyApp.Pdf;

public interface IPdfService
{
    Task<byte[]> GenerateFromHtmlAsync(string html, PdfGenerateOptions? options = null, CancellationToken ct = default);
}
// PdfGenerateOptions.cs
namespace MyApp.Pdf;

public sealed class PdfGenerateOptions
{
    public string Format { get; set; } = "A4";
    public string Orientation { get; set; } = "portrait";
    public string Engine { get; set; } = "quality";
    public MarginOptions? Margin { get; set; }
}

public sealed class MarginOptions
{
    public string Top { get; set; } = "20mm";
    public string Right { get; set; } = "15mm";
    public string Bottom { get; set; } = "20mm";
    public string Left { get; set; } = "15mm";
}
// PdfService.cs
using System.Net.Http.Json;
using Microsoft.Extensions.Options;

namespace MyApp.Pdf;

public sealed class PdfService : IPdfService
{
    private readonly HttpClient _httpClient;
    private readonly FunbrewPdfOptions _options;
    private readonly ILogger<PdfService> _logger;

    public PdfService(
        HttpClient httpClient,
        IOptions<FunbrewPdfOptions> options,
        ILogger<PdfService> logger)
    {
        _httpClient = httpClient;
        _options    = options.Value;
        _logger     = logger;
    }

    public async Task<byte[]> GenerateFromHtmlAsync(
        string html,
        PdfGenerateOptions? options = null,
        CancellationToken ct = default)
    {
        var opts = options ?? new PdfGenerateOptions();

        var payload = new
        {
            html,
            options = new
            {
                format      = opts.Format,
                orientation = opts.Orientation,
                engine      = opts.Engine,
                margin      = opts.Margin is not null ? new
                {
                    top    = opts.Margin.Top,
                    right  = opts.Margin.Right,
                    bottom = opts.Margin.Bottom,
                    left   = opts.Margin.Left,
                } : null,
            },
        };

        var startedAt = DateTime.UtcNow;

        var response = await _httpClient.PostAsJsonAsync(_options.ApiUrl, payload, ct);

        var elapsed = (DateTime.UtcNow - startedAt).TotalMilliseconds;

        if (!response.IsSuccessStatusCode)
        {
            var body = await response.Content.ReadAsStringAsync(ct);
            _logger.LogError(
                "PDF generation failed. Status={Status}, Elapsed={Elapsed}ms, Body={Body}",
                (int)response.StatusCode, elapsed, body);
            throw new PdfGenerationException(
                $"PDF generation failed: HTTP {(int)response.StatusCode} — {body}");
        }

        var pdfBytes = await response.Content.ReadAsByteArrayAsync(ct);

        _logger.LogInformation(
            "PDF generated. Size={Size}bytes, Elapsed={Elapsed}ms",
            pdfBytes.Length, elapsed);

        return pdfBytes;
    }
}
// PdfGenerationException.cs
namespace MyApp.Pdf;

public sealed class PdfGenerationException : Exception
{
    public PdfGenerationException(string message) : base(message) { }
    public PdfGenerationException(string message, Exception inner) : base(message, inner) { }
}

Registering with the DI Container (Program.cs)

Use IHttpClientFactory — it manages HttpClient lifetimes and handles DNS refresh correctly.

// Program.cs
using MyApp.Pdf;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// Bind FunbrewPdf configuration section
builder.Services.Configure<FunbrewPdfOptions>(
    builder.Configuration.GetSection(FunbrewPdfOptions.SectionName));

// Register typed HttpClient
builder.Services.AddHttpClient<IPdfService, PdfService>(
    "FunbrewPdf",
    (sp, client) =>
    {
        var options = sp.GetRequiredService<IOptions<FunbrewPdfOptions>>().Value;
        client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey);
        client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
    });

var app = builder.Build();
// ...
app.Run();

With this setup, IPdfService can be constructor-injected anywhere in your application. The Authorization header is configured once on the HttpClient and sent automatically with every request.

Basic PDF Generation

Returning a PDF from an API Controller

// Controllers/PdfController.cs
using Microsoft.AspNetCore.Mvc;
using MyApp.Pdf;

[ApiController]
[Route("api/[controller]")]
public class PdfController : ControllerBase
{
    private readonly IPdfService _pdfService;

    public PdfController(IPdfService pdfService)
    {
        _pdfService = pdfService;
    }

    [HttpPost("generate")]
    public async Task<IActionResult> Generate(
        [FromBody] GeneratePdfRequest request,
        CancellationToken ct)
    {
        try
        {
            var pdfBytes = await _pdfService.GenerateFromHtmlAsync(request.Html, null, ct);
            return File(pdfBytes, "application/pdf", "document.pdf");
        }
        catch (PdfGenerationException ex)
        {
            return StatusCode(502, new { error = ex.Message });
        }
    }
}

public sealed record GeneratePdfRequest(string Html);

For a deep dive into HTML-to-PDF conversion techniques and CSS optimizations, see the HTML to PDF complete guide.

Razor Template Integration

The typical .NET pattern is to render a Razor view to an HTML string and then send that string to the API. This lets you reuse your existing view templates without any duplication.

IRazorViewRenderer

// IRazorViewRenderer.cs
namespace MyApp.Pdf;

public interface IRazorViewRenderer
{
    Task<string> RenderToStringAsync<TModel>(string viewName, TModel model);
}
// RazorViewRenderer.cs
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Routing;

namespace MyApp.Pdf;

public sealed class RazorViewRenderer : IRazorViewRenderer
{
    private readonly IRazorViewEngine _viewEngine;
    private readonly ITempDataProvider _tempDataProvider;
    private readonly IServiceProvider _serviceProvider;

    public RazorViewRenderer(
        IRazorViewEngine viewEngine,
        ITempDataProvider tempDataProvider,
        IServiceProvider serviceProvider)
    {
        _viewEngine      = viewEngine;
        _tempDataProvider = tempDataProvider;
        _serviceProvider = serviceProvider;
    }

    public async Task<string> RenderToStringAsync<TModel>(string viewName, TModel model)
    {
        var httpContext   = new DefaultHttpContext { RequestServices = _serviceProvider };
        var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());

        using var sw = new StringWriter();

        var viewResult = _viewEngine.FindView(actionContext, viewName, false);
        if (!viewResult.Success)
            throw new InvalidOperationException($"View '{viewName}' not found.");

        var viewData = new ViewDataDictionary<TModel>(
            new EmptyModelMetadataProvider(),
            new ModelStateDictionary())
        {
            Model = model,
        };

        var tempData    = new TempDataDictionary(httpContext, _tempDataProvider);
        var viewContext = new ViewContext(
            actionContext,
            viewResult.View,
            viewData,
            tempData,
            sw,
            new HtmlHelperOptions());

        await viewResult.View.RenderAsync(viewContext);
        return sw.ToString();
    }
}

Register the renderer in Program.cs:

// Program.cs (additions)
builder.Services.AddControllersWithViews(); // required for Razor views
builder.Services.AddScoped<IRazorViewRenderer, RazorViewRenderer>();

Invoice ViewModel and Razor Template

// Models/InvoiceViewModel.cs
namespace MyApp.Models;

public sealed class InvoiceViewModel
{
    public string InvoiceNumber { get; set; } = "";
    public DateOnly IssuedAt { get; set; }
    public DateOnly DueAt { get; set; }
    public CustomerInfo Customer { get; set; } = new();
    public CompanyInfo Company { get; set; } = new();
    public List<InvoiceItem> Items { get; set; } = [];
    public decimal Total => Items.Sum(i => i.Quantity * i.UnitPrice);
}

public sealed record CustomerInfo(string Name, string Address, string Email);
public sealed record CompanyInfo(string Name, string Address, string Email);
public sealed record InvoiceItem(string Name, int Quantity, decimal UnitPrice);
@* Views/Pdf/Invoice.cshtml *@
@model MyApp.Models.InvoiceViewModel
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <style>
    body {
      font-family: -apple-system, 'Segoe UI', sans-serif;
      color: #1a1a1a;
      margin: 0;
      padding: 32px;
    }
    .header { display: flex; justify-content: space-between; margin-bottom: 32px; }
    .invoice-title { font-size: 28px; font-weight: 700; color: #2563eb; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th { background: #f1f5f9; padding: 10px 12px; text-align: left; font-size: 13px; }
    td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 14px; }
    .total-row { text-align: right; font-size: 18px; font-weight: 700; margin-top: 16px; }
    .footer { margin-top: 48px; font-size: 12px; color: #64748b; text-align: center; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="invoice-title">INVOICE</div>
      <p>No. @Model.InvoiceNumber</p>
      <p>Issued: @Model.IssuedAt.ToString("MMMM d, yyyy")</p>
      <p>Due: @Model.DueAt.ToString("MMMM d, yyyy")</p>
    </div>
    <div>
      <p><strong>@Model.Company.Name</strong></p>
      <p>@Model.Company.Address</p>
      <p>@Model.Company.Email</p>
    </div>
  </div>

  <div>
    <p><strong>Bill To: @Model.Customer.Name</strong></p>
    <p>@Model.Customer.Address</p>
  </div>

  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Unit Price</th>
        <th>Amount</th>
      </tr>
    </thead>
    <tbody>
      @foreach (var item in Model.Items)
      {
        <tr>
          <td>@item.Name</td>
          <td>@item.Quantity</td>
          <td>$@item.UnitPrice.ToString("N2")</td>
          <td>$@((item.Quantity * item.UnitPrice).ToString("N2"))</td>
        </tr>
      }
    </tbody>
  </table>

  <div class="total-row">
    Total: $@Model.Total.ToString("N2")
  </div>

  <div class="footer">
    @Model.Company.Name — @Model.Company.Address — @Model.Company.Email
  </div>
</body>
</html>

InvoiceController

// Controllers/InvoiceController.cs
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MyApp.Models;
using MyApp.Pdf;

[Authorize]
public class InvoiceController : Controller
{
    private readonly IPdfService _pdfService;
    private readonly IRazorViewRenderer _renderer;

    public InvoiceController(IPdfService pdfService, IRazorViewRenderer renderer)
    {
        _pdfService = pdfService;
        _renderer   = renderer;
    }

    [HttpGet("/invoices/{invoiceId:int}/pdf")]
    public async Task<IActionResult> Download(int invoiceId, CancellationToken ct)
    {
        // In production, fetch data from your database
        var model = new InvoiceViewModel
        {
            InvoiceNumber = $"INV-{invoiceId:D5}",
            IssuedAt      = DateOnly.FromDateTime(DateTime.Today),
            DueAt         = DateOnly.FromDateTime(DateTime.Today.AddDays(30)),
            Customer      = new CustomerInfo("Acme Corp.", "123 Main St, San Francisco, CA", "billing@acme.com"),
            Company       = new CompanyInfo("My Company Inc.", "456 Market St, San Francisco, CA", "info@mycompany.com"),
            Items =
            [
                new InvoiceItem("FUNBREW PDF Pro Plan", 1, 49.00m),
                new InvoiceItem("Additional API calls (500)", 2, 20.00m),
            ],
        };

        var html = await _renderer.RenderToStringAsync("Pdf/Invoice", model);

        try
        {
            var pdfBytes = await _pdfService.GenerateFromHtmlAsync(html, new PdfGenerateOptions
            {
                Format      = "A4",
                Orientation = "portrait",
                Engine      = "quality",
            }, ct);

            return File(pdfBytes, "application/pdf", $"invoice-{invoiceId}.pdf");
        }
        catch (PdfGenerationException ex)
        {
            return StatusCode(502, ex.Message);
        }
    }
}

For PDF-specific CSS techniques, see the HTML to PDF CSS optimization guide. For automating invoice PDF generation at scale, see the invoice PDF automation guide.

Async Processing with BackgroundService

For large reports or high-volume scenarios, it is better to queue jobs and process them in the background rather than blocking the HTTP request. The following pattern uses Channel<T> as an in-process queue with a BackgroundService worker.

PdfJobQueue

// PdfJobQueue.cs
using System.Threading.Channels;

namespace MyApp.Pdf;

public sealed class PdfJobQueue
{
    private readonly Channel<PdfJob> _channel =
        Channel.CreateBounded<PdfJob>(new BoundedChannelOptions(1000)
        {
            FullMode = BoundedChannelFullMode.Wait,
        });

    public ValueTask EnqueueAsync(PdfJob job, CancellationToken ct = default)
        => _channel.Writer.WriteAsync(job, ct);

    public IAsyncEnumerable<PdfJob> ReadAllAsync(CancellationToken ct = default)
        => _channel.Reader.ReadAllAsync(ct);
}

public sealed record PdfJob(
    string JobId,
    string Html,
    string StoragePath,
    PdfGenerateOptions? Options = null);

PdfWorker (BackgroundService)

// PdfWorker.cs
using MyApp.Pdf;

public sealed class PdfWorker : BackgroundService
{
    private readonly PdfJobQueue _queue;
    private readonly IPdfService _pdfService;
    private readonly ILogger<PdfWorker> _logger;

    public PdfWorker(PdfJobQueue queue, IPdfService pdfService, ILogger<PdfWorker> logger)
    {
        _queue     = queue;
        _pdfService = pdfService;
        _logger    = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("PdfWorker started.");

        await foreach (var job in _queue.ReadAllAsync(stoppingToken))
        {
            try
            {
                _logger.LogInformation("Processing PDF job {JobId}", job.JobId);

                var pdfBytes = await _pdfService.GenerateFromHtmlAsync(
                    job.Html, job.Options, stoppingToken);

                // In production, store to Azure Blob Storage, S3, etc.
                await File.WriteAllBytesAsync(job.StoragePath, pdfBytes, stoppingToken);

                _logger.LogInformation(
                    "PDF job {JobId} completed. Path={Path}", job.JobId, job.StoragePath);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "PDF job {JobId} failed.", job.JobId);
                // Notify via Slack, email, etc.
            }
        }
    }
}

Register in Program.cs:

// Program.cs (additions)
builder.Services.AddSingleton<PdfJobQueue>();
builder.Services.AddHostedService<PdfWorker>();

Enqueueing from a Controller

// Controllers/ReportController.cs
[ApiController]
[Route("api/[controller]")]
public class ReportController : ControllerBase
{
    private readonly PdfJobQueue _queue;
    private readonly IRazorViewRenderer _renderer;

    public ReportController(PdfJobQueue queue, IRazorViewRenderer renderer)
    {
        _queue    = queue;
        _renderer = renderer;
    }

    [HttpPost("{reportId:int}/generate")]
    public async Task<IActionResult> GenerateAsync(int reportId, CancellationToken ct)
    {
        var html = await _renderer.RenderToStringAsync("Pdf/Report", new { ReportId = reportId });

        var jobId      = Guid.NewGuid().ToString("N");
        var outputPath = $"/tmp/reports/report-{reportId}-{DateTime.UtcNow:yyyyMMdd}.pdf";

        await _queue.EnqueueAsync(new PdfJob(jobId, html, outputPath), ct);

        return Accepted(new
        {
            jobId,
            storagePath = outputPath,
            message     = "PDF generation started. The file will be available for download once complete.",
        });
    }
}

For batch processing patterns and high-volume generation strategies, see the PDF batch processing guide. For notifying clients when async PDF generation completes, see the webhook integration guide.

Testing with HttpMessageHandler Mocking

Because you are using IHttpClientFactory, you can swap in a fake HttpMessageHandler in tests to avoid making real HTTP calls.

FakeHttpMessageHandler

// Tests/FakeHttpMessageHandler.cs
using System.Net;

namespace MyApp.Tests;

public sealed class FakeHttpMessageHandler : HttpMessageHandler
{
    private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;

    public FakeHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
    {
        _handler = handler;
    }

    protected override Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
        => Task.FromResult(_handler(request));
}

Unit Tests for PdfService

// Tests/PdfServiceTests.cs
using System.Net;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using MyApp.Pdf;
using Xunit;

namespace MyApp.Tests;

public sealed class PdfServiceTests
{
    private static IPdfService CreateService(
        Func<HttpRequestMessage, HttpResponseMessage> handler)
    {
        var fakeHandler = new FakeHttpMessageHandler(handler);
        var httpClient  = new HttpClient(fakeHandler);

        httpClient.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "test-api-key");

        var options = Options.Create(new FunbrewPdfOptions
        {
            ApiKey         = "test-api-key",
            ApiUrl         = "https://pdf.funbrew.cloud/api/v1/generate",
            TimeoutSeconds = 30,
        });

        return new PdfService(httpClient, options, NullLogger<PdfService>.Instance);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_ReturnsPdfBytes_WhenSuccessful()
    {
        var fakePdfBytes = "%PDF-1.4 fake content"u8.ToArray();

        var service = CreateService(_ =>
            new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent(fakePdfBytes)
                {
                    Headers = { ContentType = new("application/pdf") },
                },
            });

        var result = await service.GenerateFromHtmlAsync("<h1>Test</h1>");

        Assert.Equal(fakePdfBytes, result);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_SendsAuthorizationHeader()
    {
        HttpRequestMessage? capturedRequest = null;

        var service = CreateService(req =>
        {
            capturedRequest = req;
            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent("%PDF"u8.ToArray()),
            };
        });

        await service.GenerateFromHtmlAsync("<h1>Test</h1>");

        Assert.NotNull(capturedRequest);
        Assert.Equal("Bearer", capturedRequest.Headers.Authorization?.Scheme);
        Assert.Equal("test-api-key", capturedRequest.Headers.Authorization?.Parameter);
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_ThrowsPdfGenerationException_OnApiError()
    {
        var service = CreateService(_ =>
            new HttpResponseMessage(HttpStatusCode.InternalServerError)
            {
                Content = new StringContent("Internal Server Error"),
            });

        await Assert.ThrowsAsync<PdfGenerationException>(
            () => service.GenerateFromHtmlAsync("<h1>Test</h1>"));
    }

    [Fact]
    public async Task GenerateFromHtmlAsync_SendsCorrectJsonPayload()
    {
        string? capturedBody = null;

        var service = CreateService(async req =>
        {
            capturedBody = await req.Content!.ReadAsStringAsync();
            return new HttpResponseMessage(HttpStatusCode.OK)
            {
                Content = new ByteArrayContent("%PDF"u8.ToArray()),
            };
        });

        await service.GenerateFromHtmlAsync("<h1>Test</h1>", new PdfGenerateOptions
        {
            Format      = "Letter",
            Orientation = "landscape",
        });

        Assert.Contains("\"html\":\"<h1>Test</h1>\"", capturedBody ?? "");
        Assert.Contains("\"format\":\"Letter\"", capturedBody ?? "");
        Assert.Contains("\"orientation\":\"landscape\"", capturedBody ?? "");
    }
}

Integration Tests with WebApplicationFactory

// Tests/InvoiceControllerTests.cs
using System.Net;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection;
using MyApp.Pdf;
using Xunit;

namespace MyApp.Tests;

public sealed class InvoiceControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public InvoiceControllerTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    private WebApplicationFactory<Program> CreateFactory(byte[]? fakePdf = null)
    {
        var pdf = fakePdf ?? "%PDF-1.4 fake invoice"u8.ToArray();

        return _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                var descriptor = services.SingleOrDefault(
                    d => d.ServiceType == typeof(IPdfService));
                if (descriptor is not null)
                    services.Remove(descriptor);

                services.AddScoped<IPdfService>(_ => new FakePdfService(pdf));
            });
        });
    }

    [Fact]
    public async Task Download_ReturnsPdfFile_WithCorrectHeaders()
    {
        var client   = CreateFactory().CreateClient(
            new WebApplicationFactoryClientOptions { AllowAutoRedirect = false });

        // Authenticate as needed for your app
        var response = await client.GetAsync("/invoices/1/pdf");

        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
        Assert.Equal("application/pdf", response.Content.Headers.ContentType?.MediaType);
        Assert.Contains("invoice-1.pdf",
            response.Content.Headers.ContentDisposition?.FileName ?? "");
    }

    private sealed class FakePdfService : IPdfService
    {
        private readonly byte[] _pdf;
        public FakePdfService(byte[] pdf) => _pdf = pdf;
        public Task<byte[]> GenerateFromHtmlAsync(
            string html, PdfGenerateOptions? options, CancellationToken ct)
            => Task.FromResult(_pdf);
    }
}

Production Tips

Retry with Polly (.NET 8 Resilience)

.NET 8 ships Microsoft.Extensions.Http.Resilience, which wraps Polly and integrates directly with IHttpClientFactory.

dotnet add package Microsoft.Extensions.Http.Resilience
// Program.cs (retry configuration)
using Microsoft.Extensions.Http.Resilience;
using Polly;

builder.Services.AddHttpClient<IPdfService, PdfService>(
    "FunbrewPdf",
    (sp, client) =>
    {
        var options = sp.GetRequiredService<IOptions<FunbrewPdfOptions>>().Value;
        client.DefaultRequestHeaders.Authorization =
            new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", options.ApiKey);
        client.Timeout = TimeSpan.FromSeconds(options.TimeoutSeconds);
    })
    .AddResilienceHandler("pdf-pipeline", pipeline =>
    {
        // Retry up to 3 times with exponential backoff
        pipeline.AddRetry(new HttpRetryStrategyOptions
        {
            MaxRetryAttempts = 3,
            BackoffType      = DelayBackoffType.Exponential,
            Delay            = TimeSpan.FromMilliseconds(500),
            ShouldHandle     = args => ValueTask.FromResult(
                args.Outcome.Result?.StatusCode is
                    System.Net.HttpStatusCode.TooManyRequests or
                    System.Net.HttpStatusCode.ServiceUnavailable or
                    System.Net.HttpStatusCode.GatewayTimeout),
        });

        // Per-attempt timeout of 30 seconds
        pipeline.AddTimeout(TimeSpan.FromSeconds(30));
    });

For a comprehensive error handling and retry strategy reference, see the error handling guide.

Response Caching with IMemoryCache

If you generate the same PDF repeatedly from the same data, cache the result to reduce API calls.

// CachedPdfService.cs (Decorator pattern)
using Microsoft.Extensions.Caching.Memory;

namespace MyApp.Pdf;

public sealed class CachedPdfService : IPdfService
{
    private readonly IPdfService _inner;
    private readonly IMemoryCache _cache;

    public CachedPdfService(IPdfService inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<byte[]> GenerateFromHtmlAsync(
        string html,
        PdfGenerateOptions? options = null,
        CancellationToken ct = default)
    {
        var cacheKey = $"pdf:{ComputeHash(html, options)}";

        if (_cache.TryGetValue(cacheKey, out byte[]? cached))
            return cached!;

        var pdfBytes = await _inner.GenerateFromHtmlAsync(html, options, ct);

        _cache.Set(cacheKey, pdfBytes, new MemoryCacheEntryOptions
        {
            AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(24),
            Size = pdfBytes.Length,
        });

        return pdfBytes;
    }

    private static string ComputeHash(string html, PdfGenerateOptions? options)
    {
        var input = $"{html}|{options?.Format}|{options?.Orientation}|{options?.Engine}";
        var bytes = System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(input));
        return Convert.ToHexString(bytes)[..16];
    }
}

Secure API Key Management

In production, never put the API key in appsettings.json. Use Azure Key Vault, AWS Secrets Manager, or your platform's native secret management:

// Program.cs (Azure Key Vault example)
if (builder.Environment.IsProduction())
{
    var keyVaultUri = builder.Configuration["KeyVaultUri"]!;
    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUri),
        new DefaultAzureCredential());
}

For a full security best practices reference, see the security guide.

Health Check

Register a health check that verifies the PDF API is reachable:

// PdfApiHealthCheck.cs
using Microsoft.Extensions.Diagnostics.HealthChecks;

namespace MyApp.Pdf;

public sealed class PdfApiHealthCheck : IHealthCheck
{
    private readonly IPdfService _pdfService;

    public PdfApiHealthCheck(IPdfService pdfService)
    {
        _pdfService = pdfService;
    }

    public async Task<HealthCheckResult> CheckHealthAsync(
        HealthCheckContext context,
        CancellationToken cancellationToken = default)
    {
        try
        {
            await _pdfService.GenerateFromHtmlAsync(
                "<p>health check</p>",
                new PdfGenerateOptions { Engine = "speed" },
                cancellationToken);

            return HealthCheckResult.Healthy("PDF API is reachable.");
        }
        catch (Exception ex)
        {
            return HealthCheckResult.Unhealthy("PDF API is not reachable.", ex);
        }
    }
}
// Program.cs (health check registration)
builder.Services.AddHealthChecks()
    .AddCheck<PdfApiHealthCheck>("pdf-api");

app.MapHealthChecks("/health");

For a full production operations reference, see the production guide.

Library Comparison

Aspect iTextSharp / iText 7 QuestPDF FUNBREW PDF API
License AGPLv3 / Commercial (paid) MIT SaaS (subscription)
HTML support Limited (XML-like markup) None (Fluent layout API) Chromium-equivalent, full HTML/CSS
CSS support CSS 2 equivalent Custom layout API Latest CSS fully supported
Razor templates Separate conversion needed Not supported Pass the HTML string directly
Japanese text Manual font configuration Manual font configuration Noto Sans JP pre-installed
Docker image Straightforward (include fonts) Straightforward Tiny (external API call only)
Server load Runs on app server Runs on app server Offloaded to external service
Testability Difficult to mock Difficult to mock Mock via HttpMessageHandler
Setup NuGet + font config NuGet only API key only

When to use which:

  • iText 7: You already have a paid license and need low-level PDF specification access.
  • QuestPDF: You prefer building layouts programmatically in C# and don't have existing HTML templates.
  • FUNBREW PDF API: You want to convert existing Razor views to PDF, you're running in Docker/Kubernetes, you need Japanese PDF support, or you need pixel-accurate CSS rendering.

Summary

Integrating FUNBREW PDF API into ASP.NET Core takes only a few dozen lines with HttpClient and DI. The patterns in this guide give you a testable, production-ready PDF generation feature:

  1. DI-first design — Encapsulate logic in IPdfService, keep controllers thin.
  2. Reuse Razor templatesIRazorViewRenderer turns any existing view into an HTML string to send to the API.
  3. Async with BackgroundService — Use Channel<T> and PdfWorker for background processing; don't block the HTTP response.
  4. Mock with FakeHttpMessageHandler — Fast, deterministic tests without hitting the real API.
  5. Resilience with PollyAddResilienceHandler handles transient failures automatically.

Try your HTML in the Playground to see the output before writing any code. When you are ready, check the API documentation for all available options and the pricing page to choose a plan.

Related Articles

Powered by FUNBREW PDF