DEV Community

Andy.
Andy.

Posted on

Using PDFserve from python

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)
  • requests library (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"
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
        },
    },
)
Enter fullscreen mode Exit fullscreen mode

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",
        },
    },
)
Enter fullscreen mode Exit fullscreen mode

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"'},
    )
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)