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.

npm: @enconvert/node-sdk · Source: enconvert/node-sdk · Node: 18+

Install#

npm install @enconvert/node-sdk
pnpm add @enconvert/node-sdk
yarn add @enconvert/node-sdk

Quick start#

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

V2 endpoints require a private API key (sk_...); public keys are rejected. See Authentication for how the two key types differ, and the V1 and V2 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.

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

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

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


convertImage#

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

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

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

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

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.


convertToPdf#

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

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

Geometry is input-dependent. Full page geometry (page size, page width and height, orientation, margins, scale, header, footer) is honored for HTML (.html, .htm, .xhtml), Markdown, plain text, EPUB, image, and SVG input. Office, ODF, iWork, RTF, and CSV input plus PDF passthrough support grayscale only, and return 400 if an explicit geometry option is set. grayscale itself is honored for every input.
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.

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? }.

You usually do not need to call this directly. The SDK polls automatically when a sync request returns 5xx. See Timeout recovery below.

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.

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

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.
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.
Three options are declared but not live yet. proxyUrl, geolocation, and actionChain are typed on PerceiveOptions but currently rejected server-side with 422. They are reserved, not usable.

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

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.

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.

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.

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.

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.

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

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

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.

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.

Snapshot diffs contain untrusted page content. Entries in snapshot.changes come straight from the watched page. Escape them before rendering into HTML or writing them into a log viewer.

PDF options#

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

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.

Error handling#

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

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 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#

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.

Never hardcode the API key. 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 dashboard.

Result shape#

Every conversion method returns a ConversionResult:

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.

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.

npx enconvert-sdk upgrade
npx enconvert-sdk upgrade --dry-run
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#


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.