---
seo_title: Node.js File Conversion SDK | EnConvert npm Client
meta_desc: Install @enconvert/node-sdk from npm for Node.js 18+. Typed file conversion methods plus a client.v2 namespace to perceive, discover, distill, ingest and watch the web.
keywords: file conversion sdk nodejs, npm file conversion api client, convert files nodejs, url to pdf nodejs sdk, heic to webp nodejs, docx to pdf node js, compress image nodejs, anything to markdown nodejs, anything to pdf nodejs, typescript conversion api client, enconvert node sdk, web scraping sdk nodejs, url to markdown nodejs, structured extraction nodejs, rag ingestion nodejs, website change monitoring nodejs
---

# Node.js File Conversion SDK

`@enconvert/node-sdk` is the official JavaScript and TypeScript client for the EnConvert API. Thirteen typed conversion methods map 1:1 to REST endpoints like `POST /v1/convert/url-to-pdf`, and a second namespace, `client.v2`, adds web intelligence: perceive a URL into agent-ready artifacts, discover a site's URLs, run a web lookup, distill structured data, ingest a site into RAG-ready JSONL, and watch pages for changes. It targets Node.js 18+ with zero runtime dependencies, built on native `fetch`, `FormData`, and `node:stream`, and transparently recovers from reverse-proxy timeouts by polling job status. Ships dual ESM and CJS builds with full TypeScript declarations.

<div class="alert alert-info">
<strong>npm:</strong> <code>@enconvert/node-sdk</code> · <strong>Source:</strong> <a href="https://github.com/enconvert/node-sdk">enconvert/node-sdk</a> · <strong>Node:</strong> 18+
</div>

---

## Install

```bash
npm install @enconvert/node-sdk
```

```bash
pnpm add @enconvert/node-sdk
```

```bash
yarn add @enconvert/node-sdk
```

---

## Quick start

```ts
import { Enconvert } from "@enconvert/node-sdk";

const client = new Enconvert({ apiKey: process.env.ENCONVERT_API_KEY! });

// V1: convert a URL to a PDF and stream it to disk.
const result = await client.convertUrlToPdf("https://example.com", {
    saveTo: "page.pdf",
});
console.log(result.presignedUrl);

// V2: read a page the way your agent should, with a quality score attached.
const op = await client.v2.perceive("https://example.com", {
    outputs: ["markdown", "structured"],
});
console.log(op.outputs.markdown.url, op.renderQuality); // e.g. 0.93
```

The SDK works in every modern Node runtime (Node 18+, Bun, Deno via the npm specifier). It is **server-side only**, so do not bundle your private API key into a browser app.

---

## What the client exposes

One client, two surfaces. Both are reached from the same `Enconvert` instance and share one API key.

| Surface | Reached as | What it covers |
|---------|-----------|----------------|
| File conversion | `client.convertUrlToPdf(...)`, `client.convertImage(...)`, and so on | Thirteen typed methods for URL rendering, image conversion, image compression, document conversion, plus job and whole-site batch polling. See [File conversion](#file-conversion). |
| Web intelligence (V2) | `client.v2.perceive(...)`, `client.v2.distill(...)`, and so on | Twenty-three methods across six capabilities: perceive, discover, lookup, distill, ingest, watch. See [Web intelligence (V2)](#web-intelligence-v2). |

V2 endpoints require a private API key (`sk_...`); public keys are rejected. See [Authentication](/docs/authentication.md) for how the two key types differ, and the [V1 and V2](/docs/concepts/v1-and-v2.md) for the REST surface behind `client.v2`.

---

## File conversion

The conversion surface exposes thirteen methods that map 1:1 to the REST API:

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

Every method returns a typed promise. All option fields are optional unless marked otherwise. The last four are whole-site batch helpers: they submit and poll async jobs, so they return a `BatchSubmission` or a `BatchStatus` rather than a `ConversionResult`.

---

### `convertUrlToPdf`

Render any public URL to a PDF.

```ts
const result = await client.convertUrlToPdf("https://example.com", {
    pdfOptions: { pageSize: "A4", orientation: "landscape" },
    singlePage: false,
    viewportWidth: 1440,
    saveTo: "report.pdf",
});
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `saveTo` | `string` | -- | Local path to stream the PDF to. Parent directories are created automatically. |
| `singlePage` | `boolean` | `true` | `true` produces one continuous page. `false` paginates using `pdfOptions.pageSize`. |
| `pdfOptions` | `PdfOptions` | -- | Page size, orientation, margins, scale, grayscale, header/footer. See [PDF options](#pdf-options). |
| `viewportWidth` | `number` | `1920` | Browser viewport width in pixels. |
| `viewportHeight` | `number` | `1080` | Browser viewport height in pixels. |
| `loadMedia` | `boolean` | `true` | Wait for images and videos before capture. |
| `enableScroll` | `boolean` | `true` | Scroll top-to-bottom to trigger lazy loaders. |
| `outputFilename` | `string` | auto | Override the generated filename. `.pdf` is appended if missing. |

---

### `convertUrlToScreenshot`

Capture a full-page PNG of any URL.

```ts
const result = await client.convertUrlToScreenshot("https://example.com", {
    viewportWidth: 1440,
    saveTo: "screenshot.png",
});
```

Accepts the same viewport, media, scroll, and filename options as `convertUrlToPdf` (minus `singlePage` and `pdfOptions`).

---

### `convertUrlToMarkdown`

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

```ts
const result = await client.convertUrlToMarkdown("https://example.com/article", {
    saveTo: "article.md",
});
```

Useful for building RAG pipelines, importing third-party content into a CMS, or generating training data. If you want a render-quality score alongside the Markdown, use [`client.v2.perceive`](#perceive) instead.

---

### `convertImage`

Convert between `jpeg`, `png`, `svg`, `heic`, and `webp`.

```ts
// From a path
await client.convertImage("photo.heic", {
    outputFormat: "webp",
    saveTo: "photo.webp",
});

// From bytes
import { readFile } from "node:fs/promises";
const buf = await readFile("photo.heic");

await client.convertImage(
    { data: buf, filename: "photo.heic" },
    { outputFormat: "webp", saveTo: "photo.webp" },
);

// Rasterize an SVG at a fixed width
await client.convertImage("logo.svg", { outputFormat: "png", width: 512, saveTo: "logo.png" });
```

The input format is detected from the path / filename extension. The output format is required.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `outputFormat` | `string` | Yes | Target format: `jpeg`, `png`, `svg`, `heic`, or `webp` (and `jpeg` for a `.pdf` input). The aliases `jpg`, `yml`, `htm`, and `md` are normalized. Unsupported pairs throw before the request is sent. |
| `saveTo` | `string` | -- | Local path to stream the result to. |
| `outputFilename` | `string` | -- | Override the generated filename. |
| `width` | `number` | -- | SVG input only (`svg-to-png`, `svg-to-jpeg`, `svg-to-webp`), 1 to 10000. On its own it scales proportionally, taking the height from the SVG's aspect ratio. |
| `height` | `number` | -- | SVG input only (`svg-to-png`, `svg-to-jpeg`, `svg-to-webp`), 1 to 10000. On its own it scales proportionally, taking the width from the SVG's aspect ratio. |

Set both `width` and `height` to pin an exact canvas, which may change the aspect ratio. Omit both and the output keeps the SVG's intrinsic width, height, or `viewBox`. Total output pixels are capped at 25,000,000. Neither option is accepted by `svg-to-heic`, and the SDK throws before sending the request if you pass them to any other conversion.

---

### `convertDocument`

Convert documents and data formats. The default `outputFormat` is `"pdf"`.

```ts
// docx to pdf
await client.convertDocument("report.docx", { saveTo: "report.pdf" });

// json to yaml
await client.convertDocument("data.json", {
    outputFormat: "yaml",
    saveTo: "data.yaml",
});

// markdown to pdf with custom page setup
await client.convertDocument("README.md", {
    outputFormat: "pdf",
    pdfOptions: { pageSize: "A4", margins: { top: 20, bottom: 20, left: 25, right: 25 } },
    saveTo: "readme.pdf",
});
```

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

EPUB has no dedicated document conversion pair. Send `.epub` files through [`convertToPdf`](#converttopdf) or [`convertToMarkdown`](#converttomarkdown) instead.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputFormat` | `string` | `"pdf"` | Target format. |
| `saveTo` | `string` | -- | Local path to stream the result to. |
| `outputFilename` | `string` | -- | Override the generated filename. |
| `pdfOptions` | `PdfOptions` | -- | Page setup. Only honored when output is PDF. |

---

### `compressImage`

Shrink a PNG, JPEG, or WebP without changing its format.

```ts
// Lossless pass only
const result = await client.compressImage("photo.jpg", { saveTo: "photo-small.jpg" });

// Aim for a 200 KB budget
const capped = await client.compressImage("photo.jpg", {
    targetSizeKb: 200,
    saveTo: "photo-capped.jpg",
});

console.log(capped.fileSize);
```

**Supported inputs:** `.png`, `.jpg`, `.jpeg`, `.webp`.

The output keeps the input format and extension, so there is no output format to choose. The first stage is lossless: metadata is stripped, the ICC profile and EXIF orientation are preserved, and the result is never larger than the input. Setting `targetSizeKb` adds a second stage that downscales with the aspect ratio locked until the budget is met. That target is best effort: an unreachable budget returns the smallest file achieved instead of an error, so check `result.fileSize`. Animated APNG and animated WebP are rejected with `400`, and the decoded canvas is capped at 40,000,000 pixels.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `targetSizeKb` | `number` | -- | Size budget in KB, integer, minimum `1`. Omit it to run the lossless pass only. |
| `saveTo` | `string` | -- | Local path to stream the result to. |
| `outputFilename` | `string` | -- | Override the generated filename. The input extension is kept. |

---

### `convertToMarkdown`

Convert any supported document, spreadsheet, presentation, ebook, web, or plain-text file to Markdown.

```ts
await client.convertToMarkdown("handbook.docx", {
    saveTo: "handbook.md",
});
```

**Supported inputs (22):** `.csv`, `.doc`, `.docx`, `.epub`, `.htm`, `.html`, `.markdown`, `.md`, `.mdown`, `.mkd`, `.odp`, `.ods`, `.odt`, `.pdf`, `.ppt`, `.pptx`, `.rtf`, `.text`, `.txt`, `.xhtml`, `.xls`, `.xlsx`.

The output is a single heading-aware `.md` file built for RAG chunking: the document's heading hierarchy survives the conversion, so a semantic chunker can split on headings instead of arbitrary character counts. There are no PDF options on this endpoint. Any other extension throws before a request is made.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `saveTo` | `string` | -- | Local path to stream the Markdown to. |
| `outputFilename` | `string` | -- | Override the generated filename. |

If you want the chunking done for you as well, hand the same files to [`client.v2.ingestFiles`](#ingest).

---

### `convertToPdf`

Convert any supported document, image, ebook, web, or plain-text file to PDF.

```ts
// docx to pdf
await client.convertToPdf("contract.docx", { saveTo: "contract.pdf" });

// html to pdf with full page geometry
await client.convertToPdf("invoice.html", {
    pdfOptions: { pageSize: "A4", margins: { top: 15, bottom: 15, left: 15, right: 15 } },
    saveTo: "invoice.pdf",
});

// pdf to grayscale pdf (passthrough)
await client.convertToPdf("scan.pdf", {
    pdfOptions: { grayscale: true },
    saveTo: "scan-gray.pdf",
});
```

**Supported inputs (36):** `.bmp`, `.csv`, `.doc`, `.docx`, `.epub`, `.gif`, `.heic`, `.heif`, `.htm`, `.html`, `.jpeg`, `.jpg`, `.markdown`, `.md`, `.mdown`, `.mkd`, `.numbers`, `.odp`, `.ods`, `.odt`, `.ots`, `.pages`, `.pdf`, `.png`, `.ppt`, `.pptx`, `.rtf`, `.svg`, `.text`, `.tif`, `.tiff`, `.txt`, `.webp`, `.xhtml`, `.xls`, `.xlsx`.

A `.pdf` input is accepted and passed through, so with `pdfOptions: { grayscale: true }` this method doubles as a PDF normalize path. EPUB is handled here too, since it has no dedicated document conversion pair. Any other extension throws before a request is made.

<div class="alert alert-warning">
<strong>Geometry is input-dependent.</strong> Full page geometry (page size, page width and height, orientation, margins, scale, header, footer) is honored for HTML (<code>.html</code>, <code>.htm</code>, <code>.xhtml</code>), Markdown, plain text, EPUB, image, and SVG input. Office, ODF, iWork, RTF, and CSV input plus PDF passthrough support <code>grayscale</code> only, and return <code>400</code> if an explicit geometry option is set. <code>grayscale</code> itself is honored for every input.
</div>

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `saveTo` | `string` | -- | Local path to stream the PDF to. |
| `outputFilename` | `string` | -- | Override the generated filename. `.pdf` is appended if missing. |
| `pdfOptions` | `PdfOptions` | -- | Page setup. See the caveat above for which inputs honor geometry. |

---

### `getJobStatus`

Poll the status of an async or recovered job.

```ts
const status = await client.getJobStatus("job_abc123");

if (status.status === "success") {
    console.log(status.presignedUrl);
} else if (status.status === "failed") {
    console.error(status.error);
}
```

Returns `{ status: "processing" | "success" | "failed", presignedUrl?, objectKey?, error? }`.

<div class="alert alert-info">
<strong>You usually do not need to call this directly.</strong> The SDK polls automatically when a sync request returns 5xx. See <a href="#timeout-recovery">Timeout recovery</a> below.
</div>

---

### Whole-site batch helpers

`convertWebsiteToPdf` and `convertWebsiteToScreenshot` discover a site's pages, queue them all, and bundle the outputs into one ZIP. Both return a `BatchSubmission` right away; poll with `getBatchStatus` or block with `waitForBatch`. Shared options are `crawlMode` (`"auto"`, `"sitemap"`, or `"full"`), `includePatterns`, `excludePatterns`, `notificationEmail`, and `callbackUrl`; `convertWebsiteToPdf` adds `singlePage` and `pdfOptions`.

```ts
const batch = await client.convertWebsiteToPdf("https://example.com", {
    crawlMode: "sitemap",
    excludePatterns: ["/tag/"],
});

const done = await client.waitForBatch(batch.batchId, { saveTo: "site.zip" });
console.log(done.status, done.completed, done.failed, done.zipDownloadUrl);
```

`waitForBatch` accepts `intervalMs` (default `5_000`), `timeoutMs` (default `1_800_000`, thirty minutes), and `saveTo`. It throws `APIError(504, ...)` if the deadline passes. See [Endpoints overview](/docs/endpoints.md) for the REST surface.

---

## Web intelligence (V2)

Everything under `client.v2` returns data an agent can trust, because every V2 render carries a `renderQuality` score from 0.0 to 1.0. A blocked page, a bot challenge, a login wall, an HTTP error page, a soft 404, or an empty SPA shell comes back with a low score plus named `deductions` and `warnings`, so it is flagged rather than mistaken for real content. The content is still returned; you decide what to do with it. Scores below roughly 0.40 mean the render did not succeed in any useful sense.

Twenty-three methods across six capabilities:

| Method | Endpoint | Returns |
|--------|----------|---------|
| `v2.perceive(url, options?)` | `POST /v2/perceive` | `PerceiveResult` |
| `v2.perceiveDirect(url, options?)` | `POST /v2/perceive` | `PerceiveDirectResult` |
| `v2.getPerceiveOperation(operationId)` | `GET /v2/perceive/{operationId}` | `PerceiveResult` |
| `v2.downloadPerceiveArtifact(operationId, output?)` | `GET /v2/perceive/{operationId}` | `PerceiveDirectResult` |
| `v2.perceiveBatch(urls, options?)` | `POST /v2/perceive/batch` | `PerceiveBatchResult` |
| `v2.getPerceiveBatch(jobId)` | `GET /v2/perceive/batch/{jobId}` | `PerceiveBatchResult` |
| `v2.discover(url, options?)` | `POST /v2/discover` | `DiscoverResult` |
| `v2.lookup(query, options?)` | `POST /v2/lookup` | `LookupResult` |
| `v2.distill(options)` | `POST /v2/distill` | `DistillResult` |
| `v2.ingest(options)` | `POST /v2/ingest` | `IngestJob` |
| `v2.ingestFiles(files, options?)` | `POST /v2/ingest/files` | `IngestJob` |
| `v2.listIngestJobs(options?)` | `GET /v2/ingest` | `IngestJobList` |
| `v2.getIngestJob(jobId)` | `GET /v2/ingest/{jobId}` | `IngestJob` |
| `v2.cancelIngestJob(jobId)` | `DELETE /v2/ingest/{jobId}` | `IngestJob` |
| `v2.retryIngestWebhook(jobId)` | `POST /v2/ingest/{jobId}/retry-webhook` | `WebhookRetryResult` |
| `v2.getWebhookSecret()` | `GET /v2/ingest/webhook-secret` | `WebhookSecret` |
| `v2.rotateWebhookSecret()` | `POST /v2/ingest/webhook-secret/rotate` | `WebhookSecret` |
| `v2.createWatcher(url, options?)` | `POST /v2/watch` | `Watcher` |
| `v2.listWatchers(options?)` | `GET /v2/watch` | `WatcherList` |
| `v2.getWatcher(watcherId)` | `GET /v2/watch/{watcherId}` | `Watcher` |
| `v2.getWatcherSnapshots(watcherId, options?)` | `GET /v2/watch/{watcherId}/snapshots` | `WatcherSnapshotList` |
| `v2.updateWatcher(watcherId, updates)` | `PATCH /v2/watch/{watcherId}` | `Watcher` |
| `v2.deleteWatcher(watcherId)` | `DELETE /v2/watch/{watcherId}` | `Watcher` |

Options are camelCase on the SDK surface and serialized to the API's snake_case wire format; responses are mapped back to camelCase. Your own payloads (extraction schemas, extracted data, tracked fields, diff entries) pass through untouched.

---

### Perceive

Render one URL into the artifacts you ask for: Markdown, cleaned or raw HTML, a viewport or full-page screenshot, a PDF, a link list, an image list, or structured data. `perceive` is synchronous and returns the completed operation with 15-minute signed artifact URLs. Full reference: [Perceive](/docs/endpoints/perceive.md).

```ts
const op = await client.v2.perceive("https://example.com", {
    outputs: ["markdown", "screenshot", "structured"],
    extract: ["tables", "metadata"],
    onlyMainContent: true,
    waitFor: "css:.article-body",
    viewport: { width: 1440, height: 900 },
});

console.log(op.renderQuality);        // 0.0 to 1.0
console.log(op.deductions);           // e.g. { http_error: 0.7 }
console.log(op.outputs.markdown.url); // 15-minute signed URL
console.log(op.structured);

if ((op.renderQuality ?? 0) < 0.4) {
    console.warn("Bad read, do not feed this to the model:", op.warnings);
}

// Re-sign artifact URLs later without re-rendering:
const again = await client.v2.getPerceiveOperation(op.operationId);
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `outputs` | `PerceiveOutputName[]` | `["markdown", "structured"]` | Any of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, `images`, `structured`. |
| `extract` | `PerceiveExtractName[]` | -- | Heuristic targets: `tables`, `prices`, `contacts`, `metadata`, `main_content`, `headings`, `structured_data`, `technologies`, `all`. |
| `onlyMainContent` | `boolean` | `true` | Strip nav, header, footer, and cookie banners from the Markdown output behind a fidelity guard. `false` returns the full page untouched. |
| `schema` | `Record<string, unknown>` | -- | JSON schema for structured extraction on the LLM tier. |
| `waitFor` | `string` | -- | CSS selector (optionally `"css:..."`) or `"js:<expr>"` to await before capture. |
| `waitTimeoutMs` | `number` | `30000` | 0 to 60000. |
| `jsCode` | `string` | -- | JavaScript executed after navigation. Max 20000 characters. |
| `viewport` | `{ width?, height? }` | `1920 x 1080` | Width 320 to 3840, height 240 to 2160. |
| `headers` | `Record<string, string>` | -- | Extra request headers. |
| `cookies` | `BrowserCookie[]` | -- | Cookies injected before rendering. Each needs `name`, `value`, and either `domain` or `url`. |
| `auth` | `{ username, password }` | -- | HTTP Basic Auth. |
| `cacheMode` | `"enabled" \| "bypass" \| "refresh"` | `"enabled"` | One-hour cache. `bypass` skips it, `refresh` forces a re-render. |
| `pdfOptions` | `PdfOptions` | -- | Only meaningful when `outputs` includes `"pdf"`. See [PDF options](#pdf-options). |
| `blockResources` | `PerceiveResourceType[]` | -- | Resource types the browser should not load, for example `["image", "font", "media"]`. |
| `respectRobots` | `boolean` | -- | Honor the site's robots rules. |
| `mobile` | `boolean` | -- | Render with a mobile profile. |
| `directDownload` | `boolean` | -- | `perceive` only. Prefer `perceiveDirect`, which sets it for you. |

<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> are typed on <code>PerceiveOptions</code> but currently rejected server-side with <code>422</code>. They are reserved, not usable.
</div>

**Direct download.** `perceiveDirect` skips the signed-URL round trip: the HTTP response body is the artifact bytes and the metadata rides in headers. It requires exactly one artifact-producing output, and the SDK throws locally before sending if that is not the case (`"structured"` may ride along, but it stays inline server-side and is not returned).

```ts
import { writeFile } from "node:fs/promises";

const direct = await client.v2.perceiveDirect("https://example.com", { outputs: ["markdown"] });
console.log(direct.contentType, direct.renderQuality, direct.sourceStatusCode);
await writeFile(direct.filename ?? "page.md", direct.content);

// Re-download a stored artifact from an earlier operation as raw bytes.
// `output` may be omitted when the operation produced exactly one artifact.
const raw = await client.v2.downloadPerceiveArtifact(op.operationId, "markdown");
```

`downloadPerceiveArtifact` returns `410` once the stored artifact has expired, and `400` (listing the available outputs) if the operation produced more than one artifact and you omitted `output`.

**Batches.** `perceiveBatch` takes up to 1000 URLs with one shared options block. Small batches run inline and come back completed; larger ones return status `"queued"`, so poll `getPerceiveBatch` with the returned `jobId`.

```ts
const batch = await client.v2.perceiveBatch(["https://example.com/a", "https://example.com/b"], {
    outputs: ["markdown"],
    outputMode: "zip",
});

let job = await client.v2.getPerceiveBatch(batch.jobId);
while (job.status === "queued" || job.status === "processing") {
    await new Promise((r) => setTimeout(r, 3000));
    job = await client.v2.getPerceiveBatch(batch.jobId);
}
console.log(job.completed, job.failed, job.zip?.url);
```

`outputMode` is `"manifest"` (default, one entry per URL in `items`) or `"zip"` (every successful artifact bundled once the job finishes). The batch endpoint rejects `directDownload`; use `outputMode: "zip"` instead.

---

### Discover

List a site's URLs without rendering anything. No browser is involved, so it is fast and cheap compared to perceiving every page. Full reference: [Discover](/docs/coming-soon/discover.md).

```ts
const found = await client.v2.discover("https://example.com", {
    mode: "hybrid",
    maxUrls: 200,
    maxDepth: 3,
    excludePatterns: ["/tag/", "/author/"],
    sameDomainOnly: true,
});

console.log(found.total, found.truncated, found.sources); // e.g. { sitemap: 42, crawl: 30 }
for (const url of found.urls) console.log(url);
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `"sitemap" \| "crawl" \| "hybrid"` | `"hybrid"` | Sitemap only, HTTP crawl only, or both. |
| `maxUrls` | `number` | `100` | 1 to 1000. `truncated` is `true` when more URLs existed than this cap allowed. |
| `maxDepth` | `number` | `2` | 1 to 5. Crawl depth from the seed URL. |
| `includePatterns` | `string[]` | -- | Regex allowlist, max 50 entries. |
| `excludePatterns` | `string[]` | -- | Regex denylist applied after `includePatterns`, max 50 entries. |
| `sameDomainOnly` | `boolean` | `true` | Keep the crawl on the seed domain. |
| `respectRobots` | `boolean` | -- | Honor the site's robots rules. `robotsRespected` on the result reports what happened. |

---

### Lookup

Run a categorized web search, and optionally auto-perceive the top results so each hit carries its own full `PerceiveResult` inline. Full reference: [Lookup](/docs/coming-soon/lookup.md).

```ts
const search = await client.v2.lookup("best static site generators", {
    category: "web",
    numResults: 10,
    country: "us",
    locale: "en",
    timeFilter: "month",
    perceiveTop: 3,
});

for (const hit of search.results) {
    console.log(hit.position, hit.title, hit.url);
    if (hit.perceive) {
        console.log("  quality:", hit.perceive.renderQuality);
        console.log("  markdown:", hit.perceive.outputs.markdown?.url);
    }
}
console.log(search.answerBox, search.knowledgeGraph);
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `category` | `"web" \| "news" \| "images" \| "scholar" \| "patents" \| "maps"` | `"web"` | Search vertical. |
| `country` | `string` | -- | Google `gl` country code, for example `"us"` or `"in"`. |
| `locale` | `string` | -- | Google `hl` interface language, for example `"en"`. |
| `timeFilter` | `"hour" \| "day" \| "week" \| "month" \| "year"` | -- | Recency window. |
| `numResults` | `number` | `10` | 1 to 100. |
| `page` | `number` | `1` | 1 to 10. |
| `location` | `string` | -- | Free-text location, for example `"Austin, Texas"`. |
| `autocorrect` | `boolean` | `true` | Let the provider fix obvious typos. |
| `perceiveTop` | `number` | `0` | 0 to 10. Auto-render the top N result URLs; each one is a full browser render. |

`perceiveTop` on the result reports how many results were actually perceived, which can be lower than what you asked for, and `perceiveOperationIds` gives you the operation ids to re-sign later.

---

### Distill

Schema-driven structured extraction. Give it a shape and a set of URLs (or a site to discover first) and it returns records matching that shape. An optional `cssSchema` answers whatever it can from selectors before anything escalates to the LLM tier. Full reference: [Distill](/docs/coming-soon/distill.md).

```ts
const extraction = await client.v2.distill({
    urls: ["https://example.com/pricing"],
    schema: { plans: "list of plan names with monthly prices" },
    cssSchema: {
        baseSelector: ".plan-card",
        fields: [
            { name: "name", type: "text", selector: "h3" },
            { name: "price", type: "text", selector: ".price" },
            { name: "url", type: "attribute", selector: "a", attribute: "href" },
        ],
    },
});

const first = extraction.results[0];
console.log(first.data);
console.log(first.extractionTier);  // "css" | "llm" | "mixed" | "none"
console.log(first.fieldsFromCss, first.fieldsFromLlm, first.renderQuality);
```

Pass exactly one of `urls` or `discoverFrom`; the SDK throws locally if you pass both or neither, and it also throws if `schema` is missing or is not an object.

```ts
// Discover a site first, then distill every page it found.
await client.v2.distill({
    discoverFrom: { url: "https://example.com", mode: "sitemap", maxPages: 10 },
    schema: { title: "page title", summary: "one-line summary" },
});
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `urls` | `string[]` | -- | Explicit URLs to distill, max 50. Mutually exclusive with `discoverFrom`. |
| `discoverFrom` | `{ url, mode?, maxPages? }` | -- | Discover first, then distill. `maxPages` is 1 to 50, default 10, and caps both discovery and distillation. |
| `schema` | `Record<string, unknown>` | required | A JSON-Schema object (`{ type: "object", properties: {...} }`) or a flat `{ field: description }` map. |
| `cssSchema` | `CssSchema` | -- | Free selector pass run before any LLM escalation. |
| `waitFor` | `string` | -- | CSS selector or `"js:<expr>"` to await. |
| `waitTimeoutMs` | `number` | `30000` | 0 to 60000. |
| `headers` | `Record<string, string>` | -- | Extra request headers. |
| `cookies` | `BrowserCookie[]` | -- | Cookies injected before rendering. |
| `respectRobots` | `boolean` | -- | Honor the site's robots rules. |

A `CssSchema` has a `baseSelector` (the repeating container, one record per match), a `fields` list, an optional `name`, and an optional `targetField` naming the output-schema property the records fill. Each field is `{ name, type, selector?, attribute?, pattern?, default?, transform?, fields? }` where `type` is one of `text`, `attribute`, `html`, `regex`, `nested`, `list`, `nested_list`. `attribute` is required for `attribute` fields, `pattern` for `regex` fields, and a non-empty `fields` array for the nested types (max depth 5).

---

### Ingest

Turn a site, or a pile of uploaded documents, into chunked RAG-ready JSONL. Ingest is always asynchronous: both entry points return a queued `IngestJob`, and you either poll it or configure a webhook. Full reference: [Ingest](/docs/endpoints/ingest.md).

```ts
// From a site.
const job = await client.v2.ingest({
    mode: "sitemap",
    url: "https://docs.example.com",
    maxPages: 100,
    chunk: { maxWords: 512, sentenceOverlap: 1 },
    webhookUrl: "https://my.app/hooks/enconvert",
});

// From uploaded files: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT/MD,
// and legacy or ODF office documents.
const fileJob = await client.v2.ingestFiles(["handbook.pdf", "notes.docx"], {
    chunk: { maxWords: 512, sentenceOverlap: 1 },
});

// Poll either one the same way. Non-terminal states: queued, discovering, processing.
let status = await client.v2.getIngestJob(job.jobId);
while (!["completed", "failed", "canceled"].includes(status.status)) {
    await new Promise((r) => setTimeout(r, 5000));
    status = await client.v2.getIngestJob(job.jobId);
}
console.log(status.totalChunks, status.outputUrl, status.errorMessage);

const page = await client.v2.listIngestJobs({ limit: 20, skip: 0 });
console.log(page.jobs.length, page.hasMore);

await client.v2.cancelIngestJob(job.jobId); // idempotent
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `mode` | `"urls" \| "sitemap" \| "crawl"` | `"urls"` | `"urls"` needs `urls` and rejects `url`. `"sitemap"` and `"crawl"` need a seed `url` and reject `urls`. Both rules are enforced locally before the request. The `IngestMode` union also has `"files"`, which is what `ingestFiles` reports on its job; do not pass it here. |
| `url` | `string` | -- | Seed URL for `sitemap` and `crawl`. |
| `urls` | `string[]` | -- | Explicit URLs for mode `"urls"`, max 1000. |
| `maxPages` | `number` | `50` | Discovery cap for `sitemap` and `crawl`, 1 to 1000. |
| `maxDepth` | `number` | `2` | 1 to 5. |
| `sameDomainOnly` | `boolean` | `true` | Keep the crawl on the seed domain. |
| `includePatterns` / `excludePatterns` | `string[]` | -- | Regex allowlist and denylist. |
| `respectRobots` | `boolean` | -- | Honor the site's robots rules. |
| `waitFor` / `waitTimeoutMs` | `string` / `number` | -- / `30000` | Per-page render wait. |
| `chunk` | `{ maxWords?, sentenceOverlap? }` | `512` / `1` | `maxWords` is 32 to 4000, `sentenceOverlap` is 0 to 10. |
| `webhookUrl` | `string` | -- | Completion webhook, HMAC-signed. |

`ingestFiles` accepts a `FileInput[]`, which means path strings, `Uint8Array` / `Buffer`, or `{ data, filename, contentType? }` objects, in any mix. It takes only `chunk` and `webhookUrl`, and throws locally on an empty list.

**Webhook signing.** Completion webhooks are HMAC-signed. Fetch the secret (it is created on first call) to verify deliveries, rotate it when you need to, and re-deliver a webhook that your endpoint missed.

```ts
const secret = await client.v2.getWebhookSecret();
console.log(secret.signatureHeader, secret.timestampHeader);
console.log(secret.signatureScheme, secret.replayToleranceSeconds);

// Rotating invalidates signatures made with the previous secret immediately.
const rotated = await client.v2.rotateWebhookSecret();

// Re-deliver a completed job's webhook.
const retry = await client.v2.retryIngestWebhook(job.jobId);
console.log(retry.delivered, retry.attempts, retry.statusCode, retry.detail);
```

`retryIngestWebhook` returns `409` when the job is not completed and `400` when the job has no webhook configured.

---

### Watch

Create a watcher that re-renders a URL on a fixed cadence and notifies you when the page changes, by email, by webhook, or both. Full reference: [Watch](/docs/coming-soon/watch.md).

```ts
const watcher = await client.v2.createWatcher("https://example.com/pricing", {
    frequencyMinutes: 60,
    diffMode: "auto",
    webhookUrl: "https://my.app/hooks/changes",
    notifyEmail: true,
});
console.log(watcher.watcherId, watcher.nextCheckAt);

const list = await client.v2.listWatchers({ limit: 20 });
const one = await client.v2.getWatcher(watcher.watcherId);

const history = await client.v2.getWatcherSnapshots(watcher.watcherId, { limit: 10 });
for (const snap of history.snapshots) {
    console.log(snap.checkedAt, snap.hasChanges, snap.similarity, snap.changeCount);
}

await client.v2.updateWatcher(watcher.watcherId, { status: "paused" });
await client.v2.updateWatcher(watcher.watcherId, { webhookUrl: "" }); // clears the webhook
await client.v2.deleteWatcher(watcher.watcherId);                     // soft delete, idempotent
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `frequencyMinutes` | `number` | `60` | Minutes between checks, 60 to 43200. The hourly floor is hard. |
| `diffMode` | `"auto" \| "text" \| "structured" \| "tables" \| "metadata"` | `"auto"` | `"auto"` lets the diff engine pick by content type. |
| `trackFields` | `Record<string, unknown>` | -- | Field or selector subset to narrow the diff. |
| `webhookUrl` | `string` | -- | Change-notification webhook, HMAC-signed. |
| `notifyEmail` | `boolean` | `true` | Email the project owner on changes. |

`updateWatcher` takes the same fields plus `status` (`"active"` or `"paused"`) and requires at least one of them; the SDK throws locally on an empty update. Passing `webhookUrl: ""` explicitly clears the webhook. Deleting is a soft delete: `deleteWatcher` returns the tombstoned watcher with status `"deleted"`, and a deleted watcher reads as `404` from `getWatcher`.

Each snapshot carries `checkedAt`, `hasChanges`, `similarity` (0.0 to 1.0 against the previous capture), `renderQuality`, `changeCount`, and a `changes` array.

<div class="alert alert-warning">
<strong>Snapshot diffs contain untrusted page content.</strong> Entries in <code>snapshot.changes</code> come straight from the watched page. Escape them before rendering into HTML or writing them into a log viewer.
</div>

---

## PDF options

Passed via the `pdfOptions` field on `convertUrlToPdf`, `convertDocument`, `convertToPdf`, `convertWebsiteToPdf`, and `client.v2.perceive` (when `outputs` includes `"pdf"`).

```ts
const result = await client.convertUrlToPdf("https://example.com", {
    pdfOptions: {
        pageSize: "A4",
        orientation: "landscape",
        margins: { top: 10, bottom: 10, left: 15, right: 15 },
        scale: 0.9,
        grayscale: false,
    },
    saveTo: "report.pdf",
});
```

| Field | Type | Description |
|-------|------|-------------|
| `pageSize` | `string` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, etc. |
| `pageWidth` / `pageHeight` | `number` | Explicit page geometry, as an alternative to `pageSize`. |
| `orientation` | `"portrait" \| "landscape"` | Defaults to portrait. |
| `margins` | `{ top, bottom, left, right }` (mm) | All four are optional. |
| `scale` | `number` | Render scale, e.g. `0.9` for 90%. |
| `grayscale` | `boolean` | Post-process the PDF through Ghostscript to grayscale. |
| `header` | `PdfHeaderFooter` | `{ content?, height? }`. `content` is capped at 2000 characters. |
| `footer` | `PdfHeaderFooter` | Same shape as `header`. |

Every parameter is described in full in [Sync and Async Jobs](/docs/concepts/sync-and-async.md).

## Error handling

Errors are typed exception classes that you can match with `instanceof`. The same hierarchy covers both the conversion methods and `client.v2`.

```ts
import {
    Enconvert,
    APIError,
    AuthenticationError,
    QuotaError,
    RateLimitError,
} from "@enconvert/node-sdk";

try {
    await client.v2.perceive("https://example.com", { outputs: ["markdown"] });
} catch (e) {
    if (e instanceof AuthenticationError) {
        console.error("Invalid API key. Check ENCONVERT_API_KEY.");
    } else if (e instanceof QuotaError) {
        console.error("Request rejected with 402.");
    } else if (e instanceof RateLimitError) {
        console.error("Too many requests. Back off and retry.");
    } else if (e instanceof APIError) {
        console.error(`API error [${e.statusCode}]: ${e.message}`);
    } else {
        throw e;
    }
}
```

| Class | Thrown on | Status code |
|-------|-----------|-------------|
| `AuthenticationError` | Invalid, missing, or revoked key | `401`, `403` (both report `statusCode` `401`) |
| `QuotaError` | Raised on HTTP 402 | `402` |
| `RateLimitError` | Too many requests | `429` |
| `APIError` | Any other 4xx / 5xx | the actual code |
| `EnconvertError` | Base class for all of the above | -- |

`QuotaError` and `RateLimitError` both extend `APIError`, which extends `EnconvertError`, so order your `instanceof` checks from most specific to least. Every `APIError` carries a `statusCode` field.

Some failures never reach the network at all: an unsupported file extension, a `distill` call with both `urls` and `discoverFrom`, an `ingest` call whose mode and arguments disagree, a `perceiveDirect` call with more than one artifact output, or an `updateWatcher` call with no fields. Those throw a plain `Error` locally so you find the mistake in development.

The full error message map is in the [Error codes](/docs/reference/errors.md) reference.

---

## Timeout recovery

Long URL-to-PDF or large document conversions can exceed the 60-120 second reverse-proxy timeout limit, even when the conversion eventually succeeds on the server. The SDK handles this transparently on the V1 conversion methods:

1. Before each request, the SDK generates a UUID and sends it as `job_id` in the request body.
2. If the original request returns 5xx, the SDK silently switches to polling `GET /v1/convert/status/{job_id}` every 3 seconds.
3. As soon as the job is recorded as `success`, the SDK returns the result. As soon as it is recorded as `failed`, the SDK throws `APIError`.
4. Polling deadline is 5 minutes. If exceeded, the SDK throws `APIError(504, "Conversion timed out")`.

You don't need to write any code for this, it just works. Set `timeout` on the constructor if you want to bound the initial request.

V2 uses explicit job objects instead of implicit recovery: `perceiveBatch` and `ingest` return an id you poll with `getPerceiveBatch` and `getIngestJob`, and `ingest` can call a webhook instead.

---

## Configuration

```ts
const client = new Enconvert({
    apiKey: process.env.ENCONVERT_API_KEY!,
    timeout: 300_000, // ms, 5 min default
    baseUrl: "https://api.enconvert.com", // override for self-hosted gateways
});
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `apiKey` | `string` | -- (required) | Private API key (`sk_...`). The constructor throws immediately if it is missing. |
| `timeout` | `number` | `300_000` | Request timeout in ms. Aborts the underlying `fetch` via `AbortController`. |
| `baseUrl` | `string` | `https://api.enconvert.com` | API base URL. Trailing slashes are stripped. |

The key travels as an `X-API-Key` header on every request, V1 and V2 alike. `client.v2` is constructed for you and shares the client's key, base URL, and timeout, so there is nothing extra to configure.

<div class="alert alert-warning">
<strong>Never hardcode the API key.</strong> Read it from an environment variable or your secret manager. Anyone who gets your private key can run conversions and V2 operations on your account. Rotate keys from the <a href="/dashboard">dashboard</a>.
</div>

---

## Result shape

Every conversion method returns a `ConversionResult`:

```ts
interface ConversionResult {
    presignedUrl: string;          // signed URL to download the output (1 hour)
    objectKey: string;             // storage object key
    filename: string;              // server-side filename
    fileSize?: number;             // bytes
    conversionTimeSeconds?: number;
    jobId?: string;                // present when timeout recovery polled
}
```

The presigned URL is valid for one hour. If you need permanent access, download the file (use `saveTo`, or fetch the URL yourself) and store it in your own bucket.

V2 results are shaped differently. A `PerceiveResult` carries `operationId`, `status`, `url`, `urlFinal`, `contentHash`, `renderQuality`, `statusCode`, `deductions`, `cacheHit`, an `outputs` map keyed by output name, `structured`, `extractionTier`, `tokens`, `costCents`, `durationMs`, `optionsEcho`, `error`, and `warnings`. Each entry in `outputs` is a `V2OutputArtifact` of `{ url?, objectKey, sizeBytes, contentType, expiresIn }`, where `expiresIn` is seconds and defaults to `900`. V2 artifact URLs therefore last 15 minutes rather than an hour, and they are re-signed on every read, so calling `getPerceiveOperation(operationId)` again gives you fresh links without re-rendering the page.

---

## TypeScript

Type definitions ship with the package, so no `@types/...` install is needed. The package is dual-published (ESM + CJS) with proper `exports`, `types`, and `.d.ts` / `.d.cts` so it works under any Node module resolution mode.

```ts
import type {
    ClientOptions,
    CompressImageOptions,
    ConversionResult,
    ConvertDocumentOptions,
    ConvertImageOptions,
    ConvertToMarkdownOptions,
    ConvertToPdfOptions,
    FileInput,
    JobStatus,
    PdfOptions,
    UrlToMarkdownOptions,
    UrlToPdfOptions,
    UrlToScreenshotOptions,
    // V2 types come from the same entry point.
    DiscoverOptions, DiscoverResult,
    DistillOptions, DistillResult,
    IngestJob, IngestOptions,
    LookupOptions, LookupResult,
    PerceiveOptions, PerceiveResult, PerceiveOutputName,
    PerceiveBatchResult, PerceiveDirectResult,
    Watcher, WatcherSnapshotList,
} from "@enconvert/node-sdk";
```

The `EnconvertV2` class itself is exported too, if you want to type a function parameter as the V2 namespace.

---

## Upgrading

The package ships a small CLI, `enconvert-sdk`, for keeping itself current.

```bash
npx enconvert-sdk upgrade
```

```bash
npx enconvert-sdk upgrade --dry-run
```

```bash
npx enconvert-sdk version
```

`upgrade` detects npm, pnpm, yarn, or bun from the ambient package manager and always prints the exact install command before running it, so nothing happens to your lockfile unseen. `--dry-run` prints that command and stops. `version` reports the installed SDK version.

---

## Source and issues

- **npm:** [@enconvert/node-sdk](https://www.npmjs.com/package/@enconvert/node-sdk)
- **GitHub:** [enconvert/node-sdk](https://github.com/enconvert/node-sdk)
- **License:** MIT
- **Other languages:** [All SDKs](/docs/guides/integrations/sdks.md)

---

## Frequently asked questions

### How do I convert files in Node.js with an npm package?

Install `@enconvert/node-sdk`, create a client with your API key (`new Enconvert({ apiKey: process.env.ENCONVERT_API_KEY! })`), and call a typed method like `convertUrlToPdf`, `convertImage`, or `convertDocument`. Pass `saveTo` to stream the result straight to disk.

### How do I scrape a web page into clean Markdown in Node.js?

Call `client.v2.perceive(url, { outputs: ["markdown"] })`. You get a 15-minute signed URL to the Markdown in `op.outputs.markdown.url` plus a `renderQuality` score for the read. If you want the bytes directly instead of a URL, call `client.v2.perceiveDirect(url, { outputs: ["markdown"] })` and read `result.content`.

### What is renderQuality and why does it matter?

`renderQuality` is a 0.0 to 1.0 score attached to every V2 render. A bot challenge, a login wall, an HTTP error page, a soft 404, or an empty SPA shell all score low and come back with named `deductions` and `warnings`, so a bad read is flagged instead of quietly entering your agent's context as if it were the real page. Scores below roughly 0.40 mean the render failed in practice, even though the request returned 200.

### How do I convert HEIC to WebP in Node.js?

Call `convertImage` with the HEIC file (a path or a `{ data, filename }` buffer object) and `outputFormat: "webp"`. The SDK converts between `jpeg`, `png`, `svg`, `heic`, and `webp`; input format is detected from the filename extension.

### How do I compress an image in Node.js without changing its format?

Call `compressImage` with a `.png`, `.jpg`, `.jpeg`, or `.webp` file. The output keeps the input format and extension, strips metadata while preserving the ICC profile and EXIF orientation, and is never larger than the input. Add `targetSizeKb` to downscale toward a size budget; the target is best effort, so read `result.fileSize` to see what was actually achieved.

### How do I convert any document to Markdown for a RAG pipeline?

Call `convertToMarkdown` with the file and pass `saveTo` to write the `.md` straight to disk. It accepts 22 extensions across Office, OpenDocument, PDF, EPUB, HTML, CSV, and plain text, and returns one heading-aware Markdown file, so your chunker can split on the document's own headings instead of arbitrary character counts.

### How do I turn a whole website into RAG-ready chunks in Node.js?

Call `client.v2.ingest({ mode: "sitemap", url, maxPages, chunk: { maxWords: 512, sentenceOverlap: 1 } })`. Ingest is always asynchronous, so poll `client.v2.getIngestJob(job.jobId)` until `status` is `"completed"` and read `outputUrl` for the signed JSONL, or set `webhookUrl` and let the completion webhook tell you. For local documents rather than a site, `client.v2.ingestFiles([...])` runs the same pipeline.

### How do I extract structured JSON from a page in Node.js?

Call `client.v2.distill({ urls, schema })` where `schema` is either a JSON-Schema object or a flat `{ field: description }` map. Add a `cssSchema` and the selector pass answers whatever it can before anything escalates to the LLM tier; `result.extractionTier`, `fieldsFromCss`, and `fieldsFromLlm` tell you which tier did the work.

### How do I monitor a web page for changes in Node.js?

Call `client.v2.createWatcher(url, { frequencyMinutes: 60, diffMode: "auto", webhookUrl })`. The hourly floor is hard, so 60 is the minimum cadence. Read the history with `getWatcherSnapshots`, pause with `updateWatcher(id, { status: "paused" })`, and remove with `deleteWatcher`, which is a soft delete and is idempotent.

### How does the SDK handle long conversions that hit the reverse-proxy timeout?

Before each V1 conversion request the SDK generates a UUID and sends it as `job_id`; if the request returns 5xx, it silently polls `GET /v1/convert/status/{job_id}` every 3 seconds until the job is `success` or `failed`. The polling deadline is 5 minutes, after which it throws `APIError(504, "Conversion timed out")`. V2 uses explicit job ids instead, polled with `getPerceiveBatch` or `getIngestJob`.

### Can I use the Node.js SDK in a browser app?

No, the SDK is server-side only, because it authenticates with a private API key (`sk_...`) that must never be bundled into client-side code. It works in Node 18+, Bun, and Deno via the npm specifier.

### How long is the presigned download URL valid?

The `presignedUrl` in every `ConversionResult` is valid for one hour. V2 artifact URLs are valid for 15 minutes and are re-signed on every read, so `getPerceiveOperation(operationId)` hands you fresh links. For permanent access, download the file and store it in your own bucket.
