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.
@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.
.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? }.
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. |
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.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:
- Before each request, the SDK generates a UUID and sends it as
job_idin the request body. - If the original request returns 5xx, the SDK silently switches to polling
GET /v1/convert/status/{job_id}every 3 seconds. - As soon as the job is recorded as
success, the SDK returns the result. As soon as it is recorded asfailed, the SDK throwsAPIError. - 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.
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#
- npm: @enconvert/node-sdk
- GitHub: enconvert/node-sdk
- License: MIT
- Other languages: All SDKs
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.