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.

Package: Enconvert · Source: conversionapi/swift-sdk · Swift: 5.9+ · Platforms: macOS 12+, iOS 15+, tvOS 15+, watchOS 8+ · Dependencies: none

Install#

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

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#

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.

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.

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

_ = 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.

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.

_ = 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.

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".

// 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:

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.

_ = 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.

_ = 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))
)
Only grayscale is honored here. convertToPdf forwards pdfOptions, but the anything-to-pdf endpoint reads grayscale and ignores the rest. Use convertDocument or convertUrlToPdf when you need page size, orientation, margins, scale, headers, or footers.

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.

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:

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.

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.
Three options are declared but not live yet. proxyUrl, geolocation, and actionChain exist on PerceiveOptions and are serialized onto the wire, but the server currently answers 422 for all three. Leave them nil.

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:

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:

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.

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.

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.

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):

_ = 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.

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:

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:

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.

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.
// 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.

Snapshot diffs contain untrusted page content. WatcherSnapshot.changes is an array of raw JSON objects lifted from the watched page. Escape the values before rendering them anywhere.

PDF options#

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

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:

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.


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:

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")
}
Website batches opt out on purpose. convertWebsiteToPdf and convertWebsiteToScreenshot 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.

Configuration#

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.

Never hardcode the API key, and never ship it in an app bundle. 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.

Result shape#

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

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
  • License: MIT. Dependencies: none, URLSession and Foundation only

Related reading: all SDKs, V2 overview, perceive, discover, lookup, distill, ingest, watch, endpoints overview, parameters and options, and your 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.