PDFserve
I made PDF serve to create an API endpoint that allows you to generate PDFs from HTML templates. It's hosted entirely in europe, stores no copies.
Prerequisites
- Python 3.9+
- A pdfserve.eu account and API key (free tier: 50 renders/month)
-
requestslibrary (pip install requests)
Step 1: Store Your API Key as an Environment Variable
Never hardcode credentials. Store your key as an environment variable:
export PDFSERVE_KEY="pdfs_live_xxxxxxxxxxxx"
In production, use your secrets manager (AWS Secrets Manager, HashiCorp Vault, Kubernetes Secrets, etc.).
Step 2: Basic Render — HTML to PDF
The simplest possible call:
import os
import requests
def render_pdf(html: str, filename: str = "document.pdf") -> bytes:
resp = requests.post(
"https://api.pdfserve.eu/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFSERVE_KEY']}"},
json={
"html": html,
"options": {"filename": filename},
},
)
resp.raise_for_status()
return resp.content
# Generate an invoice
html = """
<html>
<body>
<h1>Invoice #1024</h1>
<p>Client: Acme GmbH</p>
<p>Amount: €1,500.00</p>
</body>
</html>
"""
pdf_bytes = render_pdf(html, "invoice-1024.pdf")
with open("invoice-1024.pdf", "wb") as f:
f.write(pdf_bytes)
The response body is the raw PDF. There's no second request to a storage URL — the document exists in memory for the duration of the HTTP connection and nowhere else.
Step 3: Render with a Jinja2 Template
In a real application you'll be templating. Here's the pattern with Jinja2:
import os
from jinja2 import Environment, FileSystemLoader
import requests
env = Environment(loader=FileSystemLoader("templates"))
def render_invoice(invoice: dict) -> bytes:
html = env.get_template("invoice.html").render(**invoice)
resp = requests.post(
"https://api.pdfserve.eu/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFSERVE_KEY']}"},
json={
"html": html,
"options": {
"filename": f"invoice-{invoice['number']}.pdf",
"page_size": "A4",
"margin": "2.5cm 2cm",
},
},
)
resp.raise_for_status()
return resp.content
The template file stays on your server. The rendered HTML goes to pdfserve.eu for the render step — in memory, never stored.
Step 4: Add Headers, Footers, and Page Numbers
For multi-page documents like contracts or reports, you'll want consistent headers and footers:
resp = requests.post(
"https://api.pdfserve.eu/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFSERVE_KEY']}"},
json={
"html": document_html,
"options": {
"page_size": "A4",
"margin": "3cm 2cm",
"header_html": """
<div style="font: 9pt sans-serif; color: #6b7280; width: 100%;">
Acme GmbH — Confidential
</div>
""",
"footer_html": """
<div style="font: 9pt sans-serif; color: #6b7280;">
Generated {{ date }}
</div>
""",
"page_numbers": True,
},
},
)
Step 5: Password-Protect Sensitive Documents
For documents containing personal data that will be emailed or downloaded, AES-256 encryption adds a layer of protection at rest:
resp = requests.post(
"https://api.pdfserve.eu/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFSERVE_KEY']}"},
json={
"html": medical_summary_html,
"options": {
"filename": f"summary-{patient_id}.pdf",
"password": generate_document_password(patient_id),
"watermark": "CONFIDENTIAL",
},
},
)
The watermark stamps diagonal text on every page. The password encrypts the PDF — recipients must enter it to open the file.
Step 6: Stream to Django or FastAPI
Don't write the PDF to disk on your server either. Stream it straight to the HTTP response:
FastAPI:
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
import io
app = FastAPI()
@app.get("/invoices/{invoice_id}/pdf")
async def get_invoice_pdf(invoice_id: int):
invoice = fetch_invoice(invoice_id)
html = render_invoice_template(invoice)
pdf_bytes = render_pdf(html, f"invoice-{invoice_id}.pdf")
return StreamingResponse(
io.BytesIO(pdf_bytes),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="invoice-{invoice_id}.pdf"'},
)
Django:
from django.http import HttpResponse
def invoice_pdf(request, invoice_id):
invoice = get_object_or_404(Invoice, pk=invoice_id)
html = render_to_string("invoices/pdf.html", {"invoice": invoice})
pdf_bytes = render_pdf(html, f"invoice-{invoice_id}.pdf")
response = HttpResponse(pdf_bytes, content_type="application/pdf")
response["Content-Disposition"] = f'attachment; filename="invoice-{invoice_id}.pdf"'
return response
The PDF bytes exist in your server's process memory for the duration of the request and are then garbage collected. Neither your server nor pdfserve.eu ever writes the document to disk.
Step 7: Handle Errors Gracefully
import os
import requests
from requests.exceptions import HTTPError
class RenderError(Exception):
pass
def render_pdf(html: str, options: dict | None = None) -> bytes:
try:
resp = requests.post(
"https://api.pdfserve.eu/v1/render",
headers={"Authorization": f"Bearer {os.environ['PDFSERVE_KEY']}"},
json={"html": html, "options": options or {}},
timeout=30,
)
resp.raise_for_status()
return resp.content
except HTTPError as e:
detail = e.response.json().get("detail", str(e))
raise RenderError(f"PDF render failed: {detail}") from e
Error responses are JSON with a detail field. A 429 means your monthly render limit is reached; a 422 means a bad request body.
Top comments (0)