PythonTutorial
HTML to PDF in Django
Generate PDFs from your Django views in 10 lines.
01. Install
pip install requests
# Set your pdfmyhtml API key in settings.py or .env
# PDFMYHTML_API_KEY=pmh_live_xxxxxxxxxxxxxxxxxxxx02. Basic call
# views.py
import os
import requests
from django.http import HttpResponse
def invoice_pdf(request, invoice_id):
html = render_to_string("invoice.html", {"invoice": invoice})
res = requests.post(
"https://api.pdfmyhtml.com/v1/html-to-pdf",
headers={"X-API-Key": os.environ["PDFMYHTML_API_KEY"]},
json={"html": html, "wait": True},
timeout=30,
)
data = res.json()
pdf = requests.get(data["download_url"]).content
return HttpResponse(pdf, content_type="application/pdf")03. Error handling
# views.py — robust version
import requests, os, logging
from django.http import HttpResponse, HttpResponseServerError
logger = logging.getLogger(__name__)
def invoice_pdf(request, invoice_id):
try:
res = requests.post(
"https://api.pdfmyhtml.com/v1/html-to-pdf",
headers={"X-API-Key": os.environ["PDFMYHTML_API_KEY"]},
json={"html": "<h1>Invoice</h1>", "wait": True},
timeout=30,
)
res.raise_for_status()
data = res.json()
if data.get("status") != "COMPLETED":
return HttpResponseServerError("PDF still processing — try /v1/jobs polling")
pdf = requests.get(data["download_url"], timeout=30).content
return HttpResponse(pdf, content_type="application/pdf")
except requests.HTTPError as e:
logger.error("pdfmyhtml HTTP %s: %s", e.response.status_code, e.response.text)
return HttpResponseServerError("Could not generate PDF")
except requests.Timeout:
logger.error("pdfmyhtml timeout")
return HttpResponseServerError("PDF service timeout")04. Async pattern (high volume)
# Async pattern: enqueue + poll (use this for high-volume / long-running jobs)
# Avoids holding a Django request open for 25s.
import requests, os, time
def enqueue_pdf(html: str) -> str:
"""Returns job_id immediately."""
res = requests.post(
"https://api.pdfmyhtml.com/v1/html-to-pdf",
headers={"X-API-Key": os.environ["PDFMYHTML_API_KEY"]},
json={"html": html, "wait": False},
timeout=10,
)
return res.json()["job_id"]
def get_pdf_url(job_id: str) -> str | None:
"""Returns download_url when ready, None if still processing."""
res = requests.get(
f"https://api.pdfmyhtml.com/v1/jobs/{job_id}",
headers={"X-API-Key": os.environ["PDFMYHTML_API_KEY"]},
)
data = res.json()
if data["status"] == "COMPLETED":
return data["download_url"]
return None
# Use Celery / Django-Q to poll without blocking your request workers.05. Real-world example
# Stripe webhook → invoice PDF → email
# Pair pdfmyhtml with django.core.mail for full automation.
from django.core.mail import EmailMessage
import requests, os
from django.template.loader import render_to_string
def send_invoice_email(stripe_invoice):
html = render_to_string("invoices/template.html", {
"number": stripe_invoice.number,
"total": stripe_invoice.total / 100,
"customer_email": stripe_invoice.customer_email,
})
res = requests.post(
"https://api.pdfmyhtml.com/v1/html-to-pdf",
headers={"X-API-Key": os.environ["PDFMYHTML_API_KEY"]},
json={"html": html, "wait": True},
)
pdf_bytes = requests.get(res.json()["download_url"]).content
email = EmailMessage(
subject=f"Invoice #{stripe_invoice.number}",
body="Your invoice is attached.",
to=[stripe_invoice.customer_email],
)
email.attach(f"invoice-{stripe_invoice.number}.pdf", pdf_bytes, "application/pdf")
email.send()Common gotchas
- 01.Django's `render_to_string()` will not include external CSS unless the URLs in your template are absolute. Use `request.build_absolute_uri()` to build full URLs to your stylesheets if you reference them in the HTML.
- 02.If you use Tailwind in your invoice template, build the CSS to a static file and inline it via `<style>` in the template — Tailwind's CDN script will not run inside Playwright headless Chromium reliably.
- 03.Don't hold a request open for the full 25s sync wait under normal load — use the async enqueue+poll pattern with Celery for any production traffic above ~5 PDFs/sec.
- 04.When deploying to managed hosts (Heroku, Render, Fly), set `PDFMYHTML_API_KEY` as an env var, not in `settings.py`.
Going deeper? Read the full guide on cost trade-offs vs self-hosted Headless Chrome.