---
seo_title: Python File Conversion SDK: pip install enconvert | EnConvert
meta_desc: Official EnConvert Python SDK. Install with pip, convert 40+ formats, and use the V2 namespace to perceive, discover, distill, ingest, and watch web pages.
keywords: python file conversion sdk, convert files python, url to pdf python, python web scraping api, docx to pdf python, enconvert python sdk, pip install enconvert, heic to webp python, html to markdown python, python rag ingestion pipeline, website change monitoring python
---

# Python File Conversion SDK

`enconvert` is the official Python client for the EnConvert API: one `pip install`, one API key, and typed methods for converting files and for reading the live web. It runs on Python 3.9 or newer with a single runtime dependency, `requests`, and ships inline type hints plus a `py.typed` marker so mypy and Pyright see everything. The client has two surfaces. Nine conversion methods cover 43 `{input}-to-{output}` pairs plus URL to PDF, screenshot, and Markdown, backed by three status-polling helpers. The `client.v2` namespace adds web intelligence: perceive, discover, lookup, distill, ingest, and watch.

<div class="alert alert-info">
<strong>PyPI:</strong> <code>enconvert</code> &middot; <strong>Source:</strong> <a href="https://github.com/conversionapi/python-sdk">conversionapi/python-sdk</a> &middot; <strong>Python:</strong> 3.9+ &middot; <strong>Runtime dependency:</strong> <code>requests&gt;=2.28</code> &middot; <strong>License:</strong> MIT
</div>

---

## Install

```bash
pip install enconvert
```

`uv add enconvert` and `poetry add enconvert` work the same way. `requests` is the only runtime dependency, and the type stubs ship inside the wheel, so there is no `types-` package to chase.

---

## Quick start

Read a page the way your agent should, with a quality score attached, then convert a local file through the same client.

```python
import os

from enconvert import Enconvert

client = Enconvert(api_key=os.environ["ENCONVERT_API_KEY"])

op = client.v2.perceive("https://example.com", outputs=["markdown", "structured"])
print(op.outputs["markdown"].url, op.render_quality)

print(client.convert_to_pdf("report.docx", save_to="report.pdf").presigned_url)
```

Every method is synchronous and blocking. The SDK is **server-side only**: it authenticates with a private API key, so never ship it inside a desktop, mobile, or browser client. Get a key from your [dashboard](/dashboard), and see [Authentication](/docs/authentication) for how keys are scoped.

---

## What the client exposes

`Enconvert` is the whole public surface. Conversion methods hang off the client directly; everything web-facing lives under `client.v2`.

| Conversion method | Endpoint | Returns |
|--------|----------|---------|
| `convert_url_to_pdf(url, ...)` | `POST /v1/convert/url-to-pdf` | `ConversionResult` |
| `convert_url_to_screenshot(url, ...)` | `POST /v1/convert/url-to-screenshot` | `ConversionResult` |
| `convert_url_to_markdown(url, ...)` | `POST /v1/convert/url-to-markdown` | `ConversionResult` |
| `convert_image(file, output_format=...)` | `POST /v1/convert/{input}-to-{output}` | `ConversionResult` |
| `convert_document(file, ...)` | `POST /v1/convert/{input}-to-{output}` | `ConversionResult` |
| `convert_to_markdown(file, ...)` | `POST /v1/convert/anything-to-markdown` | `ConversionResult` |
| `convert_to_pdf(file, ...)` | `POST /v1/convert/anything-to-pdf` | `ConversionResult` |
| `convert_website_to_pdf(url, ...)` | `POST /v1/convert/website-to-pdf` | `BatchSubmission` |
| `convert_website_to_screenshot(url, ...)` | `POST /v1/convert/website-to-screenshot` | `BatchSubmission` |
| `get_job_status(job_id)` | `GET /v1/convert/status/{job_id}` | `JobStatus` |
| `get_batch_status(batch_id)` | `GET /v1/convert/batch/{batch_id}` | `BatchStatus` |
| `wait_for_batch(batch_id, ...)` | `GET /v1/convert/batch/{batch_id}` (polled) | `BatchStatus` |

| `client.v2` capability | Methods | Returns |
|------------|---------|---------|
| Perceive | `perceive`, `perceive_direct`, `get_perceive_operation`, `download_perceive_artifact`, `perceive_batch`, `get_perceive_batch` | `PerceiveResult`, `PerceiveDirectResult`, `PerceiveBatchResult` |
| Discover, Lookup, Distill | `discover`, `lookup`, `distill` | `DiscoverResult`, `LookupResult`, `DistillResult` |
| Ingest | `ingest`, `ingest_files`, `list_ingest_jobs`, `get_ingest_job`, `cancel_ingest_job`, `retry_ingest_webhook`, `get_webhook_secret`, `rotate_webhook_secret` | `IngestJob`, `IngestJobList`, `WebhookSecret`, `WebhookRetryResult` |
| Watch | `create_watcher`, `list_watchers`, `get_watcher`, `get_watcher_snapshots`, `update_watcher`, `delete_watcher` | `Watcher`, `WatcherList`, `WatcherSnapshotList` |

Every argument after the first positional one is keyword-only, snake_case, and optional unless a table says otherwise. Results are frozen dataclasses, so build a new instance rather than mutating one. REST shapes are in [Endpoints overview](/docs/endpoints-overview).

---

## File conversion

Every conversion method takes `save_to` (a `str` or `os.PathLike`; the result is streamed there and parent directories are created) and `output_filename` (override the generated name). Both are left out of the tables below.

### convert_url_to_pdf

Render any reachable URL to PDF.

```python
result = client.convert_url_to_pdf(
    "https://example.com", single_page=False, viewport_width=1440, save_to="report.pdf"
)
print(result.presigned_url, result.file_size)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `single_page` | `bool` | `True` | `True` gives one continuous page. `False` paginates using `pdf_options.page_size`. |
| `pdf_options` | `PdfOptions` | -- | Page size, orientation, margins, scale, grayscale, header, footer. See [PDF options](#pdf-options). |
| `viewport_width` / `viewport_height` | `int` | `1920` / `1080` | Browser viewport size in pixels. |

`load_media` and `enable_scroll` are both `True` by default: the first waits for images and video, the second scrolls top to bottom so lazy loaders fire. Three more options reach pages behind a gate: `auth` (`HttpBasicAuth`), `cookies` (`list[BrowserCookie]`), and `headers` (`dict[str, str]`).

<div class="alert alert-warning">
<strong>Do not combine <code>auth</code> with an <code>Authorization</code> header.</strong> The API rejects the conflict rather than guessing which credential wins. Pick one.
</div>

---

### convert_url_to_screenshot

Capture a PNG of any URL.

```python
client.convert_url_to_screenshot("https://example.com", viewport_width=1440, save_to="shot.png")
```

Takes the same viewport, media, scroll, filename, and browser-access options as `convert_url_to_pdf`, minus `single_page` and `pdf_options`.

---

### convert_url_to_markdown

Pull clean GitHub-Flavored Markdown out of a URL. Navigation, footers, ads, and scripts are stripped, the main article body is kept, and YAML frontmatter with title, description, url, links, and images is prepended.

```python
client.convert_url_to_markdown("https://example.com/article", save_to="article.md")
```

Same option set as `convert_url_to_screenshot`. When you also want a quality score, page metadata, or structured extraction on the same read, use [`v2.perceive`](#perceive) instead.

---

### convert_image

Convert between `jpeg`, `png`, `svg`, `heic`, and `webp`, or rasterize a PDF to JPEG. The input format comes from the filename extension.

```python
client.convert_image("photo.heic", output_format="webp", save_to="photo.webp")
client.convert_image("scan.pdf", output_format="jpeg", save_to="scan.jpeg")
```

`output_format` is required and must be one of `jpeg`, `png`, `svg`, `heic`, or `webp`; the aliases `jpg`, `yml`, `htm`, and `md` are normalized for you. `file` accepts a path string, an `os.PathLike`, raw `bytes`, or a `FileData(data, filename)` wrapper. Raw `bytes` carry no filename and upload as `upload.bin`, so prefer `FileData` whenever the extension matters.

```python
from pathlib import Path

from enconvert import FileData

blob = FileData(data=Path("photo.heic").read_bytes(), filename="photo.heic")
client.convert_image(blob, output_format="webp", save_to="photo.webp")
```

---

### convert_document

Convert documents and data formats. `output_format` defaults to `"pdf"`, and `pdf_options` is honored when the output is PDF.

```python
from enconvert import PdfMargins, PdfOptions

client.convert_document("report.docx", save_to="report.pdf")
client.convert_document("data.json", output_format="yaml", save_to="data.yaml")
client.convert_document(
    "README.md", pdf_options=PdfOptions(page_size="A4", margins=PdfMargins(top=20)), save_to="r.pdf"
)
```

**Supported inputs:** `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.html`, `.htm`, `.odt`, `.ods`, `.odp`, `.ots`, `.pages`, `.numbers`, `.md`, `.markdown`, `.csv`, `.json`, `.xml`, `.yaml`, `.yml`, `.toml`.

EPUB has no dedicated document pair. Send `.epub` through `convert_to_pdf` or `convert_to_markdown`.

---

### convert_to_markdown

Auto-detect an uploaded document server-side and return clean Markdown. This is the RAG-ingestion building block for a single file.

```python
client.convert_to_markdown("handbook.docx", save_to="handbook.md")
```

**Accepted inputs:** PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy and ODF office formats. Images are not supported here, and the endpoint takes no PDF options. For a whole site rather than one file, use [`v2.ingest`](#ingest).

---

### convert_to_pdf

Auto-detect an uploaded file server-side and return a PDF.

```python
from enconvert import PdfOptions

client.convert_to_pdf("slides.pptx", save_to="slides.pdf")
client.convert_to_pdf("scan.pdf", pdf_options=PdfOptions(grayscale=True), save_to="gray.pdf")
```

**Accepted inputs:** office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, and an existing PDF as passthrough. Because a `.pdf` input is passed through, this doubles as a grayscale normalizer.

<div class="alert alert-warning">
<strong>Only <code>pdf_options.grayscale</code> is honored on this endpoint.</strong> The other page-geometry fields are ignored here. When you need real page setup, route the file through <code>convert_document</code> or <code>convert_url_to_pdf</code> instead.
</div>

---

### convert_website_to_pdf and convert_website_to_screenshot

Discover every page of a site, convert each one in the background, and collect a single ZIP. Both are asynchronous by design and return a `BatchSubmission` immediately.

```python
batch = client.convert_website_to_pdf(
    "https://example.com", crawl_mode="sitemap", exclude_patterns=["/blog/tag/"]
)
print(batch.batch_id, batch.url_count, batch.discovery_method)

status = client.wait_for_batch(batch.batch_id, save_to="site.zip")
print(status.completed, "of", status.total, "pages converted")

for item in client.get_batch_status(batch.batch_id).items:
    print(item.source_url, item.status, item.download_url)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `crawl_mode` | `"auto" \| "sitemap" \| "full"` | server default | `sitemap` reads `sitemap.xml` only. `full` adds a breadth-first crawl. `auto` picks the highest mode available to the key. |
| `include_patterns` / `exclude_patterns` | `list[str]` | -- | Keep or drop URLs by path fragment. Excludes apply in full crawl mode only. |
| `notification_email` / `callback_url` | `str` | -- | Address to email, and webhook to call, when the batch finishes. |
| `single_page` | `bool` | server default | PDF only. |
| `pdf_options` | `PdfOptions` | -- | PDF only. See [PDF options](#pdf-options). |

Per-page render options apply to every discovered URL as well: `viewport_width`, `viewport_height`, `load_media`, `enable_scroll`, `auth`, `cookies`, and `headers`. Anything left unset keeps the gateway's own default.

`wait_for_batch` accepts `interval_seconds` (default `5.0`), `timeout_seconds` (default `1800.0`), and `save_to`. It raises `APIError(504, ...)` if the batch is still processing when the deadline passes.

---

### Supported conversion pairs

The SDK carries a copy of the gateway's conversion map and rejects an unimplemented pair locally, before any network round-trip, naming the valid outputs for that input in the message.

| Input | Outputs |
|-------|---------|
| `json` | `csv`, `toml`, `xml`, `yaml` |
| `xml` | `csv`, `json` |
| `yaml`, `toml` | `json` |
| `csv` | `json`, `xml` |
| `markdown` | `html`, `pdf` |
| `html` | `pdf` |
| `doc`, `excel`, `ppt`, `odt`, `ods`, `odp`, `ots`, `pages`, `numbers` | `pdf` |
| `jpeg`, `png`, `svg`, `heic`, `webp` | each other, all 20 pairs |
| `pdf` | `jpeg` |

That is 43 implemented pairs. Check them programmatically:

```python
from enconvert import IMPLEMENTED_CONVERSIONS, valid_outputs_for

valid_outputs_for("json")                  # ['csv', 'toml', 'xml', 'yaml']
"heic-to-webp" in IMPLEMENTED_CONVERSIONS  # True
```

The complete parameter reference lives in [Parameters and options](/docs/parameters-options).

---

## Web intelligence (V2)

Every V2 read carries `render_quality`, a float from 0.0 to 1.0 that says how honestly the page rendered. A challenge screen, cookie wall, login gate, HTTP error page, or empty SPA shell comes back with a low score, a `deductions` map naming what fired, and a `warnings` list. The content is still returned, just flagged, so a bad read never quietly enters your agent's context. Treat the score as a gate and check it before you use the text. `PerceiveResult`, `PerceiveDirectResult`, `DistillItem`, `LookupItem.perceive`, and `WatcherSnapshot` all expose it. Concepts are in the [V2 overview](/docs/v2-overview).

### Perceive

Render one URL into agent-ready artifacts. Synchronous: the call returns the completed operation with signed artifact URLs.

```python
op = client.v2.perceive(
    "https://example.com",
    outputs=["markdown", "screenshot", "structured"],
    extract=["tables", "metadata"],
    only_main_content=True,
)

print(op.render_quality)            # 0.0 to 1.0
print(op.status_code)               # HTTP status of the page itself
print(op.deductions)                # e.g. {"login_wall": 0.65}
print(op.outputs["markdown"].url)   # signed URL, 15 minutes
print(op.structured)

if (op.render_quality or 0) < 0.6:
    print("Low-confidence read:", op.warnings)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputs` | `list[str]` | `["markdown", "structured"]` | Any of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, `images`, `structured`. |
| `extract` | `list[str]` | -- | Any of `tables`, `prices`, `contacts`, `metadata`, `main_content`, `headings`, `structured_data`, `technologies`, `all`. |
| `wait_for` | `str` | -- | CSS selector to wait for before capture. |
| `viewport` | `PerceiveViewport` | 1920 x 1080 | `width` 320 to 3840, `height` 240 to 2160. |
| `cache_mode` | `"enabled" \| "bypass" \| "refresh"` | server default | Reuse, skip, or rewrite the cached render. |
| `block_resources` | `list[str]` | -- | Any of `image`, `media`, `font`, `stylesheet`, `script`, `xhr`, `fetch`, `websocket`, `manifest`, `other`. |
| `only_main_content` | `bool` | server default | Strip navigation, headers, footers, and other page chrome from the extracted content. |
| `direct_download` | `bool` | server default | Return raw bytes instead of a signed URL. Unset by default, so the key is omitted from the request and the server default (false) applies. Accepted by `perceive` only; the batch endpoint rejects it. |

Also accepted: `schema` (`dict`, a free-form extraction schema passed through untouched), `js_code` (`str`, run in the page before capture), `wait_timeout_ms` (`int`), `headers` (`dict[str, str]`), `cookies` (`list[BrowserCookie]`), `auth` (`HttpBasicAuth`), `proxy_url` (`str`), `geolocation` (`dict`), `action_chain` (`list[dict]` of scripted click, type, and scroll steps), `pdf_options` (`PdfOptions`, applied to the `pdf` output), `mobile` (`bool`), and `respect_robots` (`bool`). `perceive_batch` takes the identical set apart from `direct_download`.

Artifact URLs are re-signed on every read, so call `client.v2.get_perceive_operation(op.operation_id)` for a fresh one instead of caching the string.

**Raw bytes, no signed-URL round-trip.** `perceive_direct` forces `direct_download` on and hands you the artifact itself, with metadata parsed from the response headers.

```python
direct = client.v2.perceive_direct("https://example.com", outputs=["markdown"])
print(direct.filename, direct.content_type, len(direct.content), direct.render_quality)

raw = client.v2.download_perceive_artifact(op.operation_id, output="markdown")
```

`perceive_direct` requires exactly one artifact-producing output out of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, and `images`. `structured` may ride along but stays inline server-side; pass anything else and the SDK raises `EnconvertError` before sending the request. On `download_perceive_artifact`, `output` may be omitted when the operation produced exactly one artifact, and an artifact past its retention window answers `410`.

**Batches.** Up to 1000 URLs share one options block. Small batches complete inline; larger ones come back with status `"queued"`, so poll the job id.

```python
batch = client.v2.perceive_batch(
    ["https://a.example.com", "https://b.example.com"], outputs=["markdown"], output_mode="zip"
)

done = client.v2.get_perceive_batch(batch.job_id)
print(done.status, done.completed, done.failed, done.pending)
for item in done.items:
    print(item.url, item.render_quality)
```

`output_mode` is `"manifest"` (default) or `"zip"`; when it is `"zip"`, the finished bundle is on `done.zip.url`. Details in [Perceive](/docs/v2-perceive).

### Discover

Enumerate a site's URLs with no browser rendering, which makes it the cheap first step before you perceive anything.

```python
found = client.v2.discover(
    "https://example.com", mode="hybrid", max_urls=200, exclude_patterns=["/tag/"]
)
print(found.total, found.truncated, found.sources)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `"sitemap" \| "crawl" \| "hybrid"` | server default | Sitemap parsing, an HTTP crawl, or both merged and deduplicated. |
| `max_urls` / `max_depth` | `int` | server default | Cap on returned URLs (`truncated` is `True` when more existed), and crawl depth from the seed. |
| `same_domain_only` | `bool` | server default | Stay on the seed host. |
| `respect_robots` | `bool` | server default | Honor `robots.txt`. |

`include_patterns` and `exclude_patterns` (both `list[str]`) filter the result set. `DiscoverResult.sources` holds the raw per-source counts before dedup, such as `{"sitemap": 42, "crawl": 30}`. More in [Discover](/docs/v2-discover).

### Lookup

Run a categorized web search, optionally rendering the top hits in the same call.

```python
search = client.v2.lookup(
    "best static site generators", category="web", num_results=10, perceive_top=3
)

for hit in search.results:
    quality = hit.perceive.render_quality if hit.perceive else None
    print(hit.position, hit.title, hit.url, quality)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `category` | `"web" \| "news" \| "images" \| "scholar" \| "patents" \| "maps"` | server default | Search vertical. |
| `time_filter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | -- | Recency window. |
| `num_results` / `page` | `int` | server default | Results per page, and the 1-based page number. |
| `perceive_top` | `int` | `0` | Auto-render the top N result URLs. Each arrives with its full `PerceiveResult` on `hit.perceive`. |

Also accepted: `country` (`str`), `locale` (`str`), `location` (`str`) for geo-sensitive queries, and `autocorrect` (`bool`). `LookupResult` carries `answer_box`, `knowledge_graph`, `perceive_operation_ids`, and `perceive_top`, the last reporting how many results were actually rendered, which can be lower than what you asked for. Reference in [Lookup](/docs/v2-lookup).

### Distill

Schema-driven structured extraction across one or many pages. Supply exactly one of `urls` or `discover_from`; `schema` is always required. Both rules are enforced client-side and raise `EnconvertError` before a request goes out.

```python
from enconvert import CssField, CssSchema

extraction = client.v2.distill(
    urls=["https://example.com/pricing"],
    schema={"plans": "list of plan names with monthly prices"},
    css_schema=CssSchema(
        base_selector=".plan-card",
        fields=[
            CssField(name="name", type="text", selector="h3"),
            CssField(name="price", type="text", selector=".price"),
        ],
        target_field="plans",
    ),
)

for item in extraction.results:
    print(item.url, item.extraction_tier, item.fields_from_css, item.fields_from_llm, item.data)
```

The optional `css_schema` runs first and answers whatever the selectors can reach. Only the fields it misses escalate to the language-model tier, and `extraction_tier` on each item reports which path ran: `css`, `llm`, `mixed`, or `none`.

Discover and distill in one call:

```python
from enconvert import DistillDiscoverFrom

client.v2.distill(
    discover_from=DistillDiscoverFrom(url="https://example.com", mode="sitemap", max_pages=10),
    schema={"title": "page title"},
)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `schema` | `dict` | -- (required) | Output shape you want back, passed through untouched. |
| `urls` | `list[str]` | -- | Explicit page list. Mutually exclusive with `discover_from`. |
| `discover_from` | `DistillDiscoverFrom` | -- | `url`, optional `mode`, optional `max_pages` (1 to 50, default 10). |
| `css_schema` | `CssSchema` | -- | Selector pass run before any model call. |

Also accepted: `wait_for` (`str`), `wait_timeout_ms` (`int`), `headers` (`dict[str, str]`), `cookies` (`list[BrowserCookie]`), and `respect_robots` (`bool`). `CssField` supports the types `text`, `attribute`, `html`, `regex`, `nested`, `list`, and `nested_list`, with optional `attribute`, `pattern`, `default`, `transform` (`lowercase`, `uppercase`, `strip`), and nested `fields` up to five levels deep. See [Distill](/docs/v2-distill).

### Ingest

Turn a whole site, an explicit URL list, or a pile of uploaded documents into chunked, RAG-ready JSONL. Ingest is always asynchronous.

```python
import time

from enconvert import IngestChunkOptions

job = client.v2.ingest(
    mode="sitemap",
    url="https://docs.example.com",
    max_pages=100,
    chunk=IngestChunkOptions(max_words=512, sentence_overlap=1),
    webhook_url="https://my.app/hooks/enconvert",
)

status = client.v2.get_ingest_job(job.job_id)
while status.status in ("queued", "discovering", "processing"):
    time.sleep(10)
    status = client.v2.get_ingest_job(job.job_id)

print(status.status, status.pages_processed, status.total_chunks, status.output_url)
```

Uploaded files run through the same job lifecycle under mode `files`. PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy and ODF office formats are accepted, and each entry may be a path, an `os.PathLike`, raw `bytes`, or a `FileData`.

```python
file_job = client.v2.ingest_files(["handbook.pdf", "notes.docx"])
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `"urls" \| "sitemap" \| "crawl" \| "files"` | `"urls"` | `urls` needs a non-empty `urls` list and rejects `url`. Every other mode needs a seed `url` and rejects `urls`. |
| `url` / `urls` | `str` / `list[str]` | -- | Seed URL for `sitemap` and `crawl`, or the explicit page list for mode `urls`. |
| `max_pages` | `int` | server default | Cap on pages ingested. |
| `chunk` | `IngestChunkOptions` | -- | `max_words` 32 to 4000, default 512. `sentence_overlap` 0 to 10, default 1. Pair it with `webhook_url` (`str`), called once the job reaches a terminal state. |

Crawl shaping and render options are accepted too: `max_depth`, `same_domain_only`, `include_patterns`, `exclude_patterns`, `respect_robots`, `wait_for`, and `wait_timeout_ms`. `ingest_files` takes only `chunk` and `webhook_url`.

```python
for summary in client.v2.list_ingest_jobs(limit=20).jobs:
    print(summary.job_id, summary.status, summary.total_chunks)

client.v2.cancel_ingest_job(job.job_id)
client.v2.retry_ingest_webhook(job.job_id)

secret = client.v2.get_webhook_secret()
print(secret.signature_header, secret.signature_scheme, secret.replay_tolerance_seconds)
client.v2.rotate_webhook_secret()
```

`cancel_ingest_job` is idempotent; cancelling an already-terminal job returns it unchanged. `retry_ingest_webhook` answers `409` when the job is not completed and `400` when no webhook was configured. `rotate_webhook_secret` invalidates the previous secret immediately. Deeper coverage in [Ingest](/docs/v2-ingest).

### Watch

Re-render a URL on a fixed cadence and get told when it changes.

```python
watcher = client.v2.create_watcher(
    "https://example.com/pricing",
    frequency_minutes=60,
    diff_mode="auto",
    webhook_url="https://my.app/hooks/changes",
    notify_email=True,
)

for snap in client.v2.get_watcher_snapshots(watcher.watcher_id, limit=10).snapshots:
    print(snap.checked_at, snap.has_changes, snap.similarity, snap.change_count)

client.v2.list_watchers(limit=20)
client.v2.update_watcher(watcher.watcher_id, status="paused")
client.v2.update_watcher(watcher.watcher_id, webhook_url="")
client.v2.delete_watcher(watcher.watcher_id)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `frequency_minutes` | `int` | server default | Check interval. Hourly is the floor. |
| `diff_mode` | `"auto" \| "text" \| "structured" \| "tables" \| "metadata"` | server default | Which layer of the page the diff engine compares. |
| `track_fields` | `dict` | -- | Named fields to track, passed through untouched. |
| `webhook_url` | `str` | -- | Called on every detected change. |
| `notify_email` | `bool` | server default | Send change notifications by email. |

`update_watcher` needs at least one field and raises `EnconvertError` otherwise; `status` accepts `"active"` or `"paused"`, and an empty `webhook_url` clears the webhook. `delete_watcher` is a soft, idempotent delete that returns the tombstoned watcher with status `"deleted"`, after which the watcher reads as `404`.

<div class="alert alert-warning">
<strong>Snapshot diffs carry untrusted page content.</strong> The dicts in <code>WatcherSnapshot.changes</code> come straight from the watched site. Escape them before rendering into HTML, an email body, or a chat message.
</div>

Diff engine and notification payloads are documented in [Watch](/docs/v2-watch).

---

## PDF options

`PdfOptions` is a frozen dataclass shared by `convert_url_to_pdf`, `convert_website_to_pdf`, `convert_document`, `convert_to_pdf`, and the `v2.perceive` family.

```python
from enconvert import BrowserCookie, HttpBasicAuth, PdfHeaderFooter, PdfMargins, PdfOptions

client.convert_url_to_pdf(
    "https://internal.example.com/report",
    pdf_options=PdfOptions(
        page_size="A4",
        orientation="landscape",
        margins=PdfMargins(top=10, bottom=10, left=15, right=15),
        scale=0.9,
        header=PdfHeaderFooter(content="Quarterly Report", height=15),
        footer=PdfHeaderFooter(content="Confidential", height=12),
    ),
    auth=HttpBasicAuth(username="user", password="pass"),
    cookies=[BrowserCookie(name="session", value="abc123", domain="internal.example.com")],
    save_to="report.pdf",
)
```

| Field | Type | Description |
|-------|------|-------------|
| `page_size` | `str` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and friends. |
| `page_width` / `page_height` | `float` | Custom page geometry. Together they override `page_size`. |
| `orientation` | `"portrait" \| "landscape"` | Defaults to portrait. |
| `margins` | `PdfMargins` | `top`, `bottom`, `left`, `right`, all optional. |
| `scale` | `float` | Render scale, for example `0.9` for 90 percent. |
| `grayscale` | `bool` | Post-process the PDF to grayscale. |
| `header` | `PdfHeaderFooter` | `content` (up to 2000 characters) and `height`. |
| `footer` | `PdfHeaderFooter` | `content` (up to 2000 characters) and `height`. |

`BrowserCookie` takes `name`, `value`, and either `domain` or `url`, plus optional `path`, `expires`, `http_only`, `secure`, and `same_site` (`"Strict"`, `"Lax"`, `"None"`). The SDK maps `http_only` and `same_site` onto their camelCase wire keys for you.

---

## Error handling

Errors are exception classes, so match them with `except`. Order handlers from specific to general: `AuthenticationError`, `QuotaError`, and `RateLimitError` all subclass `APIError`, which subclasses `EnconvertError`.

```python
from enconvert import APIError, AuthenticationError, EnconvertError, QuotaError, RateLimitError

try:
    op = client.v2.perceive("https://example.com")
except AuthenticationError:
    print("Invalid or missing API key. Check ENCONVERT_API_KEY.")
except QuotaError as e:
    print(f"402 from the API: {e.message}")
except RateLimitError:
    print("Too many requests. Back off and retry.")
except APIError as e:
    print(f"API error [{e.status_code}]: {e.message}")
except EnconvertError as e:
    print(f"Rejected before the request was sent: {e}")
```

| Class | Raised on | Status code |
|-------|-----------|-------------|
| `AuthenticationError` | Invalid, missing, or revoked key | `401`, `403` |
| `QuotaError` | HTTP 402 | `402` |
| `RateLimitError` | Too many requests | `429` |
| `APIError` | Any other 4xx or 5xx | the actual code |
| `EnconvertError` | Base class, and client-side validation that never reaches the network | -- |

`APIError` carries `status_code` and `message`, and its `str()` reads `[404] Not found`. Client-side raises you may hit: an empty `api_key`, an unsupported file extension, an unimplemented conversion pair, a `distill` call with both or neither of `urls` and `discover_from`, an `ingest` mode and argument mismatch, an empty `update_watcher` payload, and a `perceive_direct` output list that does not resolve to exactly one artifact. The full message map is in [Error codes](/docs/error-codes).

---

## Timeout recovery

Long URL and document conversions can outlive a reverse-proxy timeout even when the server finishes the job. The Python SDK recovers transparently:

1. Before each single-file or single-URL conversion, the SDK generates a UUID4 hex string and sends it as `job_id`.
2. If that request comes back 5xx, the SDK switches to polling `GET /v1/convert/status/{job_id}` every 3 seconds. A `404` there means the job row is not written yet, so polling continues.
3. On `success` the SDK returns the result as if nothing happened; on `failed` it raises `APIError(500, ...)` with the server's error message; and past the 300-second polling deadline it raises `APIError(504, "Conversion timed out")`.

You write no code for this. When a response arrives through the recovery path, `ConversionResult.job_id` is populated so you can correlate it in your logs.

Two carve-outs. `convert_website_to_pdf` and `convert_website_to_screenshot` opt out, because a website submission has no per-job row, so a 5xx there means the submission itself failed and surfaces directly. V2 endpoints are not polled either; use their own job ids with `get_perceive_batch` or `get_ingest_job`.

---

## Configuration

```python
client = Enconvert(
    api_key=os.environ["ENCONVERT_API_KEY"], timeout=300.0, base_url="https://api.enconvert.com"
)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_key` | `str` | -- (required) | Private API key. An empty value raises `EnconvertError` at construction. |
| `timeout` | `float` | `300.0` | Per-request timeout in seconds, applied to every call including uploads. |
| `base_url` | `str` | `https://api.enconvert.com` | API base URL. Trailing slashes are stripped. |

The client keeps a `requests.Session`, so connections pool across calls; reuse one client instead of building one per request. Your key travels as the `X-API-Key` header, and `save_to` downloads hit the signed storage URL as a plain unauthenticated GET streamed to disk in 64 KB chunks, so the key never leaves the API host.

<div class="alert alert-warning">
<strong>Never hardcode the API key.</strong> Read it from an environment variable or your secret manager, and keep it server-side. Anyone holding your private key can run work against your project.
</div>

---

## Result shape

Conversion methods return a frozen `ConversionResult`:

```python
@dataclass(frozen=True)
class ConversionResult:
    presigned_url: str
    object_key: str
    filename: str
    file_size: int | None = None
    conversion_time_seconds: float | None = None
    job_id: str | None = None
```

The presigned URL is short-lived. Pass `save_to`, or fetch the URL yourself, and store the bytes in your own bucket when you need durable access.

| Type | Returned by | Key fields |
|------|-------------|------------|
| `JobStatus` | `get_job_status` | `status` (`processing`, `success`, `failed`), `presigned_url`, `object_key`, `error` |
| `BatchSubmission`, `BatchStatus` | `convert_website_to_*`, `get_batch_status`, `wait_for_batch` | `batch_id`, `status`, `url_count`, `total`, `completed`, `failed`, `discovery_method`, `zip_download_url`, `items` |
| `PerceiveResult` | `v2.perceive`, `v2.get_perceive_operation` | `operation_id`, `render_quality`, `status_code`, `deductions`, `outputs`, `structured`, `warnings`, `cache_hit` |
| `PerceiveDirectResult` | `v2.perceive_direct`, `v2.download_perceive_artifact` | `content` (raw bytes), `content_type`, `filename`, `render_quality`, `source_status_code` |
| `IngestJob` | `v2.ingest`, `v2.ingest_files`, `v2.get_ingest_job` | `job_id`, `status`, `mode`, `pages_processed`, `total_chunks`, `output_url`, `error_message` |
| `Watcher` | the `v2` watch methods | `watcher_id`, `status`, `frequency_minutes`, `diff_mode`, `checks_count`, `next_check_at`, `last_change_at` |

V2 artifact URLs are signed for 15 minutes and re-signed each time you read the operation, so call `get_perceive_operation` rather than caching a URL string.

---

## Source and issues

- **PyPI:** [enconvert](https://pypi.org/project/enconvert/)
- **GitHub:** [conversionapi/python-sdk](https://github.com/conversionapi/python-sdk)
- **Python:** 3.9, 3.10, 3.11, 3.12, 3.13
- **License:** MIT
- **Other clients:** [All SDKs](/docs/sdks)

---

## Frequently asked questions

### How do I convert files in Python?

Run `pip install enconvert`, build a client with `Enconvert(api_key=os.environ["ENCONVERT_API_KEY"])`, and call a typed method such as `convert_document`, `convert_image`, `convert_to_pdf`, or `convert_to_markdown`. Pass `save_to="out.pdf"` and the SDK streams the finished file straight to disk instead of handing you a URL to fetch yourself.

### How do I convert a URL to PDF in Python?

Call `client.convert_url_to_pdf("https://example.com", save_to="page.pdf")`. Set `single_page=False` plus `pdf_options=PdfOptions(page_size="A4")` for paginated output, and adjust `viewport_width`, `load_media`, or `enable_scroll` when a page needs a wider canvas or lazy-loaded images.

### How do I convert DOCX to PDF in Python?

Either `client.convert_document("report.docx", save_to="report.pdf")`, since `output_format` already defaults to `"pdf"`, or `client.convert_to_pdf("report.docx", save_to="report.pdf")` when you want server-side format auto-detection. The same call handles XLSX, PPTX, ODT, ODS, ODP, OTS, Pages, and Numbers.

### How do I convert HEIC to WebP in Python?

`client.convert_image("photo.heic", output_format="webp", save_to="photo.webp")`. The input format comes from the filename extension, and `jpeg`, `png`, `svg`, `heic`, and `webp` all convert to each other. Working from memory instead of disk? Wrap the bytes in `FileData(data=blob, filename="photo.heic")` so the extension survives.

### How do I scrape a web page into clean Markdown in Python?

Use `client.v2.perceive(url, outputs=["markdown"])` and read `op.outputs["markdown"].url`, or `client.v2.perceive_direct(url, outputs=["markdown"])` to get the bytes back in the response body. Add `only_main_content=True` to drop navigation and footers. For a plain conversion with no scoring or extraction, `convert_url_to_markdown` is the lighter call.

### What does render_quality mean, and when should I retry a page?

It is a float from 0.0 to 1.0 on every V2 read that says how honestly the page rendered. A challenge screen, cookie wall, login gate, HTTP error page, or empty SPA shell scores low and names what fired in `deductions`, with detail in `warnings` and the page's own HTTP status in `status_code`. Gate on it: treat a low score as a signal to retry with `cache_mode="refresh"`, a `wait_for` selector that only real content matches, or different `cookies`, rather than feeding the text to your model.

### How do I turn a whole documentation site into RAG chunks in Python?

Call `client.v2.ingest(mode="sitemap", url="https://docs.example.com", max_pages=100, chunk=IngestChunkOptions(max_words=512, sentence_overlap=1))`. The job is asynchronous, so either poll `get_ingest_job(job_id)` until the status leaves `queued`, `discovering`, and `processing`, or pass `webhook_url` and wait to be called. The finished JSONL sits at `output_url`. For local documents rather than a site, `ingest_files` runs the same pipeline.

### Does the Python SDK support asyncio?

Not natively. Every method is synchronous and built on `requests`. Inside an async application, wrap calls in `await asyncio.to_thread(client.convert_to_pdf, "report.docx")` or hand them to a `concurrent.futures.ThreadPoolExecutor` so the event loop keeps running. The client is safe to share across threads because it holds a pooled `requests.Session`.

### How does the SDK survive a proxy timeout on a long conversion?

Each single-file and single-URL conversion sends a generated `job_id`. If the request returns 5xx, the SDK polls `GET /v1/convert/status/{job_id}` every 3 seconds for up to 300 seconds, returns normally once the job reports `success`, raises `APIError(500, ...)` if it reports `failed`, and raises `APIError(504, "Conversion timed out")` if the deadline passes. Website batch submissions skip this path, since a failure there means the submission itself did not land.
