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
Authorizationheader is the raw key. NoBearerprefix. A wrong key returns HTTP401with{"Success": false, "Error": "Invalid API key", ...}, even when you asked for binary output, which is why the client checks theContent-Typebefore treating the body as a PDF. - A request with no key at all returns
402with an x402 payment offer instead of a plain error. If you see a402in your logs, the environment variable is missing in that environment. - Retry only what is retryable. Network errors, timeouts,
429and5xxget two more attempts with backoff;400and401fail immediately, because sending the same bad request again will not fix it. - The JSON response carries
Success,Error,FileUrl,MbOut,Cost,SecondsandResponseId. LogResponseIdon 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 }} · {{ 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_KEYonly 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.comto 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
AsyncClientper 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
Costfield 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
| Symptom | Likely cause | Fix |
|---|---|---|
401 with "Invalid API key" | Wrong key, or a Bearer prefix in the header | Send the raw key in Authorization |
402 with an x402Version body | No Authorization header at all, usually a missing env var | Set API2PDF_API_KEY in that environment and restart uvicorn |
| Pydantic validation error on startup | API2PDF_API_KEY missing from .env / process env | Add the variable; Settings fails closed on purpose |
| Request hangs then 504 from your proxy | Proxy timeout lower than the render time | Raise proxy / host timeouts, or use V2 XL for heavy documents |
| Background task never sends the email | Process 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 charts | Chrome printed before assets or data loaded | Use 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.



