---
seo_title: Kotlin File Conversion SDK: JVM API Client | EnConvert
meta_desc: Official EnConvert Kotlin SDK for JDK 17+. Idiomatic Kotlin data classes for file conversion and for perceiving, discovering, distilling, ingesting, watching pages.
keywords: kotlin file conversion sdk, convert files kotlin, url to pdf kotlin, kotlin web scraping api, docx to pdf kotlin, enconvert kotlin sdk, jvm file conversion api client, heic to webp kotlin, web page to markdown kotlin, kotlin rag ingestion pipeline, kotlin website change monitoring, maven central conversion library
---

# Kotlin File Conversion SDK

`com.enconvert:enconvert-kotlin` is the official EnConvert client for Kotlin and the JVM, built against JDK 17. It converts files across 43 implemented format pairs (DOCX to PDF, HEIC to WebP, JSON to YAML, URL to PDF, anything to Markdown), and it reads live web pages into agent-ready Markdown, JSON, screenshots, and RAG-ready JSONL through the `client.v2` namespace. Options and responses are idiomatic Kotlin data classes with named arguments and sane defaults, HTTP rides on the JDK's own `java.net.http.HttpClient`, and slow conversions recover from reverse-proxy timeouts by polling job status.

<div class="alert alert-info">
<strong>Maven Central:</strong> <code>com.enconvert:enconvert-kotlin:0.0.1</code> · <strong>Source:</strong> <a href="https://github.com/conversionapi/kotlin-sdk">conversionapi/kotlin-sdk</a> · <strong>Requires:</strong> JDK 17+ · <strong>License:</strong> MIT
</div>

---

## Install

```kotlin
// Gradle, Kotlin DSL
dependencies {
    implementation("com.enconvert:enconvert-kotlin:0.0.1")
}
```

```groovy
// Gradle, Groovy DSL
dependencies {
    implementation 'com.enconvert:enconvert-kotlin:0.0.1'
}
```

```xml
<dependency>
  <groupId>com.enconvert</groupId>
  <artifactId>enconvert-kotlin</artifactId>
  <version>0.0.1</version>
</dependency>
```

The only runtime dependency is `org.jetbrains.kotlinx:kotlinx-serialization-json`. Everything else comes from the JDK: requests go out through `java.net.http.HttpClient` and multipart bodies are assembled by the SDK itself. The toolchain targets JVM 17, so any JDK 17 or newer runtime works.

---

## Quick start

```kotlin
import com.enconvert.Enconvert
import com.enconvert.PerceiveOptions
import com.enconvert.PerceiveOutputName
import com.enconvert.UrlToPdfOptions

fun main() {
    val client = Enconvert(apiKey = System.getenv("ENCONVERT_API_KEY"))

    // Convert a live page to PDF and stream it straight to disk.
    val pdf = client.convertUrlToPdf("https://example.com", UrlToPdfOptions(saveTo = "page.pdf"))
    println(pdf.presignedUrl)

    // Read the same page the way your agent should, with a quality score attached.
    val op = client.v2.perceive(
        "https://example.com",
        PerceiveOptions(outputs = listOf(PerceiveOutputName.MARKDOWN, PerceiveOutputName.STRUCTURED)),
    )
    println("${op.outputs["markdown"]?.url} ${op.renderQuality}") // e.g. 0.93
}
```

Every EnConvert type lives in the `com.enconvert` package, and the snippets below leave imports out; JDK types such as `java.nio.file.Files` and `java.nio.file.Path` appear unqualified for the same reason. Every method blocks, since there are no `suspend` functions, so from a coroutine wrap the call in `withContext(Dispatchers.IO)`. The client authenticates with a private API key sent as the `X-API-Key` header, which makes it server-side only: never ship the key inside an Android app or anything else you distribute. See [Authentication](/docs/authentication) for key types.

---

## What the client exposes

`Enconvert` is the whole surface. File conversion lives on the client itself; web intelligence lives on the `v2` namespace reached as `client.v2`.

| Surface | Reached as | Covers |
|---------|-----------|--------|
| File conversion | `client.<method>()` | URL to PDF, screenshot, Markdown; image and document pairs; anything-to-PDF and anything-to-Markdown; whole-site batches; job and batch status |
| Web intelligence | `client.v2.<method>()` | Perceive, discover, lookup, distill, ingest, watch: 23 methods over 21 REST endpoints |

Twelve conversion methods map onto the REST API described in the [endpoints overview](/docs/endpoints-overview):

| Method | Endpoint | Returns |
|--------|----------|---------|
| `convertUrlToPdf(url, opts?)` | `POST /v1/convert/url-to-pdf` | `ConversionResult` |
| `convertUrlToScreenshot(url, opts?)` | `POST /v1/convert/url-to-screenshot` | `ConversionResult` |
| `convertUrlToMarkdown(url, opts?)` | `POST /v1/convert/url-to-markdown` | `ConversionResult` |
| `convertImage(file, opts)` | `POST /v1/convert/{from}-to-{to}` | `ConversionResult` |
| `convertDocument(file, opts?)` | `POST /v1/convert/{from}-to-{to}` | `ConversionResult` |
| `convertToMarkdown(file, opts?)` | `POST /v1/convert/anything-to-markdown` | `ConversionResult` |
| `convertToPdf(file, opts?)` | `POST /v1/convert/anything-to-pdf` | `ConversionResult` |
| `convertWebsiteToPdf(url, opts?)` | `POST /v1/convert/website-to-pdf` | `BatchSubmission` |
| `convertWebsiteToScreenshot(url, opts?)` | `POST /v1/convert/website-to-screenshot` | `BatchSubmission` |
| `getJobStatus(jobId)` | `GET /v1/convert/status/{jobId}` | `JobStatus` |
| `getBatchStatus(batchId)` | `GET /v1/convert/batch/{batchId}` | `BatchStatus` |
| `waitForBatch(batchId, opts?)` | `GET /v1/convert/batch/{batchId}` (polled) | `BatchStatus` |

The four file-upload methods each have four overloads. The first argument may be a path `String`, a `java.nio.file.Path`, a bare `ByteArray` (filename defaults to `upload.bin`), or a `FileInput(data, filename, contentType?)` when you have raw bytes and want to name them yourself.

---

## File conversion

### convertUrlToPdf

Render any public URL to PDF.

```kotlin
val result = client.convertUrlToPdf(
    "https://example.com/report",
    UrlToPdfOptions(
        render = UrlRenderOptions(viewportWidth = 1440),
        singlePage = false,
        pdfOptions = PdfOptions(pageSize = "A4", orientation = PdfOrientation.LANDSCAPE, margins = PdfMargins(top = 10.0)),
        saveTo = "report.pdf",
    ),
)
println("${result.filename} ${result.fileSize}")
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `render` | `UrlRenderOptions` | `UrlRenderOptions()` | Viewport, media, scroll, filename, browser access. |
| `saveTo` | `String?` | -- | Local path to stream the PDF to. Parent directories are created for you. |
| `singlePage` | `Boolean` | `true` | `true` produces one continuous page. `false` paginates using `pdfOptions.pageSize`. |
| `pdfOptions` | `PdfOptions?` | -- | Page geometry. See [PDF options](#pdf-options). |

`UrlRenderOptions` is shared by every URL-based conversion. It carries `viewportWidth` and `viewportHeight` (default 1920 x 1080), `loadMedia` and `enableScroll` (both default `true`, waiting for media and scrolling top to bottom so lazy loaders fire), `outputFilename`, and three browser-access fields: `auth` (an `HttpBasicAuth`), `cookies` (a `List<BrowserCookie>`, max 50), and `headers` (max 20, hop-by-hop headers rejected).

<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 one you meant.
</div>

### convertUrlToScreenshot

Capture a PNG of any URL. `UrlToScreenshotOptions` carries `render` and `saveTo` only.

```kotlin
client.convertUrlToScreenshot(
    "https://example.com",
    UrlToScreenshotOptions(render = UrlRenderOptions(viewportWidth = 1440), saveTo = "shot.png"),
)
```

### convertUrlToMarkdown

Extract clean GitHub-Flavored Markdown from a URL. The converter strips navigation, footers, ads, and scripts, keeps the main article body, and prepends YAML frontmatter with the title, description, url, links, and images.

```kotlin
val article = client.convertUrlToMarkdown("https://example.com/post", UrlToMarkdownOptions(saveTo = "article.md"))
println(article.presignedUrl)
```

When you also want a quality score, an artifact bundle, or structured extraction from the same render, use [`client.v2.perceive`](#perceive) instead.

### convertImage

Convert between `jpeg`, `png`, `svg`, `heic`, and `webp` in any direction, or rasterize a PDF to JPEG. `ConvertImageOptions` takes a required `outputFormat` plus optional `saveTo` and `outputFilename`.

```kotlin
client.convertImage("photo.heic", ConvertImageOptions(outputFormat = "webp", saveTo = "photo.webp"))
client.convertImage(Path.of("logo.svg"), ConvertImageOptions(outputFormat = "png", saveTo = "logo.png"))

val bytes = Files.readAllBytes(Path.of("photo.heic"))
client.convertImage(
    FileInput(data = bytes, filename = "photo.heic"),
    ConvertImageOptions(outputFormat = "webp", saveTo = "photo.webp"),
)
```

The input format is resolved from the filename extension. The output format is normalized for you, so `"jpg"` resolves to `jpeg`. Unsupported pairs throw `EnconvertException` before any network call, with the valid outputs listed in the message. You can ask the format table directly:

```kotlin
validOutputsFor("pdf")  // [jpeg]
validOutputsFor("json") // [csv, toml, xml, yaml]
validOutputsFor("heic") // [jpeg, png, svg, webp]
```

### convertDocument

Convert documents and data formats. `outputFormat` defaults to `"pdf"`.

```kotlin
client.convertDocument("report.docx", ConvertDocumentOptions(saveTo = "report.pdf"))
client.convertDocument("data.json", ConvertDocumentOptions(outputFormat = "yaml", saveTo = "data.yaml"))
client.convertDocument(
    "README.md",
    ConvertDocumentOptions(
        outputFormat = "pdf",
        pdfOptions = PdfOptions(pageSize = "A4", margins = PdfMargins(top = 20.0, bottom = 20.0)),
        saveTo = "readme.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`.

The 43 implemented pairs, exactly as the SDK gates them:

| Input | Outputs |
|-------|---------|
| `json` | `csv`, `toml`, `xml`, `yaml` |
| `xml` | `csv`, `json` |
| `csv` | `json`, `xml` |
| `yaml` | `json` |
| `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 `convertToPdf` or `convertToMarkdown`. Options are `outputFormat`, `saveTo`, `outputFilename`, and `pdfOptions` (honored only when the output is PDF).

### convertToMarkdown

Convert an uploaded file of almost any document format to clean Markdown. The format is auto-detected server-side, so the SDK runs no extension check and uploads the file as-is.

```kotlin
client.convertToMarkdown("handbook.docx", ConvertToMarkdownOptions(saveTo = "handbook.md"))
```

**Accepted:** PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy Office and ODF formats. Images are not supported here.

The output is one heading-aware `.md` file, which makes it a natural first stage for a RAG pipeline: a semantic chunker can split on the document's own heading hierarchy instead of arbitrary character counts. There are no PDF options on this endpoint; `saveTo` and `outputFilename` are the only options.

### convertToPdf

Convert an uploaded file of almost any format to PDF. Accepted input covers Office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, and an existing PDF as a passthrough.

```kotlin
client.convertToPdf("slides.pptx", ConvertToPdfOptions(saveTo = "slides.pdf"))
client.convertToPdf("scan.pdf", ConvertToPdfOptions(pdfOptions = PdfOptions(grayscale = true), saveTo = "gray.pdf"))
```

<div class="alert alert-warning">
<strong>Only <code>pdfOptions.grayscale</code> is honored on this endpoint.</strong> Page size, orientation, margins, scale, header, and footer are ignored here. When you need full page geometry, go through <code>convertDocument</code> or <code>convertUrlToPdf</code> instead.
</div>

### convertWebsiteToPdf and convertWebsiteToScreenshot

Discover every page of a website, convert each one in the background, and collect a single ZIP. Both are asynchronous: they return a `BatchSubmission` immediately, and you poll with `getBatchStatus` or block with `waitForBatch`.

```kotlin
val batch = client.convertWebsiteToPdf(
    "https://example.com",
    WebsiteToPdfOptions(
        website = WebsiteConversionOptions(crawlMode = CrawlMode.SITEMAP, excludePatterns = listOf("/tag/")),
    ),
)
val status = client.waitForBatch(batch.batchId, WaitForBatchOptions(saveTo = "site.zip"))
println("${status.completed} of ${status.total} converted, ${status.failed} failed")
```

`convertWebsiteToScreenshot` works identically and produces a ZIP of PNGs. `WebsiteConversionOptions` carries `render`, `crawlMode` (`AUTO`, `SITEMAP`, `FULL`), `includePatterns`, `excludePatterns`, `notificationEmail`, and `callbackUrl`. `waitForBatch` polls every 5 seconds and gives up after 30 minutes, both overridable through `WaitForBatchOptions(intervalMs, timeoutMs, saveTo)`; on timeout it throws `ApiException` with status `504`.

### getJobStatus

Poll a single async or recovered conversion job.

```kotlin
val status = client.getJobStatus("job_abc123")
when (status.status) {
    JobStatusValue.SUCCESS -> println(status.presignedUrl)
    JobStatusValue.FAILED -> System.err.println(status.error)
    JobStatusValue.PROCESSING -> println("still running")
}
```

<div class="alert alert-info">
<strong>You rarely need to call this yourself.</strong> The SDK already polls it when a synchronous request returns 5xx. See <a href="#timeout-recovery">Timeout recovery</a>.
</div>

---

## Web intelligence (V2)

Every V2 read carries `renderQuality`, a score from 0.0 to 1.0 describing how cleanly the page actually rendered. A challenge page, cookie wall, login gate, or empty SPA shell comes back with a low score, a populated `deductions` map naming which checks fired, and `warnings`, while the content itself is still returned. A bad read is flagged rather than quietly entering your agent's context. `statusCode` reports the HTTP status of the final main-document response, and `contentHash` lets you tell that nothing changed since the last read. Start at the [V2 overview](/docs/v2-overview) for the concepts behind the six capabilities.

| Capability | Methods on `client.v2` |
|-----------|------------------------|
| Perceive | `perceive`, `getPerceiveOperation`, `perceiveBatch`, `getPerceiveBatch`, `perceiveDirect`, `downloadPerceiveArtifact` |
| Discover | `discover` |
| Lookup | `lookup` |
| Distill | `distill` |
| Ingest | `ingest`, `ingestFiles`, `listIngestJobs`, `getIngestJob`, `cancelIngestJob`, `retryIngestWebhook`, `getWebhookSecret`, `rotateWebhookSecret` |
| Watch | `createWatcher`, `listWatchers`, `getWatcher`, `getWatcherSnapshots`, `updateWatcher`, `deleteWatcher` |

### Perceive

Render one URL into the artifacts you ask for. Synchronous: the call returns a completed operation whose artifact URLs are signed for 15 minutes. Full reference at [Perceive](/docs/v2-perceive).

```kotlin
val op = client.v2.perceive(
    "https://example.com/pricing",
    PerceiveOptions(
        outputs = listOf(PerceiveOutputName.MARKDOWN, PerceiveOutputName.SCREENSHOT_FULL_PAGE, PerceiveOutputName.STRUCTURED),
        extract = listOf(PerceiveExtractName.TABLES, PerceiveExtractName.METADATA),
        viewport = PerceiveViewport(width = 1440),
        waitFor = "css:.pricing-table",
    ),
)

if ((op.renderQuality ?: 0.0) < 0.5) System.err.println("Low quality read: ${op.deductions} ${op.warnings}")
println(op.outputs["markdown"]?.url)
println(op.structured)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputs` | `List<PerceiveOutputName>?` | `[MARKDOWN, STRUCTURED]` | `MARKDOWN`, `HTML_CLEANED`, `HTML_RAW`, `SCREENSHOT`, `SCREENSHOT_FULL_PAGE`, `PDF`, `LINKS`, `IMAGES`, `STRUCTURED`. |
| `extract` | `List<PerceiveExtractName>?` | -- | `TABLES`, `PRICES`, `CONTACTS`, `METADATA`, `MAIN_CONTENT`, `HEADINGS`, `STRUCTURED_DATA`, `TECHNOLOGIES`, `ALL`. |
| `schema` | `Map<String, Any?>?` | -- | JSON schema for structured extraction. |
| `waitFor` / `waitTimeoutMs` | `String?` / `Int?` | -- / `30000` | A CSS selector (optionally `css:` prefixed) or `js:<expr>` to await, and its budget, 0 to 60000. |
| `jsCode` | `String?` | -- | JavaScript run after navigation, max 20000 characters. |
| `viewport` | `PerceiveViewport?` | 1920 x 1080 | `width` 320-3840, `height` 240-2160. |
| `headers` / `cookies` / `auth` | -- | -- | Extra headers, injected cookies, HTTP Basic credentials. |
| `cacheMode` | `PerceiveCacheMode?` | `ENABLED` | `ENABLED` (1 hour cache), `BYPASS`, `REFRESH`. |
| `pdfOptions` | `PdfOptions?` | -- | Only meaningful when `outputs` includes `PDF`. |
| `blockResources` | `List<PerceiveResourceType>?` | -- | Resource types the browser should not load. |
| `respectRobots` / `mobile` | `Boolean?` | -- | Honor `robots.txt`; render with a mobile profile. |
| `onlyMainContent` | `Boolean?` | `true` | Strip nav, header, footer, and cookie banners from the Markdown artifact and the `main_content` extract. |
| `directDownload` | `Boolean?` | -- | Return artifact bytes instead of a JSON envelope. Prefer `perceiveDirect`. |

`proxyUrl`, `geolocation`, and `actionChain` exist on `PerceiveOptions` but are not yet available server-side and are currently rejected with `422`.

Batch up to 1000 URLs behind one shared options block. Small batches complete inline; larger ones come back `QUEUED`, so poll the job id. `getPerceiveOperation` re-signs the artifact URLs of any earlier operation.

```kotlin
val batch = client.v2.perceiveBatch(
    listOf("https://a.example.com", "https://b.example.com"),
    PerceiveBatchOptions(
        options = PerceiveOptions(outputs = listOf(PerceiveOutputName.MARKDOWN)),
        outputMode = PerceiveBatchOutputMode.ZIP,
    ),
)

var job = client.v2.getPerceiveBatch(batch.jobId)
while (job.status == PerceiveBatchStatus.QUEUED || job.status == PerceiveBatchStatus.PROCESSING) {
    Thread.sleep(5_000)
    job = client.v2.getPerceiveBatch(batch.jobId)
}
println("${job.completed}/${job.total} done, zip at ${job.zip?.url}")

val again = client.v2.getPerceiveOperation(op.operationId) // freshly signed URLs
```

When you want the bytes and nothing else, `perceiveDirect` streams the artifact back on the same request and skips the signed-URL round trip. It needs exactly one artifact-producing output, meaning anything except `STRUCTURED`, and throws `EnconvertException` locally if you ask for zero or more than one.

```kotlin
val direct = client.v2.perceiveDirect("https://example.com", PerceiveOptions(outputs = listOf(PerceiveOutputName.PDF)))
Files.write(Path.of(direct.filename ?: "page.pdf"), direct.content)
println("${direct.renderQuality} ${direct.sourceStatusCode} ${direct.warningsCount}")

// Re-download a stored artifact of an earlier operation.
val markdown = client.v2.downloadPerceiveArtifact(op.operationId, PerceiveOutputName.MARKDOWN)
println(markdown.content.toString(Charsets.UTF_8))
```

`downloadPerceiveArtifact` accepts a null `output` when the operation produced exactly one artifact, and returns `410` once the stored artifact passes its retention window.

### Discover

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

```kotlin
val found = client.v2.discover(
    "https://example.com",
    DiscoverOptions(mode = DiscoverMode.HYBRID, maxUrls = 200, maxDepth = 3, excludePatterns = listOf("/tag/")),
)
println("${found.total} urls, truncated=${found.truncated}, sources=${found.sources}")
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `DiscoverMode?` | `HYBRID` | `SITEMAP`, `CRAWL`, or `HYBRID` (sitemap plus HTTP crawl). |
| `maxUrls` / `maxDepth` | `Int?` | `100` / `2` | 1-1000 and 1-5. |
| `includePatterns` / `excludePatterns` | `List<String>?` | -- | Regex allowlist and denylist, max 50 entries each. The denylist is applied second. |
| `sameDomainOnly` | `Boolean?` | `true` | Stay on the seed domain. |
| `respectRobots` | `Boolean?` | -- | Honor `robots.txt`. |

`DiscoverResult.sources` reports raw per-source counts before dedup, for example `{sitemap=42, crawl=30}`.

### Lookup

Run a categorized web search, and optionally perceive the top results in the same call. Full reference at [Lookup](/docs/v2-lookup).

```kotlin
val search = client.v2.lookup(
    "best static site generators",
    LookupOptions(category = LookupCategory.WEB, numResults = 10, country = "us", timeFilter = LookupTimeFilter.MONTH, perceiveTop = 3),
)

for (hit in search.results) {
    println("${hit.position}. ${hit.title} ${hit.url}")
    hit.perceive?.let { println("   rendered at quality ${it.renderQuality}") }
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `category` | `LookupCategory?` | `WEB` | `WEB`, `NEWS`, `IMAGES`, `SCHOLAR`, `PATENTS`, `MAPS`. |
| `country` / `locale` | `String?` | -- | Google `gl` country code and `hl` interface language. |
| `timeFilter` | `LookupTimeFilter?` | -- | `HOUR`, `DAY`, `WEEK`, `MONTH`, `YEAR`. |
| `numResults` / `page` | `Int?` | `10` / `1` | 1-100 and 1-10. |
| `location` | `String?` | -- | Free-text location, for example `"Austin, Texas"`. |
| `autocorrect` | `Boolean?` | `true` | Let the provider fix typos. |
| `perceiveTop` | `Int?` | `0` | Auto-perceive the top N result URLs, 0-10. Each one runs a full browser render. |

The result also carries `answerBox`, `knowledgeGraph`, `perceiveOperationIds`, and `total`.

### Distill

Point a schema at some pages and get structured data back. An optional CSS pass answers whatever it can before anything escalates to the LLM tier. Full reference at [Distill](/docs/v2-distill).

```kotlin
val extraction = client.v2.distill(
    DistillOptions(
        urls = listOf("https://example.com/pricing"),
        schema = mapOf("plans" to "list of plan names with monthly prices"),
        cssSchema = CssSchema(
            baseSelector = ".plan-card",
            fields = listOf(
                CssField(name = "name", type = CssFieldType.TEXT, selector = "h3"),
                CssField(name = "price", type = CssFieldType.TEXT, selector = ".price"),
            ),
            targetField = "plans",
        ),
    ),
)

for (item in extraction.results) {
    println("${item.url} tier=${item.extractionTier} css=${item.fieldsFromCss} llm=${item.fieldsFromLlm}")
    println(item.data)
}
```

Or discover the URLs first and distill each one:

```kotlin
client.v2.distill(
    DistillOptions(
        discoverFrom = DistillDiscoverFrom(url = "https://example.com", mode = DiscoverMode.SITEMAP, maxPages = 10),
        schema = mapOf("title" to "page title", "summary" to "one-line summary"),
    ),
)
```

Provide exactly one of `urls` or `discoverFrom`. Passing both, or neither, throws `EnconvertException` before the request leaves your process.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `urls` | `List<String>?` | -- | Explicit URLs to distill, max 50. |
| `discoverFrom` | `DistillDiscoverFrom?` | -- | Discover a site's URLs first. `maxPages` is 1-50, default 10. |
| `schema` | `Map<String, Any?>` | required | A JSON Schema object, or a flat `{field to description}` map. |
| `cssSchema` | `CssSchema?` | -- | CSS pass run before any LLM escalation. |
| `waitFor` / `waitTimeoutMs` | `String?` / `Int?` | -- / `30000` | Selector or `js:` expression to await, and its budget. |
| `headers` / `cookies` / `respectRobots` | -- | -- | Same render controls as perceive. |

`CssField.type` is one of `TEXT`, `ATTRIBUTE`, `HTML`, `REGEX`, `NESTED`, `LIST`, `NESTED_LIST`. `ATTRIBUTE` requires `attribute`, `REGEX` requires `pattern`, and the three nested kinds require a non-empty `fields` list, up to five levels deep.

### Ingest

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

```kotlin
val job = client.v2.ingest(
    IngestOptions(
        mode = IngestMode.SITEMAP,
        url = "https://docs.example.com",
        maxPages = 100,
        chunk = IngestChunkOptions(maxWords = 512, sentenceOverlap = 1),
        webhookUrl = "https://my.app/hooks/enconvert",
    ),
)

var state = client.v2.getIngestJob(job.jobId)
while (state.status !in setOf(IngestStatus.COMPLETED, IngestStatus.FAILED, IngestStatus.CANCELED)) {
    Thread.sleep(10_000)
    state = client.v2.getIngestJob(job.jobId)
}
if (state.status == IngestStatus.COMPLETED) println("${state.totalChunks} chunks at ${state.outputUrl}")
```

`mode` is `URLS` by default, which requires a non-empty `urls` list and rejects `url`. `SITEMAP` and `CRAWL` require a seed `url` and reject `urls`. The SDK enforces both rules locally and throws `EnconvertException` rather than sending a request that cannot succeed.

Uploaded files go through `ingestFiles`, which shares the same job lifecycle under mode `FILES` and accepts PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, and legacy Office and ODF documents.

```kotlin
val paths = listOf(Path.of("handbook.pdf"), Path.of("notes.docx"))
val fileJob = client.v2.ingestFiles(
    paths.map { FileInput(data = Files.readAllBytes(it), filename = it.fileName.toString()) },
    IngestFilesOptions(chunk = IngestChunkOptions(maxWords = 512, sentenceOverlap = 1)),
)
println(fileJob.jobId)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `IngestMode?` | `URLS` | `URLS`, `SITEMAP`, `CRAWL`, `FILES`. |
| `url` / `urls` | `String?` / `List<String>?` | -- | Seed URL for `SITEMAP` and `CRAWL`; explicit URLs (max 1000) for `URLS`. |
| `maxPages` / `maxDepth` | `Int?` | `50` / `2` | Discovery caps, 1-1000 and 1-5. |
| `sameDomainOnly` | `Boolean?` | `true` | Stay on the seed domain. |
| `includePatterns` / `excludePatterns` / `respectRobots` | -- | -- | Same discovery controls as `discover`. |
| `waitFor` / `waitTimeoutMs` | `String?` / `Int?` | -- / `30000` | Per-page render wait. |
| `chunk` | `IngestChunkOptions?` | -- | `maxWords` 32-4000 (default 512), `sentenceOverlap` 0-10 (default 1). |
| `webhookUrl` | `String?` | -- | Completion webhook, HMAC-signed. |

Job management and webhook plumbing:

```kotlin
client.v2.listIngestJobs(V2ListOptions(limit = 20))   // newest first
client.v2.cancelIngestJob(job.jobId)                  // idempotent

val secret = client.v2.getWebhookSecret()
println("${secret.signatureHeader} ${secret.signatureScheme} ${secret.replayToleranceSeconds}s")

client.v2.rotateWebhookSecret()         // old signatures stop verifying immediately
client.v2.retryIngestWebhook(job.jobId) // re-deliver a completed job's webhook
```

### Watch

Re-render a URL on a fixed cadence and get notified when it changes. Full reference at [Watch](/docs/v2-watch).

```kotlin
val watcher = client.v2.createWatcher(
    "https://example.com/pricing",
    WatchCreateOptions(
        frequencyMinutes = 60,
        diffMode = WatchDiffMode.AUTO,
        webhookUrl = "https://my.app/hooks/changes",
        notifyEmail = true,
    ),
)

client.v2.listWatchers(V2ListOptions(limit = 20))
for (snap in client.v2.getWatcherSnapshots(watcher.watcherId, SnapshotListOptions(limit = 10)).snapshots) {
    println("${snap.checkedAt} changed=${snap.hasChanges} similarity=${snap.similarity}")
}

client.v2.updateWatcher(watcher.watcherId, WatcherUpdate(status = WatcherUpdateStatus.PAUSED))
client.v2.updateWatcher(watcher.watcherId, WatcherUpdate(webhookUrl = "")) // clears the webhook
client.v2.deleteWatcher(watcher.watcherId)                                 // soft delete, idempotent
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `frequencyMinutes` | `Int?` | `60` | Minutes between checks, 60-43200. The hourly floor is hard. |
| `diffMode` | `WatchDiffMode?` | `AUTO` | `AUTO`, `TEXT`, `STRUCTURED`, `TABLES`, `METADATA`. |
| `trackFields` | `Map<String, Any?>?` | -- | Field or selector subset the diff engine should watch. |
| `webhookUrl` | `String?` | -- | Change webhook, HMAC-signed with the same secret as ingest. |
| `notifyEmail` | `Boolean?` | `true` | Email the project owner on changes. |

`updateWatcher` requires at least one field and throws `EnconvertException` on an empty `WatcherUpdate`. Its `status` accepts only `ACTIVE` or `PAUSED`; deletion goes through `deleteWatcher`, which returns the tombstoned watcher with status `DELETED`. `getWatcher` on a deleted watcher reads as `404`.

<div class="alert alert-warning">
<strong>Snapshot diffs contain untrusted page content.</strong> <code>WatcherSnapshot.changes</code> is raw text lifted from the watched page. Escape it before rendering it into HTML, a dashboard, or a chat message.
</div>

---

## PDF options

`PdfOptions` is shared by `convertUrlToPdf`, `convertDocument`, `convertToPdf` (grayscale only), `convertWebsiteToPdf`, and `PerceiveOptions.pdfOptions`.

```kotlin
client.convertUrlToPdf(
    "https://example.com",
    UrlToPdfOptions(
        pdfOptions = PdfOptions(
            pageSize = "A4",
            orientation = PdfOrientation.LANDSCAPE,
            margins = PdfMargins(top = 10.0, bottom = 10.0, left = 15.0, right = 15.0),
            scale = 0.9,
            header = PdfHeaderFooter(content = "Quarterly report", height = 12.0),
        ),
        saveTo = "report.pdf",
    ),
)
```

| Field | Type | Description |
|-------|------|-------------|
| `pageSize` | `String?` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and so on. |
| `pageWidth` / `pageHeight` | `Double?` | Custom geometry. Together they override `pageSize`. |
| `orientation` | `PdfOrientation?` | `PORTRAIT` or `LANDSCAPE`. Defaults to portrait. |
| `margins` | `PdfMargins?` | `top`, `bottom`, `left`, `right`, all optional doubles in mm. |
| `scale` | `Double?` | Render scale, for example `0.9` for 90 percent. |
| `grayscale` | `Boolean?` | Post-process the PDF to grayscale. |
| `header` / `footer` | `PdfHeaderFooter?` | `content` (max 2000 characters) and `height`. |

Only fields you actually set are serialized onto the wire, so a partially filled `PdfOptions` never overrides a server default you did not touch. The full parameter matrix lives in [Parameters and options](/docs/parameters-options).

---

## Error handling

Every failure is an `EnconvertException` or a subclass, so a single `catch` can be your backstop while specific subclasses handle the cases you care about.

```kotlin
try {
    client.v2.perceive("https://example.com")
} catch (e: AuthenticationException) {
    System.err.println("Invalid or missing API key")
} catch (e: QuotaException) {
    System.err.println("Request rejected with 402")
} catch (e: RateLimitException) {
    System.err.println("Too many requests, back off and retry")
} catch (e: ApiException) {
    System.err.println("API error [${e.statusCode}]: ${e.message}")
} catch (e: EnconvertException) {
    System.err.println("Client-side validation failed: ${e.message}")
}
```

| Class | Raised on | Status code |
|-------|-----------|-------------|
| `AuthenticationException` | Invalid, missing, or revoked API key | `401`, `403` |
| `QuotaException` | Raised on HTTP 402 | `402` |
| `RateLimitException` | Too many requests | `429` |
| `ApiException` | Any other 4xx or 5xx response | the actual code |
| `EnconvertException` | Base class, plus client-side validation such as an unsupported conversion pair or a malformed options object | -- |

`ApiException` exposes the raw `statusCode` property, and its `message` renders as `[<statusCode>] <server message>` with the server's `detail` or `error` field pulled out of the JSON body. Catch order matters: the three narrow classes all extend `ApiException`, which extends `EnconvertException`, so list them first. The message map is documented in [Error codes](/docs/error-codes).

---

## Timeout recovery

Long URL-to-PDF renders and large document conversions can outlive a 60 to 120 second reverse-proxy timeout even when the conversion succeeds on the server. The SDK handles that on the V1 conversion methods:

1. Before each request it generates a 32-character hex job id and sends it as `job_id` in the JSON body or as a multipart field.
2. If the request comes back 5xx, the SDK stops trusting the response and polls `GET /v1/convert/status/{job_id}` every 3 seconds. A `404` while the job row is still being written means "keep waiting".
3. On `success` the SDK maps the payload to a normal `ConversionResult`. On `failed` it throws `ApiException` carrying the server's error message.
4. The deadline is 5 minutes, after which it throws `ApiException(504, "Conversion timed out")`.

Successful responses that omit `job_id` (the synchronous URL path does this) get the client-generated id backfilled, so `result.jobId` is always something you can hand to `getJobStatus`. Two deliberate exceptions: `convertWebsiteToPdf` and `convertWebsiteToScreenshot` skip the fallback, because a website submission has no per-job row and a 5xx there means the submission itself failed. V2 methods skip it too, since each V2 endpoint has its own polling or webhook story.

---

## Configuration

```kotlin
val client = Enconvert(
    apiKey = System.getenv("ENCONVERT_API_KEY"),
    timeout = 300_000,                      // ms, 5 minutes
    baseUrl = "https://api.enconvert.com",  // override for a self-hosted gateway
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `apiKey` | `String` | required | Private API key. A blank value throws `IllegalArgumentException` from the constructor. |
| `timeout` | `Long` | `300_000` | Per-request timeout in milliseconds, applied to the underlying `HttpRequest`. |
| `baseUrl` | `String` | `https://api.enconvert.com` | API base URL. Trailing slashes are stripped. |

<div class="alert alert-warning">
<strong>Never hardcode the API key.</strong> Read it from an environment variable, a Gradle property, or your secret manager, and keep it out of any artifact you ship to a user's device. Anyone holding your private key can run requests against your project.
</div>

---

## Result shape

Every conversion method returns a `ConversionResult`:

```kotlin
public data class ConversionResult(
    val presignedUrl: String,
    val objectKey: String,
    val filename: String,
    val fileSize: Long? = null,
    val conversionTimeSeconds: Double? = null,
    val jobId: String? = null,
)
```

The presigned URL is a temporary signed link. Pass `saveTo` if you want the bytes on disk right away, or download the URL yourself and store the file in your own bucket for permanent access.

V2 reads return a `PerceiveResult` instead, which is where the honesty signals live:

```kotlin
val op = client.v2.perceive("https://example.com")

op.operationId    // "per_..."
op.status         // PerceiveStatus.COMPLETED
op.url            // requested URL; op.urlFinal after redirects
op.renderQuality  // Double?, 0.0 to 1.0
op.statusCode     // Int?, HTTP status of the main document
op.deductions     // Map<String, Double>, e.g. {http_error=0.7}. Empty on a clean render.
op.warnings       // List<String>
op.cacheHit       // Boolean; op.contentHash is the SHA-256 of the rendered content
op.outputs        // Map<String, V2OutputArtifact> keyed by output name
op.structured     // Map<String, Any?>?, present when extract or schema was used
op.extractionTier // HEURISTIC, CSS, or LLM
op.tokens         // V2Tokens(input, output); op.costCents and op.durationMs alongside
```

Each `V2OutputArtifact` carries `url`, `objectKey`, `sizeBytes`, `contentType`, and `expiresIn` (900 seconds). Artifact URLs are re-signed on every `getPerceiveOperation` call, so store the `operationId`, not the URL. Untyped payloads (extraction schemas, extracted data, tracked fields, diff entries, search extras) cross the boundary as `Map<String, Any?>` and convert losslessly in both directions, so nothing you put into a schema is reshaped on its way out.

---

## Source and issues

- **Maven Central:** `com.enconvert:enconvert-kotlin:0.0.1`
- **GitHub:** [conversionapi/kotlin-sdk](https://github.com/conversionapi/kotlin-sdk)
- **License:** MIT
- **Other languages:** see the full [SDK list](/docs/sdks)

---

## Frequently asked questions

### How do I convert files in Kotlin?

Add `com.enconvert:enconvert-kotlin:0.0.1` to your Gradle or Maven build, construct `Enconvert(apiKey = System.getenv("ENCONVERT_API_KEY"))`, and call a typed method such as `convertDocument`, `convertImage`, or `convertUrlToPdf`. Pass `saveTo` in the options object to stream the output straight to a local file instead of downloading the presigned URL yourself.

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

Call `client.convertDocument("report.docx", ConvertDocumentOptions(saveTo = "report.pdf"))`. The output format defaults to `pdf`, so you can leave `outputFormat` unset. The input format is resolved from the file extension, and `.doc` and `.docx` both map to the same conversion. For page geometry, pass a `PdfOptions` through `ConvertDocumentOptions.pdfOptions`.

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

Call `client.convertUrlToPdf(url, UrlToPdfOptions(saveTo = "page.pdf"))`. Set `singlePage = false` to paginate with `pdfOptions.pageSize`, and use `UrlRenderOptions` to change the viewport, disable media loading, or turn off the scroll pass that triggers lazy loaders.

### How do I convert HEIC to WebP on the JVM?

Call `client.convertImage("photo.heic", ConvertImageOptions(outputFormat = "webp", saveTo = "photo.webp"))`. All 20 pairs among `jpeg`, `png`, `svg`, `heic`, and `webp` are implemented, plus `pdf` to `jpeg` rasterization. An unsupported pair throws `EnconvertException` before any network call, and `validOutputsFor("heic")` lists the valid targets up front.

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

Two options. `client.convertUrlToMarkdown(url, UrlToMarkdownOptions(saveTo = "page.md"))` gives you a Markdown file with YAML frontmatter. `client.v2.perceive(url, PerceiveOptions(outputs = listOf(PerceiveOutputName.MARKDOWN)))` gives you the same content plus `renderQuality`, `deductions`, `warnings`, and `statusCode`, which is what you want when an agent will read the result unattended.

### What does renderQuality mean and when should I reject a page?

`renderQuality` runs from 0.0 to 1.0 and describes how cleanly the page rendered, not how good the content is. Challenge pages, login walls, HTTP errors, and empty SPA shells push it down, and `deductions` names each check that fired, for example `{http_error=0.7}`. The content is always returned so you can inspect it. A common pattern is to treat anything below 0.5 as suspect and either re-request with `cacheMode = PerceiveCacheMode.REFRESH` or route it to a human.

### How do I turn a documentation site into RAG chunks from Kotlin?

Call `client.v2.ingest(IngestOptions(mode = IngestMode.SITEMAP, url = "https://docs.example.com", chunk = IngestChunkOptions(maxWords = 512, sentenceOverlap = 1)))`. Ingest is always asynchronous: poll `getIngestJob(jobId)` until the status is `COMPLETED` and read `outputUrl` for the JSONL, or set `webhookUrl` and let the completion webhook find you. Local documents go through `ingestFiles` with the same chunk settings.

### Does the SDK block the calling thread?

Yes. Every method calls `HttpClient.send` synchronously, and there are no `suspend` functions or coroutine builders in the SDK. `waitForBatch` and the internal timeout-recovery poller sleep the current thread between attempts. From a coroutine, wrap calls in `withContext(Dispatchers.IO)`; from a server framework, keep them off the request-handling thread pool.

### Can I call this SDK from Java?

You can, since these are ordinary JVM classes, but Kotlin default arguments are not exposed to Java as overloads, so a Java caller has to pass every constructor argument of an options data class. If your codebase is Java, use the separate Java SDK listed on the [SDK page](/docs/sdks) instead.

### Where do I get an API key?

Create a private key in your [dashboard](/dashboard). It is sent as the `X-API-Key` header on every request, so keep it server-side. Key types and scopes are covered in [Authentication](/docs/authentication), and [pricing](/pricing) covers the commercial side.
