castpdf
The official Python client for the CastPDF API: render PDFs from your templates or from HTML, issue EU e-invoices (Factur-X, ZUGFeRD, XRechnung), run batches and manage webhooks.
- Sync (
CastPDF) and asyncio (AsyncCastPDF) clients on httpx. - Type hints throughout (
py.typed), with TypedDicts for requests and responses. - Retries with backoff on rate limits, server errors and network errors; idempotency keys; typed exceptions.
- A webhook signature helper for the
CastPDF-Signatureheader.
Requires Python 3.10 or newer.
Install
pip install castpdf
Quick start
Create an API key in the CastPDF dashboard (cpdf_test_... keys make watermarked test documents that are not counted).
from castpdf import CastPDF
client = CastPDF() # reads CASTPDF_API_KEY, or pass CastPDF("cpdf_live_...")
pdf = client.pdfs.create(
template_id="0b6f0c2e-6d4e-4a43-9a43-3f0f6c1b7a10",
data={"customer": {"name": "Ada Lovelace"}, "items": [{"name": "Consulting", "qty": 2, "price": 120}]},
test=True,
)
print(pdf["url"]) # signed download link, valid until pdf["expires_at"]
Async:
import asyncio
from castpdf import AsyncCastPDF
async def main() -> None:
async with AsyncCastPDF() as client:
pdf = await client.pdfs.create(html="<h1>Hello</h1>", test=True)
print(pdf["url"])
asyncio.run(main())
Both clients can be used as context managers (with CastPDF() as client:) to close their connection pool, and take
http_client= to use your own httpx.Client / httpx.AsyncClient (proxies, custom transports).
PDFs
| Method | Request | Returns |
|---|---|---|
pdfs.create(**params) |
response: "url" |
dict with a signed url (the document is stored) |
pdfs.create_file(**params) |
response: "binary" |
PdfFile(content, content_type, filename, document_id, pages, warnings) |
pdfs.create_async(**params) |
async: true |
{"id", "status": "queued"}; the result arrives as a webhook |
from pathlib import Path
# From raw HTML, straight to a file
f = client.pdfs.create_file(html="<h1>Hello {{ name }}</h1>", data={"name": "Ada"}, format="A4", margins="20mm")
Path(f.filename or "out.pdf").write_bytes(f.content)
# Queued: poll until done (or listen for the pdf.created webhook)
queued = client.pdfs.create_async(template_id=template_id, data=data, metadata={"order": "1042"})
doc = client.pdfs.wait_for(queued["id"])
# Look a document up again (a fresh signed url while it is stored)
again = client.pdfs.get(doc["id"])
test=True makes a watermarked test document that is not counted, even with a live key. A test key always makes test
documents. The other options (mode, format, orientation, margins, deliver, metadata, merge_data, ...)
are described in the API reference.
E-invoices
Pass e_invoice to any PDF method. Factur-X and ZUGFeRD return a PDF/A-3b with the XML embedded; XRechnung returns the
XML itself (content_type == "application/xml").
invoice = client.pdfs.create_file(
e_invoice={
"standard": "zugferd",
"profile": "en16931",
"invoice": {
"number": "RE-2026-0042",
"issue_date": "2026-10-10",
"currency": "EUR",
"seller": {
"name": "Muster GmbH",
"vat_id": "DE123456789",
"address": {"line1": "Hauptstr. 1", "city": "Berlin", "postal_code": "10115", "country_code": "DE"},
},
"buyer": {"name": "Kunde AG", "address": {"city": "Hamburg", "postal_code": "20095", "country_code": "DE"}},
"payment": {"iban": "DE02120300000000202051", "terms": "Net 14 days"},
"lines": [{"name": "Consulting", "quantity": 8, "unit_code": "HUR", "unit_price": "95.00", "vat": {"category": "S", "rate": 19}}],
},
},
test=True,
)
print(invoice.e_invoice) # "zugferd/en16931"
Documents
page = client.documents.list(limit=50) # {"data": [...], "next_before": ...}
for doc in client.documents.iterate(): # every document, newest first
print(doc["id"], doc["status"])
Templates
templates = client.templates.list()["data"]
fields = client.templates.fields(templates[0]["id"]) # what to send as `data`
template = client.templates.get(template_id, version=3)
client.templates.create(name="Invoice", html="<h1>{{ number }}</h1>", sample_data={"number": "RE-1"}) # live keys
client.templates.update(template_id, html="<h1>Invoice {{ number }}</h1>") # a new version
client.templates.pin(template_id, 2) # None unpins
preview = client.templates.preview(template_id, data={"number": "RE-1"}) # watermarked, not counted
client.templates.versions(template_id)
client.templates.delete(template_id)
Batches
batch = client.batches.create(
template_id=template_id,
items=[{"data": c, "filename": f"{c['id']}.pdf"} for c in customers],
idempotency_key="march-statements",
)
done = client.batches.get(batch["id"]) # done["zip"]["url"] when status is "completed"
for item in client.batches.iterate_items(batch["id"]):
print(item["index"], item["status"])
client.batches.cancel(batch["id"])
Webhooks
endpoint = client.webhooks.create(url="https://example.com/castpdf", events=["pdf.created", "pdf.failed"])
print(endpoint["secret"]) # shown only now (and after rotate_secret): store it
client.webhooks.update(endpoint["id"], enabled=False)
client.webhooks.send_test(endpoint["id"])
client.webhooks.deliveries(endpoint["id"])
client.webhooks.rotate_secret(endpoint["id"]) # the old secret keeps signing for 24 hours
Verifying signatures
CastPDF signs every delivery with CastPDF-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">.
Verify against the raw body, before parsing it:
import os
from flask import Flask, abort, request
from castpdf import SignatureVerificationError, construct_event
app = Flask(__name__)
@app.post("/castpdf")
def castpdf_webhook():
try:
event = construct_event(
request.get_data(), # raw bytes
request.headers.get("CastPDF-Signature"),
os.environ["CASTPDF_WEBHOOK_SECRET"],
)
except SignatureVerificationError:
abort(400)
if event["type"] == "pdf.created":
print("ready:", event["data"]["url"])
return "", 200
verify_signature(payload, header, secret) returns True or False instead of raising. Signatures older than 5 minutes
are refused (tolerance_seconds= changes that). During the 24 hours after a secret rotation the header carries two
v1 values, and either secret verifies.
Account
me = client.me() # team, plan and key mode: a cheap connection test
client.account.usage()
Errors
Every exception extends CastPDFError. Answers with an error status raise an APIError subclass with status,
code (for example "template_not_found"), message, docs_url, details and request_id:
| Class | Status |
|---|---|
BadRequestError |
400, 422 |
AuthenticationError |
401 |
PaymentRequiredError |
402 |
PermissionDeniedError |
403 |
NotFoundError |
404 |
ConflictError |
409 |
GoneError |
410 |
PayloadTooLargeError |
413 |
RateLimitError |
429 (retry_after in seconds) |
InternalServerError |
5xx |
APIConnectionError, APITimeoutError |
no answer |
from castpdf import NotFoundError, RateLimitError
try:
client.pdfs.create(template_id=template_id, data=data)
except NotFoundError:
print("No such template")
except RateLimitError as err:
print(f"Retry in {err.retry_after} s")
Retries, idempotency and timeouts
- A
429 rate_limitedis retried (it was never processed), waiting forretry-after. Daily limits (storage_limit_reached,email_limit_reached) are not. - 5xx answers, timeouts and network errors are retried for
GET,PUTandDELETE, and forPOSTonly when the request carries anIdempotency-Key, so a PDF is never rendered twice by a retry. - Waits grow exponentially with jitter (0.5 s, 1 s, 2 s, ... up to 8 s).
max_retriesdefaults to 2.
client = CastPDF(
max_retries=3,
timeout=60.0, # seconds per attempt; default 120
auto_idempotency_keys=True, # generate a key for pdfs.create* and batches.create when none is given
)
client.pdfs.create(template_id=template_id, data=data, idempotency_key=f"invoice-{order_id}")
client.me(request_options={"timeout": 10, "max_retries": 0, "headers": {"x-trace": "abc"}})
The same key and body within 24 hours return the first result instead of rendering again. A request with a key is
always stored by the API (to replay it), also for create_file.
Version history
- 0.1.0: first release.
License
MIT
Metadata
Release files for castpdf 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| castpdf-0.1.0.tar.gz | 23.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| castpdf-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 44.9 kB
Release files / castpdf-0.1.0.tar.gz
| Download URL | castpdf-0.1.0.tar.gz |
|---|---|
| Size | 23.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
37f973e7579dcc1ad7a7a04bc6a153b4949e160a89595fd9d4967a1aa3e75c91
|
|
BLAKE2b-256 checksum How to use checksums |
d672ef2c4814397ae4a6df4336e4f0cb2ecfe4fa3a4ea051c64a3caa023df4ca
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency logRelease files / castpdf-0.1.0-py3-none-any.whl
| Download URL | castpdf-0.1.0-py3-none-any.whl |
|---|---|
| Size | 21.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6c32e810791adfc01faffe6bd082cf87c1fb4d9b8aa331b7849e5c358a2e8e7b
|
|
BLAKE2b-256 checksum How to use checksums |
73265e0c5737fd58f7bc3768e3ea8e338ea9696a84fb3b1deb87fbf44a8f0fdb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 12, 2026.
Transparency log