Skip to main content

Poli Page SDK for Python

Pypi Downloads Ci Codeql Coverage Python Types Linter Deps Docs License

Official Python SDK for Poli Page — render polished PDFs from HTML templates via the Poli Page API.

→ API reference (auto-generated from source): https://poli-page.github.io/sdk-python/

Install

pip install poli-page

Requires Python 3.11 or later.

Quick start

Render a template by slug

Set POLI_PAGE_API_KEY to a sandbox key of a fresh organization (pp_sandbox_…, see Authentication & environments), then:

from poli_page import PoliPage

client = PoliPage()  # reads POLI_PAGE_API_KEY from the environment

pdf = client.render.pdf(
    {
        "project": "getting-started",
        "template": "welcome",
        "data": {"name": "SDK Demo"},
    }
)
# pdf is bytes

Every organization gets a getting-started project, published into its sandbox environment when the organization is created, with a welcome template. A sandbox key renders it with no setup, so the snippet above runs as-is on a fresh organization. It is a normal, editable project: in an organization that renamed it, deleted it or removed it from sandbox, the API answers 404 PROJECT_NOT_FOUND or 404 PROJECT_NOT_IN_ENVIRONMENT, so use a fresh organization to follow along.

There is no version to pick: a render uses the project's copy published in the API key's environment. For your own templates, publish the project to the environment of the key you render with, then swap the slugs:

pdf = client.render.pdf(
    {
        "project": "billing",
        "template": "invoice",
        "data": {"invoice_number": "INV-001", "total": 1280},
    }
)

To preview a template or render inline HTML while you design it, use the playground: https://play.poli.page. The API and this SDK render published templates only.

Async client

Every method is mirrored on AsyncPoliPage for asyncio code — same constructor, same options, same return shapes:

import asyncio
from poli_page import AsyncPoliPage


async def main():
    async with AsyncPoliPage() as client:  # reads POLI_PAGE_API_KEY
        pdf = await client.render.pdf(
            {
                "project": "getting-started",
                "template": "welcome",
                "data": {"name": "SDK Demo"},
            }
        )


asyncio.run(main())

Pick the variant at construction time. The SDK does not auto-detect "am I in an async context" — that pattern bites later.

Write a PDF to disk

from poli_page import PoliPage
from poli_page.fs import render_to_file

client = PoliPage()  # reads POLI_PAGE_API_KEY
render_to_file(
    client,
    {
        "project": "getting-started",
        "template": "welcome",
        "data": {"name": "SDK Demo"},
    },
    "./welcome.pdf",
)

render_to_file streams response bytes directly to disk (bounded memory). Creates parent directories and replaces an existing file, through a temporary file renamed over the target once the PDF is complete: when the render, the download or the write fails, the target is left as it was. A symlink stays a link to the replaced file, and the file keeps its permission mode (not its owner or hard links). A file that cannot be written raises PoliPageError with the code io_error and the message Failed to write PDF: <target>: <reason>, the OSError as its __cause__. The async variant is poli_page.fs.async_render_to_file.

Try it locally — runnable demos

The repo ships two end-to-end demos that exercise every public method against the real API:

python demo/sync_demo.py     # synchronous client
python demo/async_demo.py    # asynchronous client

The demos render getting-started/welcome. First run prompts for a sandbox key (pp_sandbox_…) and saves it to .env at the repo root. Subsequent runs are silent. The POLI_PAGE_API_KEY env var wins over the file when set; POLI_PAGE_BASE_URL (optional) overrides the API host. Outputs land in demo/output-sync/ and demo/output-async/.

Each demo walks nine steps: render.pdf → render.pdf_stream → render_to_file (async: async_render_to_file) → render.document → documents.get → documents.thumbnails → documents.preview → documents.delete → not-found (a render of a template that does not exist, caught as NotFoundError with code TEMPLATE_NOT_FOUND). See demo/README.md.

Stream — for large PDFs or piping to S3 / HTTP responses

with client.render.pdf_stream(
    {
        "project": "billing",
        "template": "invoice",
        "data": {"invoice_number": "INV-001"},
    }
) as stream:
    with open("invoice.pdf", "wb") as fh:
        for chunk in stream:
            fh.write(chunk)

The context manager closes the underlying HTTP response deterministically. The async client returns an async with / async for-able equivalent.

Working with stored documents

Every render produces a stored document, accessible via document_id for later download or thumbnails. render.pdf and render.pdf_stream are conveniences that chain a presigned-URL fetch internally to return bytes; render.document returns just the descriptor (skip the auto-download when you'll fetch the bytes later).

# 1. Render and store
doc = client.render.document(
    {
        "project": "billing",
        "template": "invoice",
        "data": {"invoice_number": "INV-001"},
        "metadata": {"customer_id": "cust_123"},  # your own audit data
    }
)
# doc.document_id, doc.page_count, doc.size_bytes, doc.presigned_pdf_url, doc.metadata, ...

# 2. Save doc.document_id in your database
db.invoices.update(id="INV-001", document_id=doc.document_id)

# 3. Later, fetch a fresh presigned URL + download
fresh = client.documents.get(doc.document_id)
pdf = fresh.download_pdf()

# 4. Generate thumbnails
thumbs = client.documents.thumbnails(doc.document_id, {"width": 320, "format": "png"})

# 5. When done, soft-delete
client.documents.delete(doc.document_id)

The presigned URL has a 15-minute TTL. If download_pdf() fails with code='DOWNLOAD_FAILED' (HTTP 403 from S3), call documents.get(id) to refresh and retry. DOWNLOAD_FAILED is never retryable, whatever the storage status: the SDK does not retry it and is_retryable() is False.

The download is bounded by the client's timeout (or the render call's timeout): render.pdf and download_pdf() bound the whole download, and render.pdf_stream uses it as an idle timeout, the first byte at most timeout after the download starts and then each gap between two chunks, so a large PDF read at your pace is not cut off. Past it, the error is DOWNLOAD_FAILED with the message Failed to download PDF: timeout. The sync client waits for the download on a worker thread, because httpx's sync transport cannot be interrupted: after a timeout, that download can keep one pooled connection busy until its current httpx read or connect phase ends.

Authentication & environments

An API key belongs to exactly one environment of your organization, and its text names that environment: a key is pp_{env}_{32 lowercase hex}, matching

^pp_(?=[a-z][a-z0-9-]{0,19}_)[a-z][a-z0-9]*(?:-[a-z0-9]+)*_[0-9a-f]{32}$

The API resolves the environment from the key and renders the project's copy published there; the SDK never sends an environment.

Environment Key Watermark Counted
sandbox (every org) pp_sandbox_… yes no
live (every org, deletable on Pro+) pp_live_… no yes
created on Pro+ (e.g. staging) pp_staging_… no yes

"Counted" means the render counts toward the organization's monthly PDF quota. sandbox renders are watermarked, are not counted, and their documents expire 30 days after creation. There is no watermark option: a template that wants a watermark outside sandbox uses the poli-watermark class.

Standard and service keys share this one format. Every key hits the same endpoint, https://api.poli.page by default or whatever you pass as base_url (see Configuration). The SDK passes the key through as a Bearer token and does not check its format.

Environment variables

Configure without hard-coding:

Variable Purpose
POLI_PAGE_API_KEY Used when api_key= is omitted (or None) on the constructor; an explicit api_key="" raises invalid_options
POLI_PAGE_BASE_URL Used when base_url= is omitted (default https://api.poli.page)
POLI_PAGE_LOG debug / info / warning / error — sets the poli_page logger level on import

Methods

Method Returns Description
client.render.pdf(input) bytes Render a PDF, return bytes
client.render.pdf_stream(input) context manager yielding bytes chunks Render and stream the response
client.render.document(input) DocumentDescriptor Render and return descriptor (skip auto-download)
client.documents.get(id) DocumentDescriptor Retrieve a stored document
client.documents.preview(id) DocumentPreviewResult Stored document's paginated HTML
client.documents.thumbnails(id, options=None) list[Thumbnail] Page thumbnails (JPEG/PNG/WebP, base64)
client.documents.delete(id) None Soft-delete a stored document (idempotent)
render_to_file(client, input, path) (from poli_page.fs) None Render and stream to disk

Every method above also exists on AsyncPoliPage (with async def / await); helpers are poli_page.fs.async_render_to_file and AsyncDocumentDescriptor.download_pdf (async).

Configuration

Option Type Default Description
api_key str (POLI_PAGE_API_KEY env var) API key of one environment (pp_{env}_…)
base_url str https://api.poli.page API base URL
max_retries int 2 Max retry attempts on retryable errors
retry_delay float (seconds) 0.5 Base delay before the first retry
timeout float (seconds) 60.0 Per-request timeout; also bounds the PDF download
on_retry callable — Called before each retry sleep with a RetryEvent
on_error callable — Called when a call terminates in error with a PoliPageError
http_client httpx.Client (or AsyncClient) — Inject a pre-configured httpx client (proxies, custom TLS, shared pool)

Unit note: retry_delay and timeout are seconds (Python idiom). The Node SDK uses milliseconds; if you're porting from that, divide by 1000.

Branching with with_options

When you need different settings for a single call (a longer timeout for a heavy render, fewer retries on a webhook-driven path), branch the client instead of reconstructing it:

slow_client = client.with_options(timeout=120.0, max_retries=5)
pdf = slow_client.render.pdf({"project": "billing", "template": "yearly-report", "data": {...}})

with_options returns a new client; unspecified options inherit from the original. The branch owns its own connection pool, so closing one does not close the other. The async client exposes the same method on AsyncPoliPage.

Error handling

The SDK ships a typed error hierarchy. Catch the broad base (PoliPageError) or the specific subclass — both work:

from poli_page import (
    PoliPage,
    PoliPageError,
    AuthenticationError,
    PermissionDeniedError,
    RateLimitError,
    BadRequestError,
    APIConnectionError,
    APITimeoutError,
    InternalServerError,
)

try:
    client.render.pdf({...})
except RateLimitError:
    queue_for_later()
except AuthenticationError:
    refresh_credentials()
except PoliPageError as err:
    if err.is_retryable():
        # SDK already retried up to max_retries
        ...
    print(err.code, err.status, err.request_id)

The hierarchy:

PoliPageError                       # base — catches everything
├── APIConnectionError              # transport-level (no status)
│   └── APITimeoutError             # per-request deadline exceeded
├── InvalidResponseError            # a 2xx whose body is not JSON
└── APIStatusError                  # any non-2xx (carries status)
    ├── BadRequestError             (400)
    ├── AuthenticationError         (401)
    ├── PermissionDeniedError       (403)
    ├── NotFoundError               (404)
    ├── ConflictError               (409)
    ├── GoneError                   (410)
    ├── UnprocessableEntityError    (422)
    ├── RateLimitError              (429)
    └── InternalServerError         (5xx)

Predicate helpers are kept for cross-language parity:

  • err.is_auth_error() — status 401 or 403
  • err.is_rate_limit_error() — status 429
  • err.is_validation_error() — status 400
  • err.is_network_error() — code network_error or timeout (every APIConnectionError)
  • err.is_retryable() — 5xx, 429, network, or timeout, except QUOTA_EXCEEDED, OVERAGE_CAP_EXCEEDED, STORAGE_ACCESS_DENIED, DOWNLOAD_FAILED, invalid_response and io_error

A 2xx answer whose body is not JSON (a proxy or a captive portal answering instead of the API) raises InvalidResponseError, a direct PoliPageError subclass: code invalid_response, the message Invalid response: HTTP <status> body is not JSON, the real 2xx as status. It is not retried. err.to_payload() returns exactly code, message, status and requestId, with status 502 for it (503 for network_error, 504 for timeout).

For lifecycle and billing failures, route the user to actionable messages:

from poli_page import error_codes

try:
    client.render.document({...})
except PoliPageError as err:
    if err.code == error_codes.ORGANIZATION_PAYMENT_GRACE:
        return show_banner("Subscription has unpaid invoices.")
    if err.code == error_codes.ORGANIZATION_CANCELLED:
        return show_banner("Subscription cancelled — service is read-only.")
    if err.code == error_codes.ORGANIZATION_PURGED:
        return show_banner("Organization has been purged.")
    if err.code == error_codes.DOCUMENT_NOT_FOUND:
        return show_404()
    if err.code == error_codes.DOCUMENT_GONE:
        return show_410()  # deleted, or a sandbox document past 30 days
    raise

→ Full error reference: https://poli-page.github.io/sdk-python/reference/errors/

Cancellation

Per-call timeout (overrides the client-level timeout):

pdf = client.render.pdf({"project": "...", "template": "...", "data": {}, "timeout": 5.0})

For full cancellation in async code, use standard asyncio task cancellation:

task = asyncio.create_task(client.render.pdf({...}))
task.cancel()  # → PoliPageError(code='aborted')

In sync code, run the call in a thread + cancel via timeout if you need true cancellation; the SDK's per-call timeout is the idiomatic path for everything else.

Observability

Two complementary mechanisms:

Logger (always-on, silent by default)

import logging

# Opt in to verbose request/response logs:
logging.getLogger("poli_page").setLevel(logging.DEBUG)
# Or via env var at import time:
#   POLI_PAGE_LOG=debug python app.py

One DEBUG line per HTTP attempt (method url status duration_ms attempt), one INFO line per retry, one ERROR line per terminal failure. Never logs the Authorization header or any field name matching api_key / apiKey / token.

Hooks (on_retry, on_error)

Optional, sync callables; never break the request:

from poli_page import PoliPage, RetryEvent, PoliPageError


def on_retry(event: RetryEvent) -> None:
    log.warning(f"retry {event.attempt} after {event.delay_ms:.0f} ms: {event.reason.code}")


def on_error(err: PoliPageError) -> None:
    sentry.capture_exception(err)


client = PoliPage(on_retry=on_retry, on_error=on_error)  # reads POLI_PAGE_API_KEY

For per-HTTP-request hooks, pass your own httpx.Client with event_hooks={...} to the SDK — that's the httpx-idiomatic path for request/response wiretaps:

import os

import httpx

client = PoliPage(
    api_key=os.environ["POLI_PAGE_API_KEY"],
    http_client=httpx.Client(
        event_hooks={
            "request": [lambda req: metrics.inc("poli.request")],
            "response": [lambda res: tracing.add_event(res.status_code)],
        }
    ),
)

Retries & idempotency

The SDK retries on 5xx, 429, network errors, and timeouts, except QUOTA_EXCEEDED, OVERAGE_CAP_EXCEEDED (429) and STORAGE_ACCESS_DENIED (502), which waiting cannot fix: those are raised at once. Before retry n (1-based) the SDK waits retry_delay * 2^(n-1) * j, with j a random factor in [0.5, 1.5); a Retry-After header (integer seconds or HTTP-date, clamped to 30 s) replaces it when the server sends one. Retry-After-Ms is ignored. Every POST sends an auto-generated Idempotency-Key (UUID v4); pass idempotency_key in the input dict to override.

Type system

py.typed ships in the wheel. Strict-mode-clean against pyright; mypy strict on src/ is green in CI as well.

RenderInput is the one render input TypedDict: project, template and data, plus the optional format, orientation, locale, metadata, idempotency_key and timeout. The request body carries only the documented fields: a key outside that set (such as the removed version) is not sent, and a None field is left out. Input without a project raises PoliPageError(code="PROJECT_REQUIRED_FOR_DOCUMENT") before any request.

Concurrency & thread-safety

The sync client is thread-safe — share a single instance across threads. The async client (AsyncPoliPage) is safe to share across asyncio tasks. The client carries no per-request mutable state, so a single instance per process is the expected pattern.

Runtime support

Runtime Status
CPython 3.11 / 3.12 / 3.13 Supported
PyPy 3.11+ Untested — should work; httpx is the only runtime dep
Browsers Not supported (API keys are server-side secrets)

Browsers are not supported. API keys (pp_{env}_…, whatever the environment) are secrets and must never be shipped to a browser. Call the SDK from your backend and proxy the result to the client.

Requirements

  • Python 3.11 or later
  • httpx >= 0.25, < 1.0 (the only runtime dependency)

Documentation & support

License

MIT © Poli Page

Metadata

Release files for poli-page 0.13.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for poli-page 0.13.0
File Size Uploaded Provenance Actions
poli_page-0.13.0.tar.gz 119.0 kB With provenance Details for poli_page-0.13.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for poli-page 0.13.0
File Interpreter ABI Platform Actions
poli_page-0.13.0-py3-none-any.whl Python 3 none any Details for poli_page-0.13.0-py3-none-any.whl

Total release size: 160.8 kB

Release files / poli_page-0.13.0.tar.gz

Download URL poli_page-0.13.0.tar.gz
Size 119.0 kB
Tags Source
SHA-256 checksum
How to use checksums
29957eac36734cc8749dfab3d9a462d216718a1a43b3efeedf707754f2f065d8
BLAKE2b-256 checksum
How to use checksums
6f8ece69ee40108c6f5dc2b5766f1f30e2f11397973882f0d680f5dbb862949f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Nov 4, 2026.

Transparency log

Release files / poli_page-0.13.0-py3-none-any.whl

Download URL poli_page-0.13.0-py3-none-any.whl
Size 41.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
5d3e0d7e3d7a2f3fe62e788c3b3075d1038ba83b7c71cdf0d3df68a05197944a
BLAKE2b-256 checksum
How to use checksums
025e9db039882243b33c0a310bb992f27d70e6f8d07e3c3bdf15bfa1c68750e8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

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 Nov 4, 2026.

Transparency log

Security for poli-page 0.13.0

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.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for poli_page-0.13.0.tar.gz

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 Nov 4, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page