Generate PDFs in FastAPI with Api2Pdf: Streaming Responses, Background Tasks and Jinja2

Summary. FastAPI apps almost always need a "Download PDF" endpoint sooner or later: invoices, receipts, statements, contracts. Shipping Chromium next to every uvicorn worker is possible, but it fights memory limits, cold starts on serverless hosts, and turns a feature into an ops project. This guide keeps the browser out of your process. Your FastAPI code builds HTML with Jinja2, sends it to the Api2Pdf Headless Chrome endpoint over httpx, and either streams the PDF bytes back with StreamingResponse or hands a hosted FileUrl to a BackgroundTasks job that emails or stores it. You will build a typed client with timeouts and retries, wire it through FastAPI dependency injection, and end with production notes that keep the API key off the client and large documents on V2 XL.

Looking for another stack? There are companion posts for Next.js App Router and Cloudflare Workers.

The architecture in one paragraph

The API key lives only in server-side settings. A route handler loads your data, renders a Jinja2 template to HTML, and calls POST https://v2.api2pdf.com/chrome/pdf/html with the raw API key in the Authorization header (no Bearer prefix). Add ?outputBinary=true and the response body is the PDF itself, which you wrap in a StreamingResponse. Leave it off and you get JSON with a FileUrl that Api2Pdf hosts for 24 hours, which suits background jobs, emails and async workflows. Nothing in this flow runs Chrome in your process, so workers stay light and cold starts stay quick.

Step 1: install and configure

You need an Api2Pdf API key from portal.api2pdf.com. Create a project and install the packages this guide uses:

python -m venv .venv
source .venv/bin/activate
pip install "fastapi[standard]" uvicorn httpx jinja2 pydantic-settings

Put the key in a .env file (and in your host's secret store in production). Never expose it to browsers or mobile clients:

# .env
API2PDF_API_KEY=your-api-key
# optional: https://v2-xl.api2pdf.com for very large documents
API2PDF_BASE_URL=https://v2.api2pdf.com

Load those values with pydantic-settings so a missing key fails at startup instead of at the first PDF request:

# app/settings.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    api2pdf_api_key: str
    api2pdf_base_url: str = "https://v2.api2pdf.com"

@lru_cache
def get_settings() -> Settings:
    return Settings()

Step 2: a small typed httpx client

The official api2pdf package on PyPI (2.1.0) wraps some of the same endpoints, but it is stale relative to the current REST surface (no zip, custom storage or request timeout helpers). For a FastAPI service you want full control: a hard timeout, retries only where they make sense, and an async client that shares a connection pool across requests. The request body takes html (or url), plus the optional fileName, inline and options (the Headless Chrome options such as margins, page size, headers and footers). outputBinary is a query string parameter.

# app/api2pdf.py
from __future__ import annotations

import asyncio
from typing import Any, TypedDict

import httpx

from .settings import Settings

class ChromePdfOptions(TypedDict, total=False):
    landscape: bool
    printBackground: bool
    width: str
    height: str
    marginTop: str
    marginBottom: str
    marginLeft: str
    marginRight: str
    preferCSSPageSize: bool
    displayHeaderFooter: bool
    headerTemplate: str
    footerTemplate: str
    delay: int
    puppeteerWaitForMethod: str
    puppeteerWaitForValue: str

class Api2PdfResponse(TypedDict):
    Success: bool
    Error: str | None
    FileUrl: str | None
    MbOut: float
    Cost: float
    Seconds: float
    ResponseId: str

class Api2PdfError(Exception):
    def __init__(self, message: str, status: int, response_id: str | None = None):
        super().__init__(message)
        self.status = status
        self.response_id = response_id

class Api2PdfClient:
    def __init__(self, settings: Settings, client: httpx.AsyncClient | None = None):
        self._settings = settings
        self._client = client or httpx.AsyncClient(
            base_url=settings.api2pdf_base_url,
            timeout=httpx.Timeout(45.0, connect=10.0),
            headers={
                "Authorization": settings.api2pdf_api_key,  # raw key, no "Bearer"
                "Content-Type": "application/json",
            },
        )
        self._owns_client = client is None

    async def aclose(self) -> None:
        if self._owns_client:
            await self._client.aclose()

    async def _chrome_pdf(
        self,
        source: dict[str, str],
        *,
        binary: bool,
        file_name: str | None = None,
        options: ChromePdfOptions | None = None,
        retries: int = 2,
    ) -> bytes | Api2PdfResponse:
        path = "/chrome/pdf/html" if "html" in source else "/chrome/pdf/url"
        body: dict[str, Any] = {**source, "inline": True, "options": options or {}}
        if file_name:
            body["fileName"] = file_name

        last_exc: Exception | None = None
        for attempt in range(retries + 1):
            try:
                res = await self._client.post(
                    path,
                    params={"outputBinary": str(binary).lower()},
                    json=body,
                )
            except (httpx.TimeoutException, httpx.TransportError) as exc:
                last_exc = exc
                if attempt < retries:
                    await asyncio.sleep(0.5 * 2**attempt)
                    continue
                raise

            content_type = res.headers.get("content-type", "")
            is_json = "json" in content_type

            if res.is_success and binary and not is_json:
                return res.content

            data: Api2PdfResponse | None = res.json() if is_json else None
            if res.is_success and data and data.get("Success"):
                return data

            retryable = res.status_code == 429 or res.status_code >= 500
            if retryable and attempt < retries:
                await asyncio.sleep(0.5 * 2**attempt)
                continue

            raise Api2PdfError(
                (data or {}).get("Error") or f"Api2Pdf returned HTTP {res.status_code}",
                res.status_code,
                (data or {}).get("ResponseId"),
            )

        raise last_exc or Api2PdfError("Api2Pdf request failed", 0)

    async def html_to_pdf_bytes(
        self,
        html: str,
        *,
        file_name: str | None = None,
        options: ChromePdfOptions | None = None,
    ) -> bytes:
        return await self._chrome_pdf(  # type: ignore[return-value]
            {"html": html}, binary=True, file_name=file_name, options=options
        )

    async def html_to_pdf_file(
        self,
        html: str,
        *,
        file_name: str | None = None,
        options: ChromePdfOptions | None = None,
    ) -> Api2PdfResponse:
        return await self._chrome_pdf(  # type: ignore[return-value]
            {"html": html}, binary=False, file_name=file_name, options=options
        )

# Sync helper for BackgroundTasks / thread-pool work
def html_to_pdf_file_sync(
    settings: Settings,
    html: str,
    *,
    file_name: str | None = None,
    options: ChromePdfOptions | None = None,
) -> Api2PdfResponse:
    body: dict[str, Any] = {"html": html, "inline": True, "options": options or {}}
    if file_name:
        body["fileName"] = file_name
    with httpx.Client(
        base_url=settings.api2pdf_base_url,
        timeout=httpx.Timeout(45.0, connect=10.0),
        headers={
            "Authorization": settings.api2pdf_api_key,
            "Content-Type": "application/json",
        },
    ) as client:
        res = client.post("/chrome/pdf/html", json=body)
        data = res.json()
        if not res.is_success or not data.get("Success"):
            raise Api2PdfError(
                data.get("Error") or f"Api2Pdf returned HTTP {res.status_code}",
                res.status_code,
                data.get("ResponseId"),
            )
        return data

A few details that matter in production:

  • The Authorization header is the raw key. No Bearer prefix. A wrong key returns HTTP 401 with {"Success": false, "Error": "Invalid API key", ...}, even when you asked for binary output, which is why the client checks the Content-Type before treating the body as a PDF.
  • A request with no key at all returns 402 with an x402 payment offer instead of a plain error. If you see a 402 in your logs, the environment variable is missing in that environment.
  • Retry only what is retryable. Network errors, timeouts, 429 and 5xx get two more attempts with backoff; 400 and 401 fail immediately, because sending the same bad request again will not fix it.
  • The JSON response carries Success, Error, FileUrl, MbOut, Cost, Seconds and ResponseId. Log ResponseId on failures; support can trace a request with it.

Step 3: Jinja2 invoice HTML with escaping

Any templating approach works, because Api2Pdf only sees the final HTML. Jinja2 is the natural fit in a Python app, and it auto-escapes by default when you load templates from files (or pass autoescape=True). Escape every value that comes from a user or a database, or a customer named <script> ends up executing inside the renderer.

# templates/invoice.html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page { size: Letter; margin: 0.6in; }
    body { font: 12px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; color: #111; }
    h1 { font-size: 22px; margin: 0 0 4px; }
    table { width: 100%; border-collapse: collapse; margin-top: 24px; }
    th, td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
    thead { display: table-header-group; }
    tr { break-inside: avoid; }
    .num { text-align: right; }
    .total td { font-weight: 700; border-bottom: 0; }
  </style>
</head>
<body>
  <h1>Invoice {{ invoice.number }}</h1>
  <div>{{ invoice.customer.name }} &middot; {{ invoice.customer.email }}</div>
  <div>Issued {{ invoice.issued_at }}</div>
  <table>
    <thead>
      <tr><th>Item</th><th class="num">Qty</th>
          <th class="num">Unit</th><th class="num">Amount</th></tr>
    </thead>
    <tbody>
      {% for line in invoice.lines %}
      <tr>
        <td>{{ line.description }}</td>
        <td class="num">{{ line.qty }}</td>
        <td class="num">{{ "%.2f"|format(line.unit_cents / 100) }}</td>
        <td class="num">{{ "%.2f"|format(line.qty * line.unit_cents / 100) }}</td>
      </tr>
      {% endfor %}
    </tbody>
    <tfoot>
      <tr class="total">
        <td colspan="3">Total</td>
        <td class="num">{{ "%.2f"|format(total_cents / 100) }}</td>
      </tr>
    </tfoot>
  </table>
</body>
</html>
# app/templates.py
from pathlib import Path
from jinja2 import Environment, FileSystemLoader, select_autoescape

env = Environment(
    loader=FileSystemLoader(Path(__file__).resolve().parent.parent / "templates"),
    autoescape=select_autoescape(["html", "xml"]),
)

def render_invoice_html(invoice: dict) -> str:
    total = sum(l["qty"] * l["unit_cents"] for l in invoice["lines"])
    return env.get_template("invoice.html").render(
        invoice=invoice, total_cents=total
    )

@page { size: Letter } together with the preferCSSPageSize option lets the CSS decide the paper size and margins. thead { display: table-header-group } repeats the column headers on every page of a long invoice, and break-inside: avoid keeps rows from splitting across pages.

Step 4: StreamingResponse that returns PDF bytes

This is the pattern most apps want: a URL such as /invoices/42/pdf that you can link to, open in a new tab, or download with ?download=1. outputBinary=true makes Api2Pdf return the PDF body directly, and FastAPI's StreamingResponse (or a plain Response) passes those bytes straight to the browser without writing a temp file.

# app/main.py (excerpt)
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.responses import Response, StreamingResponse

from .api2pdf import Api2PdfClient, Api2PdfError, ChromePdfOptions
from .settings import Settings, get_settings
from .templates import render_invoice_html

# get_invoice stands in for your own data layer
async def get_invoice(invoice_id: str) -> dict | None: ...

@app.get("/invoices/{invoice_id}/pdf")
async def download_invoice_pdf(
    invoice_id: str,
    download: bool = Query(False),
    client: Api2PdfClient = Depends(get_api2pdf),
):
    # Authorize the caller here: this route hands out the document.
    invoice = await get_invoice(invoice_id)
    if not invoice:
        raise HTTPException(status_code=404, detail="Invoice not found")

    html = render_invoice_html(invoice)
    options: ChromePdfOptions = {
        "preferCSSPageSize": True,
        "printBackground": True,
    }
    try:
        pdf = await client.html_to_pdf_bytes(
            html,
            file_name=f"invoice-{invoice['number']}.pdf",
            options=options,
        )
    except Api2PdfError as err:
        # Log ResponseId so support can trace the request
        print({"event": "pdf_failed", "id": invoice_id, "status": err.status, "response_id": err.response_id})
        raise HTTPException(status_code=502, detail="Could not generate the PDF") from err

    disposition = "attachment" if download else "inline"
    filename = f"invoice-{invoice['number']}.pdf"
    return Response(
        content=pdf,
        media_type="application/pdf",
        headers={
            "Content-Disposition": f'{disposition}; filename="{filename}"',
            "Cache-Control": "private, no-store",
        },
    )

StreamingResponse works the same way if you prefer an iterator (for example when you buffer from disk). For an in-memory bytes body, a plain Response is simpler and equally correct. Either way, set Cache-Control: private, no-store so CDNs and shared caches never store a customer document.

# equivalent StreamingResponse form
return StreamingResponse(
    iter([pdf]),
    media_type="application/pdf",
    headers={
        "Content-Disposition": f'{disposition}; filename="{filename}"',
        "Cache-Control": "private, no-store",
    },
)

Step 5: dependency injection and lifespan

Wire the client once per process and share the httpx.AsyncClient connection pool. FastAPI's lifespan hook opens and closes it cleanly:

# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends, Request

from .api2pdf import Api2PdfClient
from .settings import get_settings

@asynccontextmanager
async def lifespan(app: FastAPI):
    settings = get_settings()
    client = Api2PdfClient(settings)
    app.state.api2pdf = client
    try:
        yield
    finally:
        await client.aclose()

app = FastAPI(lifespan=lifespan)

def get_api2pdf(request: Request) -> Api2PdfClient:
    return request.app.state.api2pdf

Routes take client: Api2PdfClient = Depends(get_api2pdf). In tests, override that dependency with a fake that returns fixed PDF bytes, so you never hit the live API from CI.

Step 6: BackgroundTasks that generate a FileUrl

Sometimes you do not want the bytes to pass through the request at all, for example when a user clicks "Email me the statement" or you want to store the PDF after the response has already returned 202. Leave outputBinary off, get a FileUrl hosted for 24 hours, and do the slow work in a background task. FastAPI's BackgroundTasks runs after the response is sent; use the sync httpx.Client helper there so you are not mixing event-loop lifetimes:

from fastapi import BackgroundTasks, status
from fastapi.responses import JSONResponse

from .api2pdf import html_to_pdf_file_sync, Api2PdfError
from .settings import Settings, get_settings

def email_invoice_pdf(settings: Settings, invoice: dict, to_address: str) -> None:
    html = render_invoice_html(invoice)
    try:
        result = html_to_pdf_file_sync(
            settings,
            html,
            file_name=f"invoice-{invoice['number']}.pdf",
            options={"preferCSSPageSize": True, "printBackground": True},
        )
    except Api2PdfError as err:
        print({"event": "bg_pdf_failed", "response_id": err.response_id, "status": err.status})
        return

    file_url = result["FileUrl"]
    response_id = result["ResponseId"]
    # send_email(to_address, subject=..., body=..., attachment_url=file_url)
    print({"event": "bg_pdf_ready", "response_id": response_id, "file_url": file_url})
    # Optional: DELETE /file/{responseId} after you have copied the PDF into your own storage

@app.post("/invoices/{invoice_id}/email-pdf", status_code=status.HTTP_202_ACCEPTED)
async def email_invoice(
    invoice_id: str,
    background_tasks: BackgroundTasks,
    settings: Settings = Depends(get_settings),
):
    # Authorize the caller here too: this endpoint triggers a billed render.
    invoice = await get_invoice(invoice_id)
    if not invoice:
        raise HTTPException(status_code=404, detail="Invoice not found")

    background_tasks.add_task(
        email_invoice_pdf, settings, invoice, invoice["customer"]["email"]
    )
    return JSONResponse(
        {"status": "queued", "invoice_id": invoice_id},
        status_code=status.HTTP_202_ACCEPTED,
    )

Two things to remember about this pattern: the route is a public POST endpoint, so authorize inside it, not only in the UI; and the FileUrl expires after 24 hours. If the PDF has to live longer, download it into your own storage, or use useCustomStorage to have Api2Pdf PUT it straight into an S3 presigned URL (covered in Batch PDF generation: zip PDFs and upload to S3). For sensitive documents, delete the hosted copy as soon as you are done with DELETE /file/{responseId}.

For heavier queues (Celery, ARQ, RQ, Dramatiq), the same sync helper works inside a worker process. Keep the API key in the worker's environment; do not pass it through the job payload.

Production notes

  • Never expose the key to clients. Browser and mobile apps must call your FastAPI routes. Your routes call Api2Pdf. Put API2PDF_API_KEY only in server env / secret managers.
  • Timeouts. The client above uses a 45s read timeout. Match that to your reverse proxy and host limits (uvicorn, Gunicorn, Cloud Run, Fly, Railway). A typical invoice renders in a second or two; large reports need more headroom.
  • Log ResponseId. Every JSON response includes it. Include it in error logs so support can pull the exact request.
  • Very large or slow documents. Hundred-page reports and image-heavy catalogs can exceed the standard limits. Set API2PDF_BASE_URL=https://v2-xl.api2pdf.com to use V2 XL (4 CPU, 8 GB of memory and a 5 minute timeout per request, at 10x the standard price), and raise the httpx timeout to match.
  • Do not regenerate what has not changed. An issued invoice is immutable. Render it once, store the PDF in your own bucket, and serve it from there on later downloads.
  • Retries can bill twice. If a request times out on your side, Api2Pdf may still have finished the render. Keep the client timeout generous and the retry count low.
  • No rate limits on the Api2Pdf side; your own process and host concurrency still matter. Share one AsyncClient per process as shown above.
  • Cleanup. Hosted files expire after 24 hours. Call DELETE /file/{responseId} to remove one immediately.
  • Pricing is pay as you go, based on the bandwidth of the output plus compute time; see pricing. The Cost field on every JSON response tells you what a call cost.

If you are weighing this against running Chromium yourself, the numbers are in Puppeteer vs Playwright vs HTML-to-PDF API: the real cost of PDF generation.

Troubleshooting

SymptomLikely causeFix
401 with "Invalid API key"Wrong key, or a Bearer prefix in the headerSend the raw key in Authorization
402 with an x402Version bodyNo Authorization header at all, usually a missing env varSet API2PDF_API_KEY in that environment and restart uvicorn
Pydantic validation error on startupAPI2PDF_API_KEY missing from .env / process envAdd the variable; Settings fails closed on purpose
Request hangs then 504 from your proxyProxy timeout lower than the render timeRaise proxy / host timeouts, or use V2 XL for heavy documents
Background task never sends the emailProcess exited before the task finished (e.g. short-lived serverless)Use a real queue (ARQ/Celery) on serverless; BackgroundTasks is for long-lived workers
Missing fonts or blank chartsChrome printed before assets or data loadedUse puppeteerWaitForMethod / networkidle0 or a delay, and inline critical fonts

Wrap-up

In FastAPI, PDF generation fits in a few small modules: settings, a typed httpx client, a Jinja2 template, and either a StreamingResponse download route or a BackgroundTasks job that works from a hosted FileUrl. Headless Chrome stays on Api2Pdf's side, so your workers stay small and you get the same rendering engine as Chrome's print preview. Grab an API key at portal.api2pdf.com, drop the client into your project, and wire up one route. The full endpoint reference is in the interactive API docs.