Metadata-Version: 2.5
Name: castpdf
Version: 0.1.0
Summary: Official Python client for the CastPDF API: PDFs from templates or HTML, EU e-invoices, batches and webhooks.
Project-URL: Homepage, https://castpdf.com
Project-URL: Documentation, https://castpdf.com/docs/api
Project-URL: Repository, https://github.com/castpdf/castpdf-python
Project-URL: Issues, https://github.com/castpdf/castpdf-python/issues
Project-URL: Changelog, https://github.com/castpdf/castpdf-python/blob/main/CHANGELOG.md
Author-email: CastPDF <support@castpdf.com>
License-Expression: MIT
License-File: LICENSE.md
Keywords: castpdf,e-invoice,factur-x,html-to-pdf,pdf,pdf-generation,sdk,xrechnung,zugferd
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.27
Requires-Dist: typing-extensions>=4.7
Description-Content-Type: text/markdown

# castpdf

The official Python client for the [CastPDF API](https://castpdf.com/docs/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](https://www.python-httpx.org/).
- 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-Signature` header.

Requires Python 3.10 or newer.

## Install

```sh
pip install castpdf
```

## Quick start

Create an API key in the CastPDF dashboard (`cpdf_test_...` keys make watermarked test documents that are not counted).

```python
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:

```python
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 |

```python
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](https://castpdf.com/docs/api).

### 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"`).

```python
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

```python
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

```python
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

```python
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

```python
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:

```python
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

```python
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 |

```python
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_limited` is retried (it was never processed), waiting for `retry-after`. Daily limits
  (`storage_limit_reached`, `email_limit_reached`) are not.
- 5xx answers, timeouts and network errors are retried for `GET`, `PUT` and `DELETE`, and for `POST` only when the
  request carries an `Idempotency-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_retries` defaults to 2.

```python
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
