---
seo_title: Swift File Conversion SDK: Async Await API Client | EnConvert
meta_desc: Official EnConvert Swift SDK for macOS, iOS, tvOS, and watchOS. Async await methods for file conversion and for perceiving, discovering, and distilling web pages.
keywords: swift file conversion sdk, convert files swift, url to pdf swift, swift web scraping api, docx to pdf swift, enconvert swift sdk, heic to webp swift, swift async await api client, ios file conversion api, swift package manager pdf library, website screenshot swift, structured data extraction swift
---

# Swift File Conversion SDK

`Enconvert` is the official EnConvert client for Swift, distributed through Swift Package Manager. It is built on `URLSession` with `async`/`await`, carries zero external dependencies, and targets Swift 5.9 and newer on macOS 12, iOS 15, tvOS 15, and watchOS 8. Twelve methods on the client cover file conversion and URL rendering (DOCX to PDF, HEIC to WebP, URL to PDF, URL to Markdown, whole-site batches), and the `client.v2` namespace adds twenty-three web intelligence methods for perceiving, discovering, looking up, distilling, ingesting, and watching pages.

<div class="alert alert-info">
<strong>Package:</strong> <code>Enconvert</code> · <strong>Source:</strong> <a href="https://github.com/conversionapi/swift-sdk">conversionapi/swift-sdk</a> · <strong>Swift:</strong> 5.9+ · <strong>Platforms:</strong> macOS 12+, iOS 15+, tvOS 15+, watchOS 8+ · <strong>Dependencies:</strong> none
</div>

---

## Install

Add the package, then list the product in the target that uses it:

```swift
dependencies: [
    .package(url: "https://github.com/conversionapi/swift-sdk.git", from: "0.0.1")
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "Enconvert", package: "swift-sdk")
    ])
]
```

In Xcode, use **File > Add Package Dependencies** and paste `https://github.com/conversionapi/swift-sdk.git`. On Linux the SDK imports `FoundationNetworking` conditionally, so nothing extra is needed on your side.

---

## Quick start

```swift
import Enconvert

let apiKey = ProcessInfo.processInfo.environment["ENCONVERT_API_KEY"] ?? ""
let client = try Enconvert(apiKey: apiKey)

let result = try await client.convertUrlToPdf("https://example.com", options: UrlToPdfOptions(saveTo: "page.pdf"))
print(result.filename, result.presignedUrl)
```

`Enconvert.init` is throwing, not failable: an empty `apiKey` raises `EnconvertError.invalidArgument` before anything touches the network. Every request method is `async throws`, and the conversion methods are marked `@discardableResult` so a call made only for its `saveTo` side effect does not warn.

---

## What the client exposes

Twelve methods hang off `Enconvert` and map 1:1 to REST endpoints:

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

`client.v2` is an `EnconvertV2` namespace holding twenty-three more methods across six capability groups:

| Group | Methods | Base path |
|-------|---------|-----------|
| Perceive | `perceive`, `perceiveDirect`, `getPerceiveOperation`, `downloadPerceiveArtifact`, `perceiveBatch`, `getPerceiveBatch` | `/v2/perceive` |
| Discover | `discover` | `/v2/discover` |
| Lookup | `lookup` | `/v2/lookup` |
| Distill | `distill` | `/v2/distill` |
| Ingest | `ingest`, `ingestFiles`, `getIngestJob`, `listIngestJobs`, `cancelIngestJob`, `retryIngestWebhook`, `getWebhookSecret`, `rotateWebhookSecret` | `/v2/ingest` |
| Watch | `createWatcher`, `listWatchers`, `getWatcher`, `getWatcherSnapshots`, `updateWatcher`, `deleteWatcher` | `/v2/watch` |

Options are passed as a struct with defaulted initializer parameters, so `UrlToPdfOptions()` means "all defaults" and you only name the fields you care about. Swift requires labeled arguments in declaration order, so keep `saveTo:` ahead of `singlePage:` and `pdfOptions:` when you set several at once.

---

## File conversion

Uploads accept a `FileInput`:

| Case | Use it for |
|------|-----------|
| `.path("report.docx")` | A file on disk. The basename decides the input format and the MIME type. |
| `.data(bytes)` | Raw bytes with no name. Uploaded as `upload.bin`, `application/octet-stream`. |
| `.wrapped(data: bytes, filename: "report.docx", contentType: nil)` | Raw bytes plus an explicit filename. A `nil` `contentType` is inferred from the extension. |

### convertUrlToPdf

Render any public URL to a PDF.

```swift
let result = try await client.convertUrlToPdf("https://example.com", options: UrlToPdfOptions(
    viewportWidth: 1440,
    saveTo: "report.pdf",
    singlePage: false,
    pdfOptions: PdfOptions(pageSize: "A4", orientation: .landscape)
))
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `viewportWidth`, `viewportHeight` | `Int?` | `1920`, `1080` | Browser viewport size in pixels. |
| `loadMedia`, `enableScroll` | `Bool?` | `true` | Wait for images and video; scroll top to bottom to trigger lazy loaders. |
| `outputFilename` | `String?` | auto | Override the generated filename. |
| `auth`, `cookies`, `headers` | `HttpBasicAuth?`, `[BrowserCookie]?`, `[String: String]?` | none | HTTP Basic credentials, injected cookies (max 50), extra request headers (max 20, hop-by-hop rejected). |
| `saveTo` | `String?` | none | Local path to write the PDF to. Parent directories are created. |
| `singlePage` | `Bool?` | `true` | `true` produces one continuous page. `false` paginates using `pdfOptions.pageSize`. |
| `pdfOptions` | `PdfOptions?` | none | Page geometry. See [PDF options](#pdf-options). |

Pages behind a login take credentials, cookies, or headers:

```swift
_ = try await client.convertUrlToPdf("https://internal.example.com/report", options: UrlToPdfOptions(
    auth: HttpBasicAuth(username: "user", password: "pass"),
    cookies: [BrowserCookie(name: "session", value: "abc123", domain: "internal.example.com")],
    headers: ["X-Tenant": "acme"],
    saveTo: "report.pdf"
))
```

Do not combine `auth` with an `Authorization` entry in `headers`. The API rejects the conflict.

### convertUrlToScreenshot

Capture a PNG of any URL.

```swift
let shot = try await client.convertUrlToScreenshot(
    "https://example.com",
    options: UrlToScreenshotOptions(viewportWidth: 1440, saveTo: "shot.png")
)
```

`UrlToScreenshotOptions` accepts the same viewport, media, scroll, filename, and browser-access fields as `UrlToPdfOptions`, without `singlePage` and `pdfOptions`.

### 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. Useful for RAG pipelines, importing third-party content into a CMS, or generating training data.

```swift
_ = try await client.convertUrlToMarkdown("https://example.com/article", options: UrlToMarkdownOptions(saveTo: "article.md"))
```

### convertImage

Convert between `jpeg`, `png`, `svg`, `heic`, and `webp`, or rasterize a PDF to JPEG.

```swift
let result = try await client.convertImage(.path("photo.heic"), options: ConvertImageOptions(outputFormat: "webp", saveTo: "photo.webp"))
```

The input format comes from the filename extension (`.jpg`, `.jpeg`, `.png`, `.svg`, `.heic`, `.webp`, and `.pdf` for rasterization). `outputFormat` is required and accepts the aliases `jpg`, `yml`, `htm`, and `md`.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `outputFormat` | `String` | Yes | Target format, for example `"webp"`. |
| `saveTo` | `String?` | no | Local path to write the result to. |
| `outputFilename` | `String?` | no | Override the generated filename. |

### convertDocument

Convert documents and structured-text formats. `outputFormat` defaults to `"pdf"`.

```swift
// docx to pdf
_ = try await client.convertDocument(.path("report.docx"), options: ConvertDocumentOptions(saveTo: "report.pdf"))

// json to yaml
_ = try await client.convertDocument(.path("data.json"), options: ConvertDocumentOptions(outputFormat: "yaml", saveTo: "data.yaml"))

// markdown to pdf with custom page setup
_ = try await client.convertDocument(.path("README.md"), options: ConvertDocumentOptions(
    saveTo: "readme.pdf",
    pdfOptions: PdfOptions(pageSize: "A4", margins: PdfMargins(top: 20, bottom: 20))
))
```

**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, so send `.epub` files through `convertToPdf` or `convertToMarkdown` instead.

The SDK ships the gateway's full conversion table and validates every `{input}-to-{output}` pair locally, so an unsupported pair throws `EnconvertError.invalidArgument` with the list of valid outputs instead of paying for a round trip that is guaranteed to fail. There are 43 implemented pairs:

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

You can query that table yourself without making a request:

```swift
validOutputsFor("json")                           // ["csv", "toml", "xml", "yaml"]
IMPLEMENTED_CONVERSIONS.contains("heic-to-webp")  // true
```

### convertToMarkdown

Convert an uploaded document of almost any format to clean Markdown, with the format auto-detected server-side. A good first stage for a RAG pipeline.

```swift
_ = try await client.convertToMarkdown(.path("handbook.docx"), options: ConvertToMarkdownOptions(saveTo: "handbook.md"))
```

PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, and legacy or ODF office files are accepted. Images are not. There are no PDF options on this endpoint.

### convertToPdf

Convert an uploaded file of almost any format to PDF: office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, or an existing PDF passed through and normalized.

```swift
_ = try await client.convertToPdf(.path("slides.pptx"), options: ConvertToPdfOptions(saveTo: "slides.pdf"))

// PDF passthrough, converted to grayscale
_ = try await client.convertToPdf(
    .path("scan.pdf"),
    options: ConvertToPdfOptions(saveTo: "scan-gray.pdf", pdfOptions: PdfOptions(grayscale: true))
)
```

<div class="alert alert-warning">
<strong>Only <code>grayscale</code> is honored here.</strong> <code>convertToPdf</code> forwards <code>pdfOptions</code>, but the anything-to-pdf endpoint reads <code>grayscale</code> and ignores the rest. Use <code>convertDocument</code> or <code>convertUrlToPdf</code> when you need page size, orientation, margins, scale, headers, or footers.
</div>

### convertWebsiteToPdf and convertWebsiteToScreenshot

Discover every page of a site, convert each one in the background, and collect a single ZIP. Both methods are async-only and require a private API key with crawl access.

```swift
let batch = try await client.convertWebsiteToPdf("https://example.com", options: WebsiteToPdfOptions(
    crawlMode: .sitemap,
    excludePatterns: ["/blog/tag/"]
))
print(batch.batchId, batch.urlCount, batch.discoveryMethod ?? "")

// Block until the batch settles and save the ZIP
let status = try await client.waitForBatch(batch.batchId, options: WaitForBatchOptions(saveTo: "site.zip"))
print("\(status.completed) of \(status.total) pages converted")

// Or poll it yourself
let snapshot = try await client.getBatchStatus(batch.batchId)
if snapshot.status != .processing {
    print(snapshot.zipDownloadUrl ?? "")
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `crawlMode` | `CrawlMode?` | `.auto` | `.auto`, `.sitemap` (sitemap.xml only), or `.full` (sitemap plus BFS crawl). |
| `includePatterns`, `excludePatterns` | `[String]?` | none | Allowlist then denylist for discovered URLs. Full crawl mode. |
| `notificationEmail` | `String?` | project owner | Email notified when the batch finishes. |
| `callbackUrl` | `String?` | none | Webhook POSTed when the batch finishes. |
| `singlePage`, `pdfOptions` | `Bool?`, `PdfOptions?` | see above | PDF batches only. |

Both methods also take the viewport, media, scroll, and browser-access fields listed under `convertUrlToPdf`, applied to every page. `waitForBatch` polls every 5 seconds with a 30 minute deadline by default; override with `WaitForBatchOptions(intervalMs:timeoutMs:saveTo:)`. Exceeding the deadline throws `EnconvertError.api(statusCode: 504, ...)`. `convertWebsiteToScreenshot` behaves identically and produces a ZIP of PNGs.

---

## Web intelligence (V2)

Every V2 read carries a `renderQuality` score from 0.0 to 1.0, exposed as a `Double?` on `PerceiveResult`, `PerceiveDirectResult`, `DistillItem`, and `WatcherSnapshot`. A low score means the page did not render honestly: a bot challenge, a login wall, a cookie banner over an empty SPA shell, an HTTP error status. The content still comes back, flagged, next to a `deductions` dictionary naming each penalty that fired and a `warnings` array, so a bad read never quietly enters your agent's context. Gate on it before you trust anything:

```swift
if let quality = op.renderQuality, quality < 0.6 {
    print("low quality read of \(op.url): \(op.deductions)")
}
```

### Perceive

Render one URL into the artifacts you ask for. Synchronous, with signed artifact URLs valid for 15 minutes. Every V2 method needs a private API key; public keys are rejected.

```swift
let op = try await client.v2.perceive("https://example.com", options: PerceiveOptions(
    outputs: [.markdown, .screenshot, .structured],
    extract: [.tables, .metadata],
    viewport: PerceiveViewport(width: 1440)
))
print(op.operationId, op.renderQuality ?? 0, op.outputs["markdown"]?.url ?? "")
print(op.structured ?? [:], op.extractionTier ?? .heuristic)

// Re-sign the artifact URLs later
let again = try await client.v2.getPerceiveOperation(op.operationId)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputs` | `[PerceiveOutputName]?` | `[.markdown, .structured]` | `.markdown`, `.htmlCleaned`, `.htmlRaw`, `.screenshot`, `.screenshotFullPage`, `.pdf`, `.links`, `.images`, `.structured`. |
| `extract` | `[PerceiveExtractName]?` | none | `.tables`, `.prices`, `.contacts`, `.metadata`, `.mainContent`, `.headings`, `.structuredData`, `.technologies`, `.all`. |
| `schema` | `JSONObject?` | none | JSON schema for structured extraction through the LLM tier. |
| `waitFor`, `waitTimeoutMs` | `String?`, `Int?` | none, `30000` | A CSS selector (optionally prefixed `css:`) or `js:<expr>` to await, and its budget in ms (0 to 60000). |
| `jsCode` | `String?` | none | JavaScript executed after navigation, max 20000 characters. |
| `viewport` | `PerceiveViewport?` | 1920 by 1080 | `width` 320 to 3840, `height` 240 to 2160. |
| `headers`, `cookies`, `auth` | `[String: String]?`, `[BrowserCookie]?`, `HttpBasicAuth?` | none | Request headers, injected cookies, HTTP Basic credentials. |
| `cacheMode` | `PerceiveCacheMode?` | `.enabled` | `.enabled` (1 hour cache), `.bypass`, `.refresh`. |
| `pdfOptions` | `PdfOptions?` | none | Only meaningful when `outputs` includes `.pdf`. |
| `blockResources` | `[PerceiveResourceType]?` | none | `.image`, `.media`, `.font`, `.stylesheet`, `.script`, `.xhr`, `.fetch`, `.websocket`, `.manifest`, `.other`. |
| `respectRobots`, `mobile` | `Bool?` | server default | Honor `robots.txt`; emulate a mobile device. |
| `onlyMainContent` | `Bool?` | `true` | Strip nav, header, footer, and cookie banners from the markdown artifact and the `main_content` extract. Set `false` for the full page. |
| `directDownload` | `Bool?` | `false` | Stream raw bytes instead of a JSON envelope. Prefer `perceiveDirect`. |

<div class="alert alert-warning">
<strong>Three options are declared but not live yet.</strong> <code>proxyUrl</code>, <code>geolocation</code>, and <code>actionChain</code> exist on <code>PerceiveOptions</code> and are serialized onto the wire, but the server currently answers <code>422</code> for all three. Leave them <code>nil</code>.
</div>

Streaming a single artifact straight to disk skips the JSON envelope and the signed-URL round trip. `perceiveDirect` checks locally that you asked for exactly one artifact-producing output, so a mistake costs nothing:

```swift
let direct = try await client.v2.perceiveDirect("https://example.com", options: PerceiveOptions(outputs: [.pdf]))
try direct.content.write(to: URL(fileURLWithPath: direct.filename ?? "page.pdf"))

// Re-download a stored artifact later. Pass nil when the operation made only one.
let saved = try await client.v2.downloadPerceiveArtifact(direct.operationId, output: .pdf)
```

`PerceiveDirectResult` carries `content`, `contentType`, `filename`, `operationId`, `objectKey`, `cacheHit`, `renderQuality`, `sourceStatusCode`, `contentHash`, and `warningsCount`, all read from response headers. A `410` from `downloadPerceiveArtifact` means the artifact has aged out of its retention window.

Batches take up to 1000 URLs with one shared options block. Small batches finish inline; larger ones come back queued and you poll:

```swift
let batch = try await client.v2.perceiveBatch(
    ["https://a.example", "https://b.example"],
    options: PerceiveBatchOptions(outputs: [.markdown], outputMode: .zip)
)
let done = try await client.v2.getPerceiveBatch(batch.jobId)
if done.status == .completed, let zip = done.zip {
    print(zip.url ?? "")
}
```

`outputMode` is `.manifest` (default) or `.zip`. `directDownload` is rejected with `422` on batches.

### Discover

Enumerate a site's URLs with no browser rendering. Fast, and it never runs a render.

```swift
let opts = DiscoverOptions(mode: .hybrid, maxUrls: 200, excludePatterns: ["/tag/"])
let found = try await client.v2.discover("https://example.com", options: opts)
print(found.total, found.truncated, found.sources, found.urls)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `DiscoverMode?` | `.hybrid` | `.sitemap`, `.crawl`, or `.hybrid` (sitemap plus HTTP crawl). |
| `maxUrls`, `maxDepth` | `Int?` | `100`, `2` | 1 to 1000 URLs; crawl depth 1 to 5. |
| `includePatterns`, `excludePatterns` | `[String]?` | none | Regex allowlist, then denylist applied after it. Max 50 patterns each. |
| `sameDomainOnly` | `Bool?` | `true` | Stay on the seed URL's domain. |
| `respectRobots` | `Bool?` | server default | Honor `robots.txt`. |

`DiscoverResult` also reports `pagesCrawled`, `robotsRespected`, and `warnings`, and `sources` holds raw per-source counts taken before dedup.

### Lookup

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

```swift
let search = try await client.v2.lookup(
    "best static site generators",
    options: LookupOptions(category: .web, numResults: 10, perceiveTop: 3)
)
for hit in search.results {
    print(hit.position ?? 0, hit.title ?? "", hit.url ?? "", hit.perceive?.renderQuality ?? 0)
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `category` | `LookupCategory?` | `.web` | `.web`, `.news`, `.images`, `.scholar`, `.patents`, `.maps`. |
| `country`, `locale` | `String?` | none | Google `gl` country code (`"us"`, `"in"`) and `hl` interface language (`"en"`). |
| `timeFilter` | `LookupTimeFilter?` | none | `.hour`, `.day`, `.week`, `.month`, `.year`. |
| `numResults`, `page` | `Int?` | `10`, `1` | 1 to 100 results; page 1 to 10. |
| `location`, `autocorrect` | `String?`, `Bool?` | none, `true` | Free-text location such as `"Austin, Texas"`; let the provider correct the query. |
| `perceiveTop` | `Int?` | `0` | Auto-render the top N result URLs, 0 to 10. Each runs a full browser render. |

`LookupResult` also exposes `answerBox`, `knowledgeGraph`, `perceiveOperationIds`, and `credits`.

### Distill

Pull structured data out of pages against a schema you define.

```swift
let extraction = try await client.v2.distill(DistillOptions(
    urls: ["https://example.com/pricing"],
    schema: ["plans": .string("list of plan names with monthly prices")],
    cssSchema: CssSchema(baseSelector: ".plan-card", fields: [
        CssField(name: "name", type: .text, selector: "h3"),
        CssField(name: "price", type: .text, selector: ".price")
    ])
))

let first = extraction.results[0]
print(first.data ?? [:], first.extractionTier, first.fieldsFromCss, first.fieldsFromLlm)
```

`schema` is a `JSONObject`, which is `[String: JSONValue]`, so a flat `{field: description}` map or a full JSON-Schema object both work. The optional `cssSchema` runs first and answers whatever plain selectors can reach; only the fields it misses escalate to the LLM tier, and `extractionTier` reports which tiers actually answered (`.css`, `.llm`, `.mixed`, or `.none`). `CssField.type` is one of `.text`, `.attribute`, `.html`, `.regex`, `.nested`, `.list`, or `.nestedList`, nested up to 5 levels deep.

Swap `urls` for `discoverFrom` to discover then distill in one call. `DistillDiscoverFrom` takes `url`, `mode` (default `.hybrid`), and `maxPages` (1 to 50, default 10, capping both discovery and distillation):

```swift
_ = try await client.v2.distill(DistillOptions(
    discoverFrom: DistillDiscoverFrom(url: "https://example.com", mode: .sitemap, maxPages: 10),
    schema: ["title": .string("page title"), "summary": .string("one-line summary")]
))
```

Passing both `urls` and `discoverFrom`, or neither, throws `EnconvertError.invalidArgument` before any request is sent.

### Ingest

Turn a site, a URL list, or a stack of uploaded documents into chunked, RAG-ready JSONL. Always asynchronous.

```swift
let job = try await client.v2.ingest(IngestOptions(
    mode: .sitemap,
    url: "https://docs.example.com",
    maxPages: 100,
    chunk: IngestChunkOptions(maxWords: 512, sentenceOverlap: 1),
    webhookUrl: "https://my.app/hooks/enconvert"
))

let status = try await client.v2.getIngestJob(job.jobId)
if status.status == .completed {
    print(status.totalChunks, status.outputUrl ?? "")
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `IngestMode?` | `.urls` | `.urls`, `.sitemap`, or `.crawl`. The fourth case, `.files`, is what `ingestFiles` reports back on its job; do not pass it here. |
| `url` | `String?` | none | Seed URL. Required for `.sitemap` and `.crawl`, forbidden for `.urls`. |
| `urls` | `[String]?` | none | Explicit URLs, max 1000. Required for `.urls`, forbidden otherwise. |
| `maxPages`, `maxDepth` | `Int?` | `50`, `2` | Discovery cap for `.sitemap` and `.crawl`, 1 to 1000; depth 1 to 5. |
| `sameDomainOnly` | `Bool?` | `true` | Stay on the seed URL's domain. |
| `includePatterns`, `excludePatterns` | `[String]?` | none | Regex allowlist, then denylist. |
| `respectRobots` | `Bool?` | server default | Honor `robots.txt`. |
| `waitFor`, `waitTimeoutMs` | `String?`, `Int?` | `30000` ms | Selector or `js:` expression awaited per page, and its budget (0 to 60000). |
| `chunk` | `IngestChunkOptions?` | none | `maxWords` 32 to 4000, default 512. `sentenceOverlap` 0 to 10, default 1. |
| `webhookUrl` | `String?` | none | Completion webhook, HMAC-signed. |

The mode and URL rules above are enforced client-side: `ingest` throws `EnconvertError.invalidArgument` rather than making a doomed request if you pass `urls` with `mode: .sitemap`. Uploaded files run through the same pipeline and the same job lifecycle:

```swift
let files: [FileInput] = [.path("handbook.pdf"), .path("notes.docx")]
let fileJob = try await client.v2.ingestFiles(files, options: IngestFilesOptions(chunk: IngestChunkOptions(maxWords: 512)))
```

PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, and legacy or ODF office files are accepted, and at least one file is required. Job management and webhook plumbing:

```swift
let list = try await client.v2.listIngestJobs(V2ListOptions(limit: 20))
let canceled = try await client.v2.cancelIngestJob(job.jobId)   // idempotent

let secret = try await client.v2.getWebhookSecret()
print(secret.signatureHeader, secret.signatureScheme, secret.replayToleranceSeconds)
_ = try await client.v2.rotateWebhookSecret()                   // old signatures stop verifying at once

let retry = try await client.v2.retryIngestWebhook(job.jobId)
print(retry.delivered, retry.attempts, retry.detail)
```

`retryIngestWebhook` answers `409` when the job is not completed and `400` when it has no webhook configured. `V2ListOptions` takes `skip` and `limit` (1 to 100, default 20).

### Watch

Re-render a page on a fixed cadence and get notified when it changes.

```swift
let watcher = try await client.v2.createWatcher("https://example.com/pricing", options: WatchCreateOptions(
    frequencyMinutes: 60,
    diffMode: .auto,
    webhookUrl: "https://my.app/hooks/changes",
    notifyEmail: true
))

let history = try await client.v2.getWatcherSnapshots(watcher.watcherId, options: SnapshotListOptions(limit: 10))
for snapshot in history.snapshots where snapshot.hasChanges {
    print(snapshot.checkedAt, snapshot.changeCount, snapshot.similarity ?? 0)
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `frequencyMinutes` | `Int?` | `60` | 60 to 43200. The hourly floor is hard. |
| `diffMode` | `WatchDiffMode?` | `.auto` | `.auto`, `.text`, `.structured`, `.tables`, `.metadata`. |
| `trackFields` | `JSONObject?` | none | Field or selector subset handed to the diff engine. |
| `webhookUrl` | `String?` | none | Change-notification webhook, HMAC-signed. |
| `notifyEmail` | `Bool?` | `true` | Email the project owner on changes. |

```swift
// An empty string clears the webhook; nil leaves it alone.
_ = try await client.v2.updateWatcher(watcher.watcherId, updates: WatcherUpdate(webhookUrl: "", status: .paused))

_ = try await client.v2.listWatchers()
_ = try await client.v2.getWatcher(watcher.watcherId)
_ = try await client.v2.deleteWatcher(watcher.watcherId)   // soft delete, idempotent
```

`updateWatcher` requires at least one field and throws `EnconvertError.invalidArgument` on an empty `WatcherUpdate`. `WatchUpdateStatus` accepts only `.active` or `.paused`; deleting goes through `deleteWatcher`, which returns the tombstoned watcher with status `.deleted`.

<div class="alert alert-warning">
<strong>Snapshot diffs contain untrusted page content.</strong> <code>WatcherSnapshot.changes</code> is an array of raw JSON objects lifted from the watched page. Escape the values before rendering them anywhere.
</div>

---

## PDF options

`PdfOptions` is shared by `convertUrlToPdf`, `convertDocument`, `convertWebsiteToPdf`, `PerceiveOptions`, and (for `grayscale` only) `convertToPdf`. Only the fields you set are sent.

```swift
let pdf = PdfOptions(
    pageSize: "A4",
    orientation: .landscape,
    margins: PdfMargins(top: 10, bottom: 10, left: 15, right: 15),
    scale: 0.9,
    grayscale: false,
    header: PdfHeaderFooter(content: "Quarterly Report", height: 15),
    footer: PdfHeaderFooter(content: "Confidential", height: 12)
)
```

| Field | Type | Description |
|-------|------|-------------|
| `pageSize` | `String?` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and friends. |
| `pageWidth`, `pageHeight` | `Double?` | Override `pageSize` when both are set together. |
| `orientation` | `PdfOrientation?` | `.portrait` or `.landscape`. Defaults to portrait. |
| `margins` | `PdfMargins?` | `top`, `bottom`, `left`, `right`, each a `Double?`. All four optional. |
| `scale` | `Double?` | Render scale, for example `0.9` for 90%. |
| `grayscale` | `Bool?` | Post-process the PDF to grayscale. |
| `header` | `PdfHeaderFooter?` | `content` (max 2000 characters) and `height`. |
| `footer` | `PdfHeaderFooter?` | Same shape as `header`. |

---

## Error handling

Swift gets one error type, `EnconvertError`, modeled as an enum rather than a class hierarchy. Match it with `catch` patterns:

```swift
do {
    _ = try await client.v2.perceive("https://example.com")
} catch EnconvertError.authentication(let message) {
    print("invalid or missing API key: \(message)")
} catch EnconvertError.rateLimit(let message) {
    print("too many requests, back off and retry: \(message)")
} catch let error as EnconvertError {
    print("api error: \(error)")   // renders as "[<status>] <message>"
}
```

| Case | Raised on | Status code |
|------|-----------|-------------|
| `.authentication(message:)` | Invalid, missing, or revoked key | `401`, `403` (both report `401`) |
| `.quota(message:)` | Any response the API answers with `402` | `402` |
| `.rateLimit(message:)` | Rate limit exceeded | `429` |
| `.api(statusCode:message:)` | Any other 4xx or 5xx | the actual code |
| `.invalidArgument(_:)` | Client-side validation, before any request | none |

`EnconvertError` conforms to `CustomStringConvertible` and `LocalizedError`, so `String(describing:)`, `localizedDescription`, and string interpolation all render as `"[<status>] <message>"`. Two convenience properties read the same values without pattern matching: `error.statusCode` (`Int?`, `nil` for `.invalidArgument`) and `error.message` (the text without the bracketed prefix). A well-formed 2xx response missing a field the SDK requires surfaces as `.api(statusCode: 0, ...)`, which separates a malformed payload from a real HTTP failure.

Unsupported conversion pairs, a `distill` call with both `urls` and `discoverFrom`, a `perceiveDirect` call asking for two artifacts, and an empty `WatcherUpdate` all throw `.invalidArgument` before the network is touched. Response codes are catalogued in the [error codes reference](/docs/error-codes).

---

## Timeout recovery

Long URL renders and large document conversions can outlive a reverse proxy's 60 to 120 second ceiling even when the job finishes fine on the server. The SDK polls its way out of that, with no code from you:

1. Before each single-file and single-URL conversion, the client generates a UUIDv4, strips the dashes, and sends it as `job_id`.
2. If that request comes back with a status of 500 or higher, the client silently switches to `GET /v1/convert/status/{job_id}`, polling every 3 seconds. A `404` there means "not recorded yet" and keeps the loop going.
3. On `success` it returns the result. On `failed` it throws `.api(statusCode: 500, message:)` carrying the server's message. The polling deadline is 5 minutes, after which you get `.api(statusCode: 504, message: "Conversion timed out")`.

`ConversionResult.jobId` is backfilled by the client even when the sync path succeeded and the response omitted it, so you can hand it to `getJobStatus` yourself:

```swift
let status = try await client.getJobStatus(result.jobId ?? "")
if status.status == .success {
    print(status.presignedUrl ?? "")
} else if status.status == .failed {
    print(status.error ?? "conversion failed")
}
```

<div class="alert alert-info">
<strong>Website batches opt out on purpose.</strong> <code>convertWebsiteToPdf</code> and <code>convertWebsiteToScreenshot</code> have no per-job row to poll, so a 5xx there surfaces immediately instead of being retried. V2 methods do not use job fallback either.
</div>

---

## Configuration

```swift
let client = try Enconvert(
    apiKey: ProcessInfo.processInfo.environment["ENCONVERT_API_KEY"] ?? "",
    baseURL: "https://api.enconvert.com",
    timeout: 300
)
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `apiKey` | `String` | required | Private API key. An empty string throws `EnconvertError.invalidArgument`. |
| `baseURL` | `String` | `https://api.enconvert.com` | Override for a self-hosted gateway. Trailing slashes are stripped. |
| `timeout` | `TimeInterval` | `300` | Seconds. Sets both `timeoutIntervalForRequest` and `timeoutIntervalForResource` on the internal `URLSession`. |

The key travels as an `X-API-Key` header on every API call. Presigned downloads deliberately go out without it, since a signed storage URL authenticates itself and forwarding the key to a storage host would leak it. For per-call cancellation, wrap the call in a `Task` and cancel it: every method is a plain `async throws` function. `Enconvert` stores only `let` properties over one `URLSession`, so build a single client at startup and reuse it; `client.v2` is a thin namespace over the same transport.

<div class="alert alert-warning">
<strong>Never hardcode the API key, and never ship it in an app bundle.</strong> Read it from the environment or your secret manager and keep the client on a server you control. The package builds for iOS, tvOS, and watchOS so you can share model code across targets, but an app binary is a public artifact: anyone who extracts your private key can run conversions against your project. Have the app call your own backend, and let the backend call EnConvert. See [authentication](/docs/authentication) for key types and rotation.
</div>

---

## Result shape

Single-file and single-URL conversions return a `ConversionResult`:

```swift
public struct ConversionResult: Codable, Equatable, Sendable {
    public let presignedUrl: String
    public let objectKey: String
    public let filename: String
    public let fileSize: Int?
    public let conversionTimeSeconds: Double?
    public let jobId: String?
}
```

Presigned URLs are short-lived. Pass `saveTo` to have the SDK stream the bytes to disk for you, creating parent directories as needed, or fetch the URL yourself and store the file in your own bucket for long-term access.

V2 artifacts arrive as `V2OutputArtifact` values keyed by output name, each holding `url` (`String?`, pre-signed for 15 minutes and re-signed on every status GET), `objectKey`, `sizeBytes`, `contentType`, and `expiresIn` (seconds, 900 by default). `PerceiveResult` wraps them with the honesty metadata: `renderQuality`, `statusCode`, `deductions`, `cacheHit`, `warnings`, `contentHash`, `urlFinal`, `structured`, `extractionTier`, `tokens`, `costCents`, `durationMs`, and `optionsEcho`, which echoes back the options the server actually honored with secrets reduced to booleans. Caller-defined payloads (extraction schemas, distilled `data`, watcher `trackFields`, diff `changes`, lookup `extra`) round-trip through `JSONValue`, an enum with `.null`, `.bool`, `.number`, `.string`, `.array`, and `.object` cases, plus the `JSONObject` alias for `[String: JSONValue]`. Every result type is `Codable`, `Equatable`, and `Sendable`, so caching a parsed result to disk and reloading it later works out of the box.

---

## Source and issues

- **Package:** `Enconvert`, via Swift Package Manager. Version exposed at runtime as the module-level constant `VERSION`
- **GitHub:** [conversionapi/swift-sdk](https://github.com/conversionapi/swift-sdk)
- **License:** MIT. Dependencies: none, `URLSession` and Foundation only

Related reading: [all SDKs](/docs/sdks), [V2 overview](/docs/v2-overview), [perceive](/docs/v2-perceive), [discover](/docs/v2-discover), [lookup](/docs/v2-lookup), [distill](/docs/v2-distill), [ingest](/docs/v2-ingest), [watch](/docs/v2-watch), [endpoints overview](/docs/endpoints-overview), [parameters and options](/docs/parameters-options), and your [dashboard](/dashboard) for keys.

---

## Frequently asked questions

### How do I convert files in Swift?

Add `https://github.com/conversionapi/swift-sdk.git` to your `Package.swift` dependencies, build a client with `try Enconvert(apiKey:)`, then call a typed method such as `convertDocument`, `convertImage`, or `convertUrlToPdf`. Pass `saveTo` in the options struct and the SDK streams the finished file straight to that path, creating parent directories as needed.

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

Call `try await client.convertUrlToPdf("https://example.com", options: UrlToPdfOptions(saveTo: "page.pdf"))`. Set `singlePage: false` to paginate instead of producing one continuous page, and pass `pdfOptions:` for page size, orientation, margins, scale, grayscale, headers, and footers. Remember that Swift wants the labels in declaration order, so `saveTo:` comes before `singlePage:` and `pdfOptions:`.

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

`try await client.convertDocument(.path("report.docx"), options: ConvertDocumentOptions(saveTo: "report.pdf"))`. The output format defaults to `"pdf"`, so `outputFormat` can be left out. The same method handles XLSX, PPTX, ODT, ODS, ODP, OTS, Pages, Numbers, HTML, Markdown, CSV, JSON, XML, YAML, and TOML input.

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

`try await client.convertImage(.path("photo.heic"), options: ConvertImageOptions(outputFormat: "webp", saveTo: "photo.webp"))`. The input format is read from the filename extension, and all 20 ordered pairs among `jpeg`, `png`, `svg`, `heic`, and `webp` work the same way. Unsupported pairs throw `EnconvertError.invalidArgument` locally, before any request is sent.

### Does the Swift SDK pull in any third-party dependencies?

No. `Package.swift` declares an empty `dependencies` array. Everything runs on `URLSession`, `JSONSerialization`, and `Foundation`, with `FoundationNetworking` imported conditionally so the package builds on Linux as well as Apple platforms.

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

Two options. `client.convertUrlToMarkdown` returns GitHub-Flavored Markdown with YAML frontmatter and is the simplest path. `client.v2.perceive` with `outputs: [.markdown]` gives you the same Markdown plus a `renderQuality` score, a `deductions` map, `warnings`, and the option to add screenshots, links, or structured extraction in the same render.

### What does render quality mean and why should I check it?

`renderQuality` is a `Double?` from 0.0 to 1.0 attached to every V2 read. It drops when the page did not render honestly: a bot challenge, a login wall, a cookie banner over an empty shell, or an HTTP error status. The content is still returned rather than swallowed, so check the score and the `deductions` dictionary naming each penalty before feeding the text to a model.

### Can I use the Swift SDK inside an iOS or macOS app?

Only behind your own backend. The package builds for iOS 15, tvOS 15, watchOS 8, and macOS 12 so you can share model code across targets, but it authenticates with a private API key and V2 endpoints reject public keys outright. Shipping that key in an app binary hands it to anyone who unzips the bundle. Call your own server from the app, and call EnConvert from the server.

### What happens when a long conversion hits the proxy timeout?

The SDK sends a client-generated `job_id` with each single-file and single-URL conversion. If the request returns 500 or higher, it polls `GET /v1/convert/status/{job_id}` every 3 seconds for up to 5 minutes, returning the result on `success` and throwing `.api(statusCode: 500, ...)` on `failed`. Exceeding the deadline yields `.api(statusCode: 504, message: "Conversion timed out")`. Website batch submissions deliberately skip this fallback.

### How do I know which conversions are supported before I send a request?

Call `validOutputsFor("json")` for the outputs a given input format supports, or check membership in `IMPLEMENTED_CONVERSIONS`, the set of all 43 implemented `{input}-to-{output}` endpoints. `convertImage` and `convertDocument` run the same check internally and throw `EnconvertError.invalidArgument` with the valid outputs listed for that input, before any request is sent.
