ScreenshotNeo

BlogHTML to image & PDF

How to Return a PDF File from a C# Web API

Return PDF bytes or streams correctly from ASP.NET Core controllers and Minimal APIs, with headers, ranges, errors, and production guidance.

By the ScreenshotNeo team1 October 20267 min read

Return a PDF as an HTTP file response, not as JSON containing encoded bytes. In ASP.NET Core, use ControllerBase.File for controllers or TypedResults.File for Minimal APIs, set the media type to application/pdf, and optionally provide a download name.

1. Return PDF bytes from an ASP.NET Core controller

Use the byte-array overload when the complete document is already in memory. ASP.NET Core creates a FileContentResult and writes the bytes to the response.

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/reports")]
public sealed class ReportsController : ControllerBase
{
    [HttpGet("{id:int}/pdf")]
    public IActionResult GetPdf(int id)
    {
        byte[] pdf = GenerateReport(id);
        return File(pdf, "application/pdf", "report.pdf");
    }

    private static byte[] GenerateReport(int id)
    {
        // Replace this with your PDF-generation code.
        return System.IO.File.ReadAllBytes($"reports/{id}.pdf");
    }
}

The second argument identifies the representation as a PDF. The third is the suggested filename. Clients can then save the response as report.pdf or display it according to their own behavior.

Microsoft documents this byte-array pattern in its ASP.NET Core response guidance: Minimal API response documentation and the ControllerBase.File API reference.

2. Return a PDF stream

Use a stream when the PDF source naturally provides one, such as a file store, database blob, or PDF generator. The stream overload returns a FileStreamResult.

[HttpGet("{id:int}/download")]
public IActionResult Download(int id)
{
    Stream pdfStream = OpenPdfStream(id);
    return File(pdfStream, "application/pdf", "report.pdf");
}

private static Stream OpenPdfStream(int id)
{
    return new FileStream(
        $"reports/{id}.pdf",
        FileMode.Open,
        FileAccess.Read,
        FileShare.Read,
        bufferSize: 64 * 1024,
        useAsync: true);
}

Do not dispose the stream before returning the result. ASP.NET Core disposes the supplied stream after the response has been sent, so it must remain usable during response execution.

3. Minimal API version

Minimal APIs use TypedResults.File for the same operation.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/reports/{id:int}/pdf", (int id) =>
{
    byte[] pdf = GenerateReport(id);
    return TypedResults.File(pdf, "application/pdf", "report.pdf");
});

app.Run();

static byte[] GenerateReport(int id)
{
    return File.ReadAllBytes($"reports/{id}.pdf");
}

For a stream, pass the stream instead of the byte array:

app.MapGet("/reports/{id:int}/download", (int id) =>
{
    Stream stream = new FileStream($"reports/{id}.pdf", FileMode.Open, FileAccess.Read,
        FileShare.Read, 64 * 1024, useAsync: true);
    return TypedResults.File(stream, "application/pdf", "report.pdf");
});

4. Choose bytes or a stream

Situation Use Reason
PDF is already fully generated in memory byte[] Simple and direct; returns a FileContentResult.
PDF comes from a file or streaming source Stream Matches the source and avoids an extra materialization step.
Controller action ControllerBase.File Framework file-result overloads are available on the controller.
Minimal API route TypedResults.File Typed Minimal API response.

The framework does not establish a universal size threshold for this choice. Base it on how the PDF is produced and on your application’s memory and throughput characteristics.

5. Content disposition and filenames

Passing a filename supplies a suggested download name. If you need to control whether a browser displays the PDF inline or treats it as an attachment, set the response content-disposition behavior deliberately and verify it with the clients you support. The filename parameter alone does not guarantee identical browser behavior.

[HttpGet("inline")]
public IActionResult Inline()
{
    byte[] pdf = GenerateReport(42);
    Response.Headers.ContentDisposition = "inline; filename=report.pdf";
    return File(pdf, "application/pdf");
}

[HttpGet("attachment")]
public IActionResult Attachment()
{
    byte[] pdf = GenerateReport(42);
    Response.Headers.ContentDisposition = "attachment; filename=report.pdf";
    return File(pdf, "application/pdf");
}

Prefer the framework filename overload for ordinary downloads. Use an explicit header only when your API contract requires a particular disposition.

6. Enable range processing when required

ControllerBase.File provides overloads with an enableRangeProcessing argument. Enable it when clients need byte-range requests, such as resumable transfers or media-style seeking. It is optional for a normal PDF download.

[HttpGet("range")]
public IActionResult RangeEnabled()
{
    Stream stream = OpenPdfStream(42);
    return File(stream, "application/pdf", "report.pdf", enableRangeProcessing: true);
}

With range processing enabled, the API can return 206 Partial Content for valid ranges and 416 Range Not Satisfiable for invalid ones. Test proxy and cache behavior if ranges are part of your contract.

7. Stored files and authorization

A file result is useful when access depends on authentication, authorization, routing, auditing, or application logic. Validate the requested report before opening its stream, and never build a path directly from untrusted input without constraining it to an allowed identifier or directory.

[HttpGet("secure/{id:int}")]
public IActionResult SecureDownload(int id)
{
    if (!User.Identity?.IsAuthenticated ?? true)
        return Unauthorized();

    string path = GetAuthorizedReportPath(User, id);
    if (path is null || !System.IO.File.Exists(path))
        return NotFound();

    var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read,
        64 * 1024, useAsync: true);
    return File(stream, "application/pdf", $"report-{id}.pdf");
}

Static-file middleware may be a better fit for public, unchanging files. Use a file result when the endpoint must enforce application rules.

8. Return useful errors

Do not return a success status with an HTML error page or JSON body while claiming the response is a PDF. Check generation and storage failures before creating the file result.

[HttpGet("safe/{id:int}")]
public IActionResult Safe(int id)
{
    try
    {
        byte[] pdf = GenerateReport(id);
        return File(pdf, "application/pdf", "report.pdf");
    }
    catch (ReportNotFoundException)
    {
        return NotFound(new { error = "Report was not found." });
    }
    catch (UnauthorizedAccessException)
    {
        return StatusCode(StatusCodes.Status403Forbidden);
    }
    catch (PdfGenerationException)
    {
        return Problem(
            statusCode: StatusCodes.Status500InternalServerError,
            title: "PDF generation failed");
    }
}

9. Client examples

cURL

curl -fL "https://api.example.com/api/reports/42/pdf" \
  -H "Authorization: Bearer $TOKEN" \
  -o report.pdf

Python

import requests

response = requests.get(
    "https://api.example.com/api/reports/42/pdf",
    headers={"Authorization": f"Bearer {TOKEN}"},
    timeout=90,
)
response.raise_for_status()
with open("report.pdf", "wb") as file:
    file.write(response.content)

Node.js

const response = await fetch("https://api.example.com/api/reports/42/pdf", {
  headers: { Authorization: `Bearer ${process.env.TOKEN}` }
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const bytes = Buffer.from(await response.arrayBuffer());
await import("node:fs/promises").then(fs => fs.writeFile("report.pdf", bytes));

10. Troubleshooting

Symptom Likely cause Fix
Client says the file is not a PDF Wrong content type, truncated bytes, or an error body saved as a PDF. Return application/pdf, call EnsureSuccessStatusCode/raise_for_status on clients, and inspect the first bytes for the PDF signature.
Browser downloads an unexpected name No filename or a client-specific content-disposition rule. Pass a filename such as report.pdf and verify the response headers.
Empty or truncated output Stream was disposed or positioned incorrectly before execution. Keep the stream open until ASP.NET Core finishes; seek to the correct position when your source requires it.
HTTP 416 Invalid byte range while range processing is enabled. Remove the invalid Range header or request a range within the resource length.
HTTP 404 Report ID or stored file does not exist. Validate the identifier and return a deliberate NotFound response.
HTTP 500 during generation PDF library, template, data, or storage failure. Log the underlying exception server-side and return a problem response without exposing internals.
Large memory usage Many large PDFs are materialized as byte arrays concurrently. Use stream-backed results where the source supports them and apply request limits appropriate to your workload.

11. Performance, reliability, and cost notes

  • Generate a PDF once per request and avoid unnecessary byte-array copies.
  • Use asynchronous I/O in storage and generation libraries where available.
  • Set request cancellation and upstream timeouts so abandoned downloads do not consume work indefinitely.
  • For repeatable reports, cache the generated document using an application-level key and invalidate it when source data changes.
  • Measure generation time, response size, status code, and cancellation rate. Do not assume a stream automatically reduces every resource cost; its benefit depends on the source and pipeline.
  • Protect report endpoints with authorization and rate limits when generation is expensive.

12. Or skip the browser setup

If your PDF workflow starts with capturing a webpage, ScreenshotNeo provides a single API request for screenshots or PDFs. Its capture flow accepts cookie and consent banners before the shot and removes more than 60 known consent platforms, newsletter popups, and chat widgets; each step can be turned off. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and responses identify the result with X-Page-Verdict and X-Billed headers. It also provides an MCP server for AI agents and supports PDF options such as paper size, margins, landscape mode, and page ranges.

See the ScreenshotNeo API documentation for the PDF parameters and response details.

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

There are 1,000 screenshots per month free with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

13. FAQ

Should I return PDF bytes as JSON?

No. Return a framework file result with the PDF media type so clients receive binary content directly.

Which content type should a PDF endpoint use?

Use application/pdf.

When should I enable range processing?

Enable it when your clients need byte-range requests. It is not required for an ordinary download.

Who disposes a stream returned by File?

ASP.NET Core disposes the supplied stream after the response is sent. Keep it alive until then.

Can Minimal APIs return a PDF?

Yes. Use TypedResults.File with a byte array or stream.