---
seo_title: Rust File Conversion SDK: Blocking crates.io Client | EnConvert
meta_desc: Official EnConvert Rust SDK. A blocking reqwest client with no async runtime required, covering file conversion and the full V2 web intelligence namespace.
keywords: rust file conversion sdk, convert files rust, url to pdf rust, rust web scraping api, docx to pdf rust, enconvert rust sdk, html to pdf rust crate, heic to webp rust, rust website screenshot api, blocking reqwest api client, rust markdown extraction from url
---

# Rust File Conversion SDK

`enconvert` is the official Rust crate for the EnConvert API. It is a blocking client built on `reqwest`'s blocking API, so there is no async runtime, no `tokio` in your dependency tree, and no `.await` anywhere in your code. Twelve methods on `Enconvert` cover file conversion: URL to PDF, screenshots, Markdown extraction, image and document format pairs, anything-to-PDF, and whole-site batches. A second namespace, `client.v2()`, adds twenty-three methods for web intelligence: perceive, discover, lookup, distill, ingest, and watch. Every option struct derives `Default`, and every failure is one error enum.

<div class="alert alert-info">
<strong>crates.io:</strong> <code>enconvert</code> (0.1.0) &middot; <strong>Source:</strong> <a href="https://github.com/conversionapi/rust-sdk">conversionapi/rust-sdk</a> &middot; <strong>Rust:</strong> 2021 edition &middot; <strong>License:</strong> MIT
</div>

---

## Install

```bash
cargo add enconvert
```

Or add it by hand. `distill` and perceive `schema` extraction take a `serde_json::Map<String, Value>`, so add `serde_json` too if you plan to use them:

```toml
[dependencies]
enconvert = "0.1"
serde_json = "1"
```

The crate pulls in `reqwest` (blocking, json, multipart), `serde`, `serde_json`, `thiserror`, and `uuid`. There are no optional features to enable.

---

## Quick start

```rust
use enconvert::{Enconvert, PerceiveOptions, PerceiveOutputName, UrlToPdfOptions};

fn main() -> Result<(), enconvert::Error> {
    let key = std::env::var("ENCONVERT_API_KEY").expect("ENCONVERT_API_KEY is not set");
    let client = Enconvert::new(key)?;

    // Convert a URL to PDF and write the result straight to disk.
    let pdf = client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
        save_to: Some("page.pdf".into()),
        ..Default::default()
    })?;
    println!("{}", pdf.presigned_url);

    // Read the same page the way your agent should, with a quality score attached.
    let op = client.v2().perceive("https://example.com", PerceiveOptions {
        outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Structured]),
        ..Default::default()
    })?;
    println!("{:?}", op.render_quality); // e.g. Some(0.93)
    Ok(())
}
```

Every method blocks the calling thread, which makes the SDK easy to drop into a CLI, a build script, a worker thread, or a synchronous web handler. All public types are re-exported at the crate root, so the snippets below need nothing but `use enconvert::{...}` and the `client` from above.

<div class="alert alert-warning">
<strong>Do not call the SDK from inside an async runtime thread.</strong> <code>reqwest</code>'s blocking client cannot be driven from a thread already owned by a Tokio (or similar) reactor. From async code, wrap each call in <code>tokio::task::spawn_blocking</code>.
</div>

---

## What the client exposes

| Surface | Reached as | What it covers |
|---------|-----------|----------------|
| File conversion | `client.<method>` | 12 methods: URL to PDF, screenshot, and Markdown; image and document pairs; anything-to-Markdown and anything-to-PDF; whole-site batches; job and batch status |
| Web intelligence | `client.v2().<method>` | 23 methods across perceive, discover, lookup, distill, ingest, and watch |
| Format tables | `valid_outputs_for`, `IMPLEMENTED_CONVERSIONS` | The 43 implemented `{input}-to-{output}` endpoints, checked client side before a request is sent |
| Errors | `enconvert::Error` | One enum, nine variants, with `is_authentication`, `is_quota`, `is_rate_limit`, `is_server_error`, and `status_code` |

Nothing is a builder and nothing is `async`. The idiomatic call shape is a struct literal with `..Default::default()`.

---

## File conversion

### `convert_url_to_pdf`

Render any public URL to a PDF.

```rust
use enconvert::{PdfOptions, PdfOrientation, UrlToPdfOptions};

let result = client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
    single_page: Some(false),
    pdf_options: Some(PdfOptions {
        page_size: Some("A4".to_string()),
        orientation: Some(PdfOrientation::Landscape),
        ..Default::default()
    }),
    save_to: Some("report.pdf".into()),
    ..Default::default()
})?;
println!("{} ({:?} bytes)", result.filename, result.file_size);
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `save_to` | `Option<PathBuf>` | - | Local path to write the PDF to. Parent directories are created automatically. |
| `single_page` | `Option<bool>` | `true` | `true` produces one continuous page. `false` paginates using `pdf_options.page_size`. |
| `pdf_options` | `Option<PdfOptions>` | - | Page geometry, scale, grayscale, header and footer. See [PDF options](#pdf-options). |
| `render.viewport_width` / `render.viewport_height` | `Option<u32>` | `1920` / `1080` | Browser viewport in pixels. |
| `render.load_media` / `render.enable_scroll` | `Option<bool>` | `true` | Wait for images and video, and scroll top to bottom to trigger lazy loaders. |
| `render.output_filename` | `Option<String>` | auto | Override the generated filename. |
| `render.auth` / `render.cookies` / `render.headers` | see types | - | HTTP Basic Auth, up to 50 injected cookies, up to 20 extra request headers. |

The `render` block is `UrlRenderOptions`, shared by every URL and website conversion. Do not combine `auth` with an `Authorization` header: the API rejects the conflict.

### `convert_url_to_screenshot`

Capture a PNG of any URL. `UrlToScreenshotOptions` carries the same `render` block plus `save_to`, and nothing else.

```rust
use enconvert::{UrlRenderOptions, UrlToScreenshotOptions};

client.convert_url_to_screenshot("https://example.com", UrlToScreenshotOptions {
    render: UrlRenderOptions { viewport_width: Some(1440), ..Default::default() },
    save_to: Some("shot.png".into()),
})?;
```

### `convert_url_to_markdown`

Extract clean GitHub-Flavored Markdown from a URL. Navigation, footers, ads, and scripts are stripped, the main article body is kept, and YAML frontmatter (title, description, url, links, images) is prepended.

```rust
use enconvert::UrlToMarkdownOptions;

client.convert_url_to_markdown("https://example.com/article", UrlToMarkdownOptions {
    save_to: Some("article.md".into()),
    ..Default::default()
})?;
```

Useful for RAG pipelines, CMS imports, and training-data collection. For a scored read with artifacts and structured extraction in one call, use [perceive](#perceive) instead.

### `convert_image`

Convert between `jpeg`, `png`, `svg`, `heic`, and `webp` (all 20 ordered pairs), or rasterize a PDF to JPEG.

```rust
use enconvert::{ConvertImageOptions, NamedFile};

// From a path: the input format comes from the extension.
client.convert_image("photo.heic", ConvertImageOptions {
    output_format: "webp".to_string(),
    save_to: Some("photo.webp".into()),
    ..Default::default()
})?;

// From bytes: supply a filename so the extension can still be read.
let data = std::fs::read("photo.heic")?;
client.convert_image(
    NamedFile { data, filename: "photo.heic".to_string(), content_type: None },
    ConvertImageOptions { output_format: "png".to_string(), ..Default::default() },
)?;
```

`output_format` is the only required field; `save_to` and `output_filename` are optional. The first argument is anything that converts into `FileInput`: a `&str` or `String` path, a `&Path` or `PathBuf`, a `Vec<u8>` or `&[u8]` of raw bytes, or a `NamedFile` when you have bytes and want to declare the filename and content type yourself.

### `convert_document`

Convert documents and data formats. `output_format` defaults to `"pdf"`, and `yml`, `htm`, `md`, and `jpg` are normalized to their canonical names. `save_to`, `output_filename`, and `pdf_options` are optional.

```rust
use enconvert::{ConvertDocumentOptions, PdfOptions};

// docx to pdf (the default output format)
client.convert_document("report.docx", ConvertDocumentOptions {
    save_to: Some("report.pdf".into()),
    ..Default::default()
})?;

// json to yaml
client.convert_document("data.json", ConvertDocumentOptions {
    output_format: Some("yaml".to_string()),
    save_to: Some("data.yaml".into()),
    ..Default::default()
})?;

// markdown to pdf with custom page setup
client.convert_document("README.md", ConvertDocumentOptions {
    pdf_options: Some(PdfOptions { page_size: Some("A4".to_string()), ..Default::default() }),
    ..Default::default()
})?;
```

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

| Input format | Valid outputs |
|--------------|---------------|
| `json` | `csv`, `toml`, `xml`, `yaml` |
| `xml` | `csv`, `json` |
| `csv` | `json`, `xml` |
| `yaml`, `toml` | `json` |
| `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` |

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

`convert_image` and `convert_document` both resolve the input format from the file extension and check the pair against the client-side table, so an unimplemented pair returns `Error::UnsupportedConversion` with the valid outputs listed, before any HTTP request is made. Query the same table directly:

```rust
use enconvert::{valid_outputs_for, IMPLEMENTED_CONVERSIONS};

println!("{:?}", valid_outputs_for("json"));   // ["csv", "toml", "xml", "yaml"]
println!("{}", IMPLEMENTED_CONVERSIONS.len()); // 43
```

### `convert_to_markdown`

Upload a document of almost any type and get clean Markdown back: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, and legacy or ODF office files. The format is detected server side, so no client-side extension check runs. Images are not supported.

```rust
use enconvert::ConvertToMarkdownOptions;

client.convert_to_markdown("handbook.pdf", ConvertToMarkdownOptions {
    save_to: Some("handbook.md".into()),
    ..Default::default()
})?;
```

The output is one heading-aware `.md` file, which makes it a good building block for RAG chunking: a semantic chunker can split on the document's own heading hierarchy instead of arbitrary character counts. There are no PDF options here, only `save_to` and `output_filename`.

### `convert_to_pdf`

Upload almost anything and get a PDF back: office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, or an existing PDF for passthrough.

```rust
use enconvert::{ConvertToPdfOptions, PdfOptions};

client.convert_to_pdf("slides.pptx", ConvertToPdfOptions {
    save_to: Some("slides.pdf".into()),
    ..Default::default()
})?;

// PDF in, grayscale PDF out.
client.convert_to_pdf("scan.pdf", ConvertToPdfOptions {
    pdf_options: Some(PdfOptions { grayscale: Some(true), ..Default::default() }),
    save_to: Some("scan-gray.pdf".into()),
    ..Default::default()
})?;
```

<div class="alert alert-warning">
<strong>Only <code>pdf_options.grayscale</code> is honored here.</strong> The anything-to-PDF endpoint auto-detects the input and applies its own geometry. For control over page size, orientation, margins, scale, headers, and footers, use <code>convert_url_to_pdf</code> or <code>convert_document</code> with an HTML or Markdown input.
</div>

### `convert_website_to_pdf` and `convert_website_to_screenshot`

Discover every page of a website, convert each one in the background, and collect a single ZIP. Both are asynchronous only: they return a `BatchSubmission`, never a finished file.

```rust
use enconvert::{CrawlMode, WaitForBatchOptions, WebsiteConversionOptions, WebsiteToPdfOptions};

let batch = client.convert_website_to_pdf("https://example.com", WebsiteToPdfOptions {
    website: WebsiteConversionOptions {
        crawl_mode: Some(CrawlMode::Sitemap),
        exclude_patterns: Some(vec!["/blog/tag/".to_string()]),
        notification_email: Some("ops@example.com".to_string()),
        ..Default::default()
    },
    ..Default::default()
})?;

// Block until the batch settles, then save the ZIP.
let status = client.wait_for_batch(&batch.batch_id, WaitForBatchOptions {
    save_to: Some("site.zip".into()),
    ..Default::default()
})?;
println!("{} of {} pages converted", status.completed, status.total);
```

| Field | Type | Default | Description |
|-------|------|---------|-------------|
| `website.crawl_mode` | `Option<CrawlMode>` | `Auto` | `Auto`, `Sitemap` (sitemap.xml only), or `Full` (sitemap plus a breadth-first crawl). |
| `website.include_patterns` / `website.exclude_patterns` | `Option<Vec<String>>` | - | Crawl only, or skip, URLs matching these patterns. Full crawl mode. |
| `website.notification_email` / `website.callback_url` | `Option<String>` | project owner / - | Address emailed, and webhook posted, when the batch finishes. |
| `website.render` | `UrlRenderOptions` | - | Same viewport, media, scroll, auth, cookie, and header fields as single-URL conversions. |
| `single_page` / `pdf_options` | see above | - | PDF batches only. |

`convert_website_to_screenshot` takes `WebsiteToScreenshotOptions`, a type alias for `WebsiteConversionOptions`, and produces a ZIP of PNGs. `wait_for_batch` polls `get_batch_status` every `interval_ms` (default 5000) until the batch leaves `Processing`, then optionally downloads the ZIP. If `timeout_ms` (default 1,800,000, that is 30 minutes) elapses first, it returns `Error::Api { status: 504, .. }`.

### `get_job_status` and `get_batch_status`

```rust
use enconvert::JobStatusValue;

let status = client.get_job_status("job_abc123")?;
match status.status {
    JobStatusValue::Success => println!("{:?}", status.presigned_url),
    JobStatusValue::Failed => eprintln!("{:?}", status.error),
    other => println!("still running: {other:?}"),
}

let batch = client.get_batch_status("batch_abc123")?; // aggregate counts and per-URL items
println!("{}/{} done, {} failed", batch.completed, batch.total, batch.failed);
```

<div class="alert alert-info">
<strong>You rarely need <code>get_job_status</code> directly.</strong> The SDK polls it for you when a synchronous conversion returns 5xx. See <a href="#timeout-recovery">Timeout recovery</a>.
</div>

---

## Web intelligence (V2)

Everything under `client.v2()` turns web pages into agent-ready data. The one thing every read has in common is `render_quality`, a score from 0.0 to 1.0 attached to each rendered page. A low score means the page did not render cleanly: a challenge page, a cookie or login wall, an empty SPA shell, or an HTTP error. The content still comes back, but it comes back flagged, with a named `deductions` map and a `warnings` list, so a bad read never quietly enters your agent's context. Perceive, distill, lookup, and watch results all carry it. Start with the [V2 overview](/docs/v2-overview) for the concepts behind the six capabilities.

### Perceive

Render one URL into the artifacts you ask for. Synchronous: the call returns a completed operation with 15-minute signed artifact URLs. See [perceive](/docs/v2-perceive).

```rust
use enconvert::{PerceiveExtractName, PerceiveOptions, PerceiveOutputName};

let op = client.v2().perceive("https://example.com", PerceiveOptions {
    outputs: Some(vec![PerceiveOutputName::Markdown, PerceiveOutputName::Screenshot]),
    extract: Some(vec![PerceiveExtractName::Tables, PerceiveExtractName::Metadata]),
    ..Default::default()
})?;

println!("{:?}", op.render_quality);
println!("{:?}", op.deductions); // e.g. {"http_error": 0.7} when something is wrong
println!("{:?}", op.outputs.get("markdown").and_then(|a| a.url.as_ref()));
println!("{:?}", op.structured);

// Artifact URLs are re-signed on every status read.
client.v2().get_perceive_operation(&op.operation_id)?;
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputs` | `Option<Vec<PerceiveOutputName>>` | `markdown`, `structured` | `Markdown`, `HtmlCleaned`, `HtmlRaw`, `Screenshot`, `ScreenshotFullPage`, `Pdf`, `Links`, `Images`, `Structured`. |
| `extract` | `Option<Vec<PerceiveExtractName>>` | - | `Tables`, `Prices`, `Contacts`, `Metadata`, `MainContent`, `Headings`, `StructuredData`, `Technologies`, `All`. |
| `schema` | `Option<Map<String, Value>>` | - | JSON schema for structured extraction. |
| `only_main_content` | `Option<bool>` | `true` | Strip nav, header, footer, and cookie banners from the Markdown artifact and the `main_content` extract. |
| `wait_for` / `wait_timeout_ms` / `js_code` | see types | - / `30000` / - | A CSS selector (optionally `css:...`) or `js:<expr>` to await, how long to wait (0 to 60000), and JavaScript to run after navigation (max 20000 characters). |
| `viewport` / `mobile` | `Option<PerceiveViewport>` / `Option<bool>` | 1920x1080 | Width 320 to 3840, height 240 to 2160, or a mobile device profile. |
| `cache_mode` / `block_resources` | see types | `Enabled` / - | `Enabled` caches for 1 hour, `Bypass` skips, `Refresh` re-renders; plus resource types the browser should not load. |
| `headers`, `cookies`, `auth`, `respect_robots`, `pdf_options` | see `UrlRenderOptions` and `PdfOptions` | - | Same shapes as the V1 render options. `pdf_options` matters only when `outputs` includes `Pdf`. |

`perceive_direct` streams a single artifact back as raw bytes instead of a JSON envelope. Exactly one artifact-producing output must be requested, and the SDK enforces that locally with `Error::InvalidInput` before sending anything.

```rust
use enconvert::{PerceiveOptions, PerceiveOutputName};

let direct = client.v2().perceive_direct("https://example.com", PerceiveOptions {
    outputs: Some(vec![PerceiveOutputName::Pdf]),
    ..Default::default()
})?;
std::fs::write(direct.filename.as_deref().unwrap_or("page.pdf"), &direct.content)?;

// Re-download a stored artifact later. Pass None when the operation made only one.
let saved = client
    .v2()
    .download_perceive_artifact(&direct.operation_id, Some(PerceiveOutputName::Pdf))?;
println!("{} bytes", saved.content.len());
```

`PerceiveDirectResult` carries `content`, `content_type`, `filename`, `operation_id`, `object_key`, `cache_hit`, `render_quality`, `source_status_code`, `content_hash`, and `warnings_count`, all read from response headers. `Error::Api { status: 410, .. }` from `download_perceive_artifact` means the artifact is past its retention window.

`perceive_batch` takes up to 1000 URLs and one shared options block. Small batches run inline; larger ones come back with status `Queued`, so poll `get_perceive_batch` with the `job_id`.

```rust
use enconvert::{PerceiveBatchOptions, PerceiveBatchOutputMode, PerceiveOptions, PerceiveOutputName};

let render = PerceiveOptions {
    outputs: Some(vec![PerceiveOutputName::Markdown]),
    ..Default::default()
};
let batch = client.v2().perceive_batch(
    vec!["https://example.com/a".to_string(), "https://example.com/b".to_string()],
    PerceiveBatchOptions { render, output_mode: Some(PerceiveBatchOutputMode::Zip) },
)?;

let done = client.v2().get_perceive_batch(&batch.job_id)?;
for item in &done.items {
    println!("{} {:?}", item.url, item.render_quality);
}
```

### Discover

Enumerate a site's URLs with no browser rendering at all. See [discover](/docs/v2-discover).

```rust
use enconvert::{DiscoverMode, DiscoverOptions};

let found = client.v2().discover("https://example.com", DiscoverOptions {
    mode: Some(DiscoverMode::Hybrid),
    max_urls: Some(200),
    exclude_patterns: Some(vec!["/tag/".to_string()]),
    ..Default::default()
})?;
println!("{} urls, truncated: {}", found.total, found.truncated);
```

`mode` defaults to `Hybrid` (`Sitemap` and `Crawl` are the alternatives), `max_urls` to 100 (1 to 1000), `max_depth` to 2 (1 to 5), and `same_domain_only` to true. `include_patterns` then `exclude_patterns` apply regex filtering, max 50 each, and `respect_robots` honors robots.txt. The result carries `urls`, `total`, `pages_crawled`, `truncated`, `robots_respected`, and a `sources` map of raw per-source counts.

### Lookup

Run a categorized web search, and optionally render the top results in the same call. See [lookup](/docs/v2-lookup).

```rust
use enconvert::{LookupCategory, LookupOptions};

let search = client.v2().lookup("best static site generators", LookupOptions {
    category: Some(LookupCategory::Web),
    num_results: Some(10),
    country: Some("us".to_string()),
    perceive_top: Some(3),
    ..Default::default()
})?;

for hit in &search.results {
    let quality = hit.perceive.as_ref().and_then(|p| p.render_quality);
    println!("{:?} {:?} {quality:?}", hit.title, hit.url);
}
```

`category` defaults to `Web` (`News`, `Images`, `Scholar`, `Patents`, and `Maps` are the others), `num_results` to 10 (1 to 100), `page` to 1 (1 to 10), and `autocorrect` to true. `country` and `locale` take codes such as `"us"` and `"en"`, `location` takes free text such as `"Austin, Texas"`, and `time_filter` accepts `Hour`, `Day`, `Week`, `Month`, or `Year`. `perceive_top` (0 to 10, default 0) auto-renders the top N result URLs and attaches a full `PerceiveResult` to each hit. The result also carries `answer_box`, `knowledge_graph`, and `perceive_operation_ids`.

### Distill

Schema-driven structured extraction: give it a shape, get that shape back for every URL. An optional CSS pass runs first, and anything it misses escalates to the LLM tier. See [distill](/docs/v2-distill).

```rust
use enconvert::{CssField, CssFieldType, CssSchema, DistillOptions};
use serde_json::json;

// CssField has no Default because `field_type` is required.
fn text_field(name: &str, selector: &str) -> CssField {
    CssField {
        name: name.into(), field_type: CssFieldType::Text, selector: Some(selector.into()),
        attribute: None, pattern: None, default: None, transform: None, fields: None,
    }
}

let distilled = client.v2().distill(DistillOptions {
    urls: Some(vec!["https://example.com/pricing".to_string()]),
    schema: json!({ "plans": "list of plan names with monthly prices" })
        .as_object().unwrap().clone(),
    css_schema: Some(CssSchema {
        base_selector: ".plan-card".to_string(),
        fields: vec![text_field("name", "h3"), text_field("price", ".price")],
        name: None,
        target_field: None,
    }),
    ..Default::default()
})?;

for item in &distilled.results {
    println!("{:?} tier {:?} css {} llm {}",
        item.data, item.extraction_tier, item.fields_from_css, item.fields_from_llm);
}
```

Or discover a site first and distill every page it finds:

```rust
use enconvert::{DistillDiscoverFrom, DistillOptions};
use serde_json::json;

client.v2().distill(DistillOptions {
    discover_from: Some(DistillDiscoverFrom {
        max_pages: Some(20), // 1 to 50, caps both discovery and distillation
        ..DistillDiscoverFrom::new("https://example.com")
    }),
    schema: json!({ "title": "page title" }).as_object().unwrap().clone(),
    ..Default::default()
})?;
```

Supply exactly one of `urls` (max 50) or `discover_from`. Both, or neither, returns `Error::InvalidInput` before any request is sent. `schema` is required and is either a JSON-Schema object or a flat `{field: description}` map. `CssFieldType` covers `Text`, `Attribute`, `Html`, `Regex`, `Nested`, `List`, and `NestedList`; `transform` accepts `Lowercase`, `Uppercase`, or `Strip`; nesting is capped at depth 5.

### Ingest

Turn a whole site, or a pile of uploaded documents, into chunked RAG-ready JSONL through one pipeline. Ingest is always asynchronous. See [ingest](/docs/v2-ingest).

```rust
use enconvert::{
    IngestChunkOptions, IngestFilesOptions, IngestMode, IngestOptions, IngestStatus, V2ListOptions,
};

// From a site.
let job = client.v2().ingest(IngestOptions {
    mode: Some(IngestMode::Sitemap),
    url: Some("https://docs.example.com".to_string()),
    max_pages: Some(100),
    chunk: Some(IngestChunkOptions { max_words: Some(512), sentence_overlap: Some(1) }),
    webhook_url: Some("https://my.app/hooks/enconvert".to_string()),
    ..Default::default()
})?;

// Or from uploaded files: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD,
// and legacy or ODF office documents.
let files = vec!["handbook.pdf".into(), "notes.docx".into()];
client.v2().ingest_files(files, IngestFilesOptions::default())?;

// Poll, list, cancel.
let status = client.v2().get_ingest_job(&job.job_id)?;
if status.status == IngestStatus::Completed {
    println!("{:?}", status.output_url); // signed URL to the JSONL
}
client.v2().list_ingest_jobs(V2ListOptions { limit: Some(50), ..Default::default() })?;
client.v2().cancel_ingest_job(&job.job_id)?; // idempotent
```

`mode` defaults to `Urls`, which requires a non-empty `urls` list (max 1000) and rejects `url`; `Sitemap` and `Crawl` are the reverse, requiring the seed `url`. That pairing is validated client side, so a mismatch returns `Error::InvalidInput` immediately. `max_pages` defaults to 50 (1 to 1000), `max_depth` to 2 (1 to 5), `same_domain_only` to true, `chunk.max_words` to 512 (32 to 4000), and `chunk.sentence_overlap` to 1 (0 to 10). `include_patterns`, `exclude_patterns`, `respect_robots`, `wait_for`, and `wait_timeout_ms` behave as they do on discover and perceive. Completion webhooks are HMAC signed:

```rust
let secret = client.v2().get_webhook_secret()?;
println!("{} {} {}", secret.secret, secret.signature_header, secret.signature_scheme);
client.v2().rotate_webhook_secret()?;            // old signatures stop verifying at once
client.v2().retry_ingest_webhook("ing_abc123")?; // re-deliver a completed job's webhook
```

### Watch

Re-render a URL on a fixed cadence and get told what changed. See [watch](/docs/v2-watch).

```rust
use enconvert::{
    SnapshotListOptions, V2ListOptions, WatchCreateOptions, WatchDiffMode, WatchUpdateStatus,
    WatcherUpdate,
};

let watcher = client.v2().create_watcher("https://example.com/pricing", WatchCreateOptions {
    frequency_minutes: Some(60),
    diff_mode: Some(WatchDiffMode::Auto),
    webhook_url: Some("https://my.app/hooks/changes".to_string()),
    notify_email: Some(true),
    ..Default::default()
})?;
println!("{} next check {:?}", watcher.watcher_id, watcher.next_check_at);

// Read the check history, newest first.
let history = client
    .v2()
    .get_watcher_snapshots(&watcher.watcher_id, SnapshotListOptions { limit: Some(10) })?;
for snap in &history.snapshots {
    println!("{} changed: {} similarity: {:?}", snap.checked_at, snap.has_changes, snap.similarity);
}
client.v2().list_watchers(V2ListOptions::default())?;
client.v2().get_watcher(&watcher.watcher_id)?;

// Pause it. Passing Some(String::new()) for webhook_url would clear the webhook.
client.v2().update_watcher(&watcher.watcher_id, WatcherUpdate {
    status: Some(WatchUpdateStatus::Paused),
    ..Default::default()
})?;
client.v2().delete_watcher(&watcher.watcher_id)?; // soft delete, idempotent
```

`frequency_minutes` defaults to 60 and accepts 60 to 43200, so the hourly floor is hard. `diff_mode` defaults to `Auto` and also accepts `Text`, `Structured`, `Tables`, and `Metadata`, with `track_fields` narrowing the diff engine to a field or selector subset. `notify_email` defaults to true and emails the project owner on changes; `webhook_url` adds an HMAC-signed change webhook.

`update_watcher` requires at least one field and returns `Error::InvalidInput` for an all-`None` `WatcherUpdate`. Snapshot `changes` entries hold untrusted page content, so escape them before rendering anywhere.

---

## PDF options

`PdfOptions` is shared by `convert_url_to_pdf`, `convert_document`, `convert_to_pdf`, and perceive's `Pdf` output. Every field is optional and only sent when set.

```rust
use enconvert::{PdfHeaderFooter, PdfMargins, PdfOptions, PdfOrientation, UrlToPdfOptions};

fn block(text: &str, height: f64) -> PdfHeaderFooter {
    PdfHeaderFooter { content: Some(text.into()), height: Some(height) }
}

client.convert_url_to_pdf("https://example.com", UrlToPdfOptions {
    pdf_options: Some(PdfOptions {
        page_size: Some("A4".to_string()),
        orientation: Some(PdfOrientation::Landscape),
        margins: Some(PdfMargins {
            top: Some(10.0), bottom: Some(10.0), left: Some(15.0), right: Some(15.0),
        }),
        scale: Some(0.9),
        header: Some(block("Quarterly Report", 15.0)),
        footer: Some(block("Confidential", 12.0)),
        ..Default::default()
    }),
    ..Default::default()
})?;
```

| Field | Type | Description |
|-------|------|-------------|
| `page_size` | `Option<String>` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and so on. |
| `page_width` / `page_height` | `Option<f64>` | Custom dimensions. Set together, they override `page_size`. |
| `orientation` | `Option<PdfOrientation>` | `Portrait` or `Landscape`. |
| `margins` | `Option<PdfMargins>` | `top`, `bottom`, `left`, `right`, each optional. |
| `scale` | `Option<f64>` | Render scale, for example `0.9` for 90 percent. |
| `grayscale` | `Option<bool>` | Post-process the PDF to grayscale. |
| `header` / `footer` | `Option<PdfHeaderFooter>` | `content` (max 2000 characters) and `height`. |

Full parameter semantics live in [parameters and options](/docs/parameters-options).

---

## Error handling

There is one error type, `enconvert::Error`. It implements `std::error::Error` through `thiserror`, so `?` propagates into any `Box<dyn Error>` or `anyhow::Result`.

```rust
use enconvert::{Error, UrlToPdfOptions};

match client.convert_url_to_pdf("https://example.com", UrlToPdfOptions::default()) {
    Ok(result) => println!("{}", result.presigned_url),
    Err(e) if e.is_authentication() => eprintln!("check ENCONVERT_API_KEY"),
    Err(e) if e.is_rate_limit() => eprintln!("too many requests, back off"),
    Err(e) if e.is_server_error() => eprintln!("gateway problem, retry later"),
    Err(Error::UnsupportedConversion(msg)) => eprintln!("{msg}"),
    Err(Error::Api { status, message }) => eprintln!("API error [{status}]: {message}"),
    Err(e) => return Err(e),
}
```

| Variant | Returned on | Status code |
|---------|-------------|-------------|
| `Error::Authentication(String)` | Invalid, missing, or revoked API key | `401`, `403` |
| `Error::Quota(String)` | Raised on HTTP 402 | `402` |
| `Error::RateLimit(String)` | Rate limit exceeded | `429` |
| `Error::Api { status, message }` | Any other 4xx or 5xx response | the actual code |
| `Error::UnsupportedConversion(String)` | A `{input}-to-{output}` pair the API does not implement, caught client side | - |
| `Error::InvalidInput(String)` | Bad arguments caught before the request, such as an empty API key | - |
| `Error::Http(reqwest::Error)` | Connection, TLS, timeout, or response-decoding failure | from the transport, when present |
| `Error::Io(std::io::Error)` | Reading a file to upload, or writing a download | - |
| `Error::Json(serde_json::Error)` | Serializing a request body | - |

Four predicates keep match arms short: `is_authentication()`, `is_quota()`, `is_rate_limit()`, and `is_server_error()`. `status_code()` returns `Option<u16>` for every variant that carries one. The full message map is in the [error codes reference](/docs/error-codes).

---

## Timeout recovery

Long URL-to-PDF renders and large document conversions can outlive a reverse-proxy timeout even when the conversion itself succeeds on the server. The SDK recovers on its own:

1. Before each request it generates a UUID and sends it as `job_id` in the body or the multipart form.
2. If that request comes back as a 5xx `Error::Api`, the SDK silently switches to polling `GET /v1/convert/status/{job_id}` every 3 seconds.
3. As soon as the job reads `success`, it returns the result. As soon as it reads `failed`, it returns `Error::Api` carrying the server's message.
4. The polling deadline is 5 minutes. Past that you get `Error::Api { status: 504, message: "Conversion timed out" }`.

Recovery covers `convert_url_to_pdf`, `convert_url_to_screenshot`, `convert_url_to_markdown`, `convert_image`, `convert_document`, `convert_to_markdown`, and `convert_to_pdf`. It deliberately does not cover the website batch submissions: those have no per-job row, so a 5xx there means the submission itself failed and surfaces directly. V2 endpoints answer directly and have no job fallback either. Responses that omit `job_id` get the client-generated one backfilled, so `result.job_id` is always something you can hand to `get_job_status` later.

---

## Configuration

```rust
use enconvert::Enconvert;
use std::time::Duration;

let client = Enconvert::with_options(
    std::env::var("ENCONVERT_API_KEY").expect("ENCONVERT_API_KEY is not set"),
    Some("https://api.enconvert.com"), // base URL override
    Some(Duration::from_secs(300)),    // request timeout
)?;
println!("enconvert {}", enconvert::VERSION);
```

| Argument | Type | Default | Description |
|----------|------|---------|-------------|
| `api_key` | `impl Into<String>` | required | Private API key. An empty string returns `Error::InvalidInput`, and so does a key containing invalid header characters. |
| `base_url` | `Option<&str>` | `https://api.enconvert.com` | Trailing slashes are stripped. |
| `timeout` | `Option<Duration>` | 300 seconds | Applied to the whole request. |

`Enconvert::new(api_key)` is shorthand for `with_options(api_key, None, None)`.

<div class="alert alert-warning">
<strong>Never hardcode the API key.</strong> Read it from an environment variable or a secret manager. The SDK marks the <code>X-API-Key</code> header value sensitive so it stays out of debug output, and it downloads signed URLs through a second, unauthenticated HTTP client so your key is never sent to object storage. Generate and rotate keys in the <a href="/dashboard">dashboard</a>, and see <a href="/docs/authentication">authentication</a> for key types.
</div>

---

## Result shape

Every single-file conversion method returns a `ConversionResult`:

```rust
pub struct ConversionResult {
    pub presigned_url: String,
    pub object_key: String,
    pub filename: String,
    pub file_size: Option<u64>,
    pub conversion_time_seconds: Option<f64>,
    pub job_id: Option<String>,
}
```

Download the file yourself from `presigned_url`, or pass `save_to` and let the SDK write it for you. Signed URLs expire, so store anything you need to keep in your own bucket.

The async paths return their own shapes. `JobStatus` carries `status` (`Processing`, `Success`, `Failed`, or `Unknown(String)`), `presigned_url`, `object_key`, and `error`. `BatchSubmission` carries `batch_id`, `status`, `url_count`, `total_discovered`, `discovery_method`, and `output_format`. `BatchStatus` adds the counters `total`, `completed`, `failed`, and `in_progress`, plus `output_mode`, `zip_download_url`, and a `Vec<BatchItem>` of per-URL rows.

V2 reads return `PerceiveResult`, whose `outputs` map is keyed by output name (`"markdown"`, `"screenshot_full_page"`, and so on) with `V2OutputArtifact { url, object_key, size_bytes, content_type, expires_in }` values. Those artifact URLs are signed for 15 minutes and re-signed on every `get_perceive_operation` call. Alongside them sit `render_quality`, `status_code`, `deductions`, `cache_hit`, `structured`, `extraction_tier`, `tokens`, `cost_cents`, `duration_ms`, `warnings`, and `options_echo`. Every response enum carries an `Unknown(String)` variant, so a status value added on the server after your build shipped parses cleanly instead of failing.

---

## Source and issues

- **crates.io:** [enconvert](https://crates.io/crates/enconvert)
- **GitHub:** [conversionapi/rust-sdk](https://github.com/conversionapi/rust-sdk)
- **License:** MIT
- **Other clients:** [all SDKs](/docs/sdks) and the [REST endpoint reference](/docs/endpoints-overview)

---

## Frequently asked questions

### How do I convert files in Rust with a crates.io package?

Run `cargo add enconvert`, build a client with `Enconvert::new(api_key)?`, and call a method such as `convert_url_to_pdf`, `convert_image`, or `convert_document`. Every option struct derives `Default`, so you write a struct literal with `..Default::default()` and set only the fields you care about. Pass `save_to` to have the SDK write the output straight to disk.

### Does the Rust SDK need tokio or an async runtime?

No. It is built on `reqwest`'s blocking API, so every method blocks the calling thread and there is no `.await`, no executor, and no `tokio` in your dependency tree. If your application is already async, call the SDK from `tokio::task::spawn_blocking` rather than directly on a reactor thread, because `reqwest`'s blocking client cannot run inside an async runtime context.

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

Call `convert_url_to_pdf("https://example.com", UrlToPdfOptions { save_to: Some("page.pdf".into()), ..Default::default() })`. Set `single_page: Some(false)` to paginate instead of producing one continuous page, and pass `pdf_options` for page size, orientation, margins, scale, grayscale, headers, and footers.

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

Call `convert_document("report.docx", ConvertDocumentOptions { save_to: Some("report.pdf".into()), ..Default::default() })`. PDF is the default output, so `output_format` can be left unset. The same method handles XLSX, PPTX, ODT, ODS, ODP, OTS, Pages, Numbers, HTML, and Markdown to PDF, plus data-format pairs such as JSON to YAML and CSV to XML.

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

Call `convert_image("photo.heic", ConvertImageOptions { output_format: "webp".to_string(), ..Default::default() })`. The input format comes from the file extension, and all 20 ordered pairs among `jpeg`, `png`, `svg`, `heic`, and `webp` are implemented, plus `pdf` to `jpeg` for rasterization. An unsupported pair returns `Error::UnsupportedConversion` before any network call.

### How do I scrape a web page into clean Markdown from Rust?

Two ways. `convert_url_to_markdown` returns a single Markdown file with YAML frontmatter. `client.v2().perceive(url, PerceiveOptions { outputs: Some(vec![PerceiveOutputName::Markdown]), ..Default::default() })` returns the same content with a `render_quality` score, named deductions, warnings, and optional screenshots, links, or structured extraction from the same render. Use perceive when an agent is going to read the result.

### How do I know whether a page actually rendered?

Read `render_quality`, a score from 0.0 to 1.0 present on every V2 read. A low score means the render was not clean: a challenge page, a cookie or login wall, an empty SPA shell, or an HTTP error. The `deductions` map names each penalty that fired, `status_code` gives the upstream HTTP status, and `warnings` lists what went wrong. The content is still returned, just flagged.

### What happens when a conversion takes longer than the proxy timeout?

The SDK handles it. Each request carries a client-generated `job_id`; if the request returns 5xx, the SDK polls `GET /v1/convert/status/{job_id}` every 3 seconds for up to 5 minutes and returns the finished result as if nothing had gone wrong. Past the deadline you get `Error::Api { status: 504, message: "Conversion timed out" }`. Whole-site batch submissions are the exception, and `wait_for_batch` is the tool for those.

### Can I use the Rust SDK from a browser or WASM target?

No. It authenticates with a private API key that must never ship to a client, and it depends on `reqwest`'s blocking client with native TLS and threads, neither of which exists on `wasm32-unknown-unknown`. Run it on a server, in a CLI, or in a worker, and have your frontend talk to your own backend.
