---
seo_title: C# .NET File Conversion SDK: NuGet API Client | EnConvert
meta_desc: Official EnConvert C# and .NET SDK targeting .NET 8+. Async methods for file conversion plus the V2 namespace to perceive, discover, distill, ingest, and watch.
keywords: c# / .net file conversion sdk, convert files c# / .net, url to pdf c# / .net, c# / .net web scraping api, docx to pdf c# / .net, enconvert csharp sdk, nuget file conversion api client, heic to webp c#, url to markdown dotnet, website screenshot api dotnet, rag ingestion pipeline c#
---

# C# and .NET File Conversion SDK

`Enconvert` is the official C# and .NET client for the EnConvert API, published on NuGet and targeting .NET 8 or newer. It converts files from C#: URL to PDF, DOCX to PDF, HEIC to WebP, JSON to YAML, any document to Markdown, and whole websites into a single ZIP. The same client carries a `V2` namespace for web scraping and web intelligence, so one key covers perceive, discover, lookup, distill, ingest, and watch. Every call is async and cancellable, every response is a typed record, and the package pulls in no third-party dependencies.

<div class="alert alert-info">
<strong>NuGet:</strong> <code>Enconvert</code> · <strong>Source:</strong> <a href="https://github.com/conversionapi/csharp-sdk">conversionapi/csharp-sdk</a> · <strong>Runtime:</strong> .NET 8+ · <strong>Dependencies:</strong> none beyond the BCL
</div>

---

## Install

```bash
dotnet add package Enconvert
```

The assembly targets `net8.0` with nullable reference types enabled and is built on `System.Net.Http` and `System.Text.Json` only.

## Quick start

```csharp
using Enconvert;

using var client = new EnconvertClient(Environment.GetEnvironmentVariable("ENCONVERT_API_KEY")!);

// Convert a live page to PDF and stream it to disk.
var pdf = await client.ConvertUrlToPdfAsync("https://example.com", new UrlToPdfOptions { SaveTo = "page.pdf" });
Console.WriteLine(pdf.PresignedUrl);

// Read a page the way your agent should, with a quality score attached.
var op = await client.V2.PerceiveAsync("https://example.com", new PerceiveOptions { Outputs = new[] { "markdown", "structured" } });
Console.WriteLine($"{op.Outputs["markdown"].Url} scored {op.RenderQuality}");
```

`EnconvertClient` implements `IDisposable`, so declare it with `using` or register it as a singleton. It authenticates with a private API key, which means it belongs on the server: never ship the key inside a desktop, mobile, or Blazor WebAssembly build. Key setup is covered in [Authentication](/docs/authentication).

---

## What the client exposes

| Member | Endpoint | Returns |
|--------|----------|---------|
| `ConvertUrlToPdfAsync(url, opts?)` | `POST /v1/convert/url-to-pdf` | `ConversionResult` |
| `ConvertUrlToScreenshotAsync(url, opts?)` | `POST /v1/convert/url-to-screenshot` | `ConversionResult` |
| `ConvertUrlToMarkdownAsync(url, opts?)` | `POST /v1/convert/url-to-markdown` | `ConversionResult` |
| `ConvertImageAsync(file, opts)` | `POST /v1/convert/{from}-to-{to}` | `ConversionResult` |
| `ConvertDocumentAsync(file, opts?)` | `POST /v1/convert/{from}-to-{to}` | `ConversionResult` |
| `ConvertToMarkdownAsync(file, opts?)` | `POST /v1/convert/anything-to-markdown` | `ConversionResult` |
| `ConvertToPdfAsync(file, opts?)` | `POST /v1/convert/anything-to-pdf` | `ConversionResult` |
| `ConvertWebsiteToPdfAsync(url, opts?)` | `POST /v1/convert/website-to-pdf` | `BatchSubmission` |
| `ConvertWebsiteToScreenshotAsync(url, opts?)` | `POST /v1/convert/website-to-screenshot` | `BatchSubmission` |
| `GetJobStatusAsync(jobId)` | `GET /v1/convert/status/{jobId}` | `JobStatus` |
| `GetBatchStatusAsync(batchId)` | `GET /v1/convert/batch/{batchId}` | `BatchStatus` |
| `WaitForBatchAsync(batchId, opts?)` | `GET /v1/convert/batch/{batchId}` (polled) | `BatchStatus` |
| `V2` | the `/v2/*` surface | `EnconvertV2` |

`client.V2` groups 23 methods across six capabilities:

| Capability | Methods | Reference |
|------------|---------|-----------|
| Perceive | `PerceiveAsync`, `GetPerceiveOperationAsync`, `PerceiveBatchAsync`, `GetPerceiveBatchAsync`, `PerceiveDirectAsync`, `DownloadPerceiveArtifactAsync` | [/docs/v2-perceive](/docs/v2-perceive) |
| Discover | `DiscoverAsync` | [/docs/v2-discover](/docs/v2-discover) |
| Lookup | `LookupAsync` | [/docs/v2-lookup](/docs/v2-lookup) |
| Distill | `DistillAsync` | [/docs/v2-distill](/docs/v2-distill) |
| Ingest | `IngestAsync`, `IngestFilesAsync`, `ListIngestJobsAsync`, `GetIngestJobAsync`, `CancelIngestJobAsync`, `RetryIngestWebhookAsync`, `GetWebhookSecretAsync`, `RotateWebhookSecretAsync` | [/docs/v2-ingest](/docs/v2-ingest) |
| Watch | `CreateWatcherAsync`, `ListWatchersAsync`, `GetWatcherAsync`, `GetWatcherSnapshotsAsync`, `UpdateWatcherAsync`, `DeleteWatcherAsync` | [/docs/v2-watch](/docs/v2-watch) |

Every method takes an optional trailing `CancellationToken`. Options are records with `init` properties, so build them with an object initializer and reuse them with `with`. Other language clients are in the [SDK index](/docs/sdks); the raw REST surface is in [Endpoints overview](/docs/endpoints-overview).

---

## File conversion

### ConvertUrlToPdfAsync

Render any reachable URL to PDF.

```csharp
var result = await client.ConvertUrlToPdfAsync("https://example.com", new UrlToPdfOptions
{
    SinglePage = false,
    ViewportWidth = 1440,
    PdfOptions = new PdfOptions { PageSize = "A4", Orientation = "landscape" },
    SaveTo = "report.pdf",
});

Console.WriteLine($"{result.Filename} ({result.FileSize} bytes)");
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `SaveTo` | `string?` | -- | Local path to stream the PDF to. Missing parent directories are created. |
| `SinglePage` | `bool?` | `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`, `ViewportHeight` | `int?` | `1920`, `1080` | Browser viewport in pixels. |
| `LoadMedia`, `EnableScroll` | `bool?` | `true` | Wait for images and video before capture; scroll top to bottom so lazy loaders fire. |
| `OutputFilename` | `string?` | auto | Override the generated filename. |
| `Auth` | `HttpBasicAuth?` | -- | HTTP Basic credentials for a page behind a login. |
| `Cookies`, `Headers` | `IReadOnlyList<BrowserCookie>?`, `IReadOnlyDictionary<string, string>?` | -- | Up to 50 cookies injected before rendering, and up to 20 extra request headers. Hop-by-hop headers are rejected. |

<div class="alert alert-warning">
<strong>Do not combine <code>Auth</code> with your own <code>Authorization</code> header.</strong> The API rejects that conflict rather than guessing which credential wins. Pick one.
</div>

### ConvertUrlToScreenshotAsync

Capture a PNG of any URL.

```csharp
await client.ConvertUrlToScreenshotAsync("https://example.com", new UrlToScreenshotOptions
{
    ViewportWidth = 1440,
    SaveTo = "screenshot.png",
});
```

`UrlToScreenshotOptions` accepts the same viewport, media, scroll, filename, and access options as `ConvertUrlToPdfAsync`, minus `SinglePage` and `PdfOptions`.

### ConvertUrlToMarkdownAsync

Extract clean GitHub-Flavored Markdown from a URL. Navigation, footers, ads, and scripts are stripped, the main article body is kept, and YAML frontmatter (title, description, url, links, images) is prepended.

```csharp
await client.ConvertUrlToMarkdownAsync("https://example.com/article", new UrlToMarkdownOptions { SaveTo = "article.md" });
```

`UrlToMarkdownOptions` shares the same render and access options. For agent-facing reads that also need a quality score and structured extraction, use [Perceive](#perceive) instead.

### ConvertImageAsync

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

```csharp
// From a path.
await client.ConvertImageAsync("photo.heic", new ConvertImageOptions { OutputFormat = "webp", SaveTo = "photo.webp" });

// From bytes, with an explicit filename so the input format can be detected.
var bytes = await File.ReadAllBytesAsync("scan.pdf");
await client.ConvertImageAsync(new FileInput(bytes, "scan.pdf"), new ConvertImageOptions
{
    OutputFormat = "jpeg",
    SaveTo = "scan.jpeg",
});
```

Three overloads accept a path `string`, a raw `byte[]`, or a `FileInput` record. The input format comes from the filename extension, so prefer `FileInput` when you hold bytes: the bare `byte[]` overload sends `upload.bin`, which only works on the auto-detecting endpoints.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `OutputFormat` | `string` | Yes | Target format. `jpg`, `yml`, `htm`, and `md` are normalized to their canonical names. |
| `SaveTo` | `string?` | -- | Local path to stream the result to. |
| `OutputFilename` | `string?` | -- | Override the generated filename. |

Unsupported pairs throw `ArgumentException` before any HTTP request is made, so a typo costs nothing.

### ConvertDocumentAsync

Convert documents and data formats. `OutputFormat` defaults to `"pdf"`.

```csharp
// docx to pdf
await client.ConvertDocumentAsync("report.docx", new ConvertDocumentOptions { SaveTo = "report.pdf" });

// json to yaml
await client.ConvertDocumentAsync("data.json", new ConvertDocumentOptions { OutputFormat = "yaml", SaveTo = "data.yaml" });

// markdown to pdf with page geometry
await client.ConvertDocumentAsync("README.md", new ConvertDocumentOptions
{
    PdfOptions = new PdfOptions { PageSize = "A4", Margins = new PdfMargins { Top = 20, Bottom = 20 } },
    SaveTo = "readme.pdf",
});
```

**Supported inputs:** `.doc`, `.docx`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.html`, `.htm`, `.odt`, `.ods`, `.odp`, `.ots`, `.pages`, `.numbers`, `.md`, `.markdown`, `.csv`, `.json`, `.xml`, `.yaml`, `.yml`, `.toml`. EPUB has no dedicated document pair, so send `.epub` through [`ConvertToPdfAsync`](#converttopdfasync) or [`ConvertToMarkdownAsync`](#converttomarkdownasync).

| 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 meaningful when the output is PDF. |

Like `ConvertImageAsync`, this method has path, `byte[]`, and `FileInput` overloads.

### ConvertToMarkdownAsync

Send almost any document to the auto-detecting Markdown endpoint: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy and ODF office formats. Images are not supported here.

```csharp
await client.ConvertToMarkdownAsync("handbook.docx", new ConvertToMarkdownOptions { SaveTo = "handbook.md" });
```

The format is detected server side, so there is no client-side extension check and no PDF options on this endpoint. `ConvertToMarkdownOptions` carries `SaveTo` and `OutputFilename` only. The output is a single heading-aware `.md` file, which makes it a solid first stage in a RAG pipeline: a semantic chunker can split on the document's own heading hierarchy instead of arbitrary character counts.

### ConvertToPdfAsync

The other auto-detecting endpoint: office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, or an existing PDF passed through for normalization.

```csharp
// Slides to PDF.
await client.ConvertToPdfAsync("slides.pptx", new ConvertToPdfOptions { SaveTo = "slides.pdf" });

// PDF passthrough, converted to grayscale.
await client.ConvertToPdfAsync("scan.pdf", new ConvertToPdfOptions
{
    PdfOptions = new PdfOptions { Grayscale = true },
    SaveTo = "gray.pdf",
});
```

<div class="alert alert-warning">
<strong>Only <code>Grayscale</code> is honored here.</strong> The anything-to-pdf endpoint ignores the rest of <code>PdfOptions</code>. When you need page size, orientation, margins, or a header and footer, route HTML and Markdown through <code>ConvertDocumentAsync</code>, or render the page with <code>ConvertUrlToPdfAsync</code>.
</div>

`ConvertToPdfOptions` carries `SaveTo`, `OutputFilename`, and `PdfOptions`, and has the same three input overloads as the methods above.

### Whole-site batches

`ConvertWebsiteToPdfAsync` and `ConvertWebsiteToScreenshotAsync` discover every page of a site, convert each one in the background, and bundle the results into a single ZIP. Both are async only and require a private API key.

```csharp
var batch = await client.ConvertWebsiteToPdfAsync("https://example.com", new WebsiteToPdfOptions
{
    CrawlMode = "sitemap",
    ExcludePatterns = new[] { "/blog/tag/" },
    NotificationEmail = "ops@example.com",
});
Console.WriteLine($"{batch.BatchId}: {batch.UrlCount} pages via {batch.DiscoveryMethod}");

// Block until the ZIP is ready, then save it.
var status = await client.WaitForBatchAsync(batch.BatchId, new WaitForBatchOptions { SaveTo = "site.zip" });
Console.WriteLine($"{status.Completed} of {status.Total} converted, {status.Failed} failed");
```

To poll on your own schedule instead, call `GetBatchStatusAsync(batchId)` and read `ZipDownloadUrl` once `Status` leaves `"processing"`.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `CrawlMode` | `string?` | `"auto"` | `"auto"`, `"sitemap"` (sitemap.xml only), or `"full"` (sitemap plus a breadth-first crawl). |
| `IncludePatterns`, `ExcludePatterns` | `IReadOnlyList<string>?` | -- | Only crawl, or skip, URLs matching these patterns. Full crawl mode. |
| `NotificationEmail`, `CallbackUrl` | `string?` | project owner, -- | Address notified when the batch finishes, and a webhook POSTed on completion. |
| `SinglePage`, `PdfOptions` | `bool?`, `PdfOptions?` | -- | PDF batches only. Viewport, media, scroll, and access options are shared with the single-URL methods. |

`WaitForBatchOptions` takes `IntervalMs` (default `5_000`), `TimeoutMs` (default `1_800_000`, so 30 minutes), and `SaveTo`. Blowing the deadline throws `ApiException` with status `504`.

### GetJobStatusAsync

Poll a single conversion job by id.

```csharp
var status = await client.GetJobStatusAsync("job_abc123");

if (status.Status == "success") Console.WriteLine(status.PresignedUrl);
else if (status.Status == "failed") Console.Error.WriteLine(status.Error);
```

<div class="alert alert-info">
<strong>You rarely need this directly.</strong> The client already polls job status for you when a synchronous request dies at the proxy. See <a href="#timeout-recovery">Timeout recovery</a>.
</div>

### Supported conversion pairs

The SDK mirrors the gateway's converter map and implements 43 typed `{input}-to-{output}` pairs.

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

The static `Formats` class exposes the same table, so you can validate before building a UI or a job queue:

```csharp
Formats.ValidOutputsFor("json");        // ["csv", "toml", "xml", "yaml"]
Formats.ValidOutputsFor("pdf");         // ["jpeg"]
Formats.ImplementedConversions;         // the full set of 43 endpoint names
Formats.NormalizeOutputFormat(".JPG");  // "jpeg"
```

Anything outside this table goes through `ConvertToPdfAsync` or `ConvertToMarkdownAsync`, which detect the format server side. The full parameter reference lives in [Parameters and options](/docs/parameters-options).

---

## Web intelligence (V2)

Every V2 read carries `RenderQuality`, a score from 0.0 to 1.0 that says how honestly the page rendered. A bot challenge, cookie or login wall, HTTP error, or empty single-page-app shell comes back with a low score, a populated `Deductions` map naming which checks fired, and a `Warnings` list, while the content is still returned. That is the point: a bad read is flagged rather than quietly entering your agent's context. The same score appears on perceive results, distill items, auto-perceived lookup hits, and watcher snapshots. Concepts are covered in the [V2 overview](/docs/v2-overview). V2 endpoints require a private API key; public keys are rejected.

### Perceive

Render one URL into agent-ready artifacts.

```csharp
var op = await client.V2.PerceiveAsync("https://example.com", new PerceiveOptions
{
    Outputs = new[] { "markdown", "screenshot", "structured" },
    Extract = new[] { "tables", "metadata" },
    OnlyMainContent = true,
});

Console.WriteLine(op.RenderQuality);            // 0.0 to 1.0
Console.WriteLine(op.StatusCode);               // upstream HTTP status
Console.WriteLine(op.Outputs["markdown"].Url);  // signed for 15 minutes
Console.WriteLine(op.Structured);               // JsonObject, shape is yours

foreach (var (check, penalty) in op.Deductions) Console.WriteLine($"deduction {check}: {penalty}");
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Outputs` | `IReadOnlyList<string>?` | `["markdown", "structured"]` | Any of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, `images`, `structured`. |
| `Extract` | `IReadOnlyList<string>?` | -- | Heuristic targets: `tables`, `prices`, `contacts`, `metadata`, `main_content`, `headings`, `structured_data`, `technologies`, `all`. |
| `Schema` | `JsonObject?` | -- | JSON schema driving LLM-tier structured extraction. |
| `WaitFor`, `WaitTimeoutMs` | `string?`, `int?` | --, `30000` | A CSS selector (optionally `css:...`) or `js:<expr>` to await before capture, bounded by 0 to 60000 ms. |
| `JsCode` | `string?` | -- | JavaScript run after navigation, max 20000 characters. |
| `Viewport` | `PerceiveViewport?` | 1920 x 1080 | `Width` 320 to 3840, `Height` 240 to 2160. |
| `Headers`, `Cookies`, `Auth` | see [above](#converturltopdfasync) | -- | Extra headers, injected cookies, HTTP Basic credentials. |
| `CacheMode` | `string?` | `"enabled"` | `"enabled"` reuses a 1 hour cache, `"bypass"` skips it, `"refresh"` re-renders. |
| `PdfOptions` | `PdfOptions?` | -- | Only meaningful when `Outputs` contains `pdf`. |
| `BlockResources` | `IReadOnlyList<string>?` | -- | Resource types to skip: `image`, `media`, `font`, `stylesheet`, `script`. |
| `RespectRobots`, `Mobile` | `bool?` | -- | Honor robots.txt; emulate a mobile device. |
| `OnlyMainContent` | `bool?` | `true` | Strip nav, header, footer, and cookie banners from the markdown artifact and the `main_content` extract. |
| `DirectDownload` | `bool?` | -- | Stream artifact bytes instead of the JSON envelope. Prefer `PerceiveDirectAsync`. |

`ProxyUrl`, `Geolocation`, and `ActionChain` exist on `PerceiveOptions` but are not yet available server side and currently come back as `422`.

Re-sign artifact URLs later, batch up to 1000 URLs, or stream bytes without the signed-URL round trip:

```csharp
// Artifact URLs are re-signed on every fetch of the operation.
var again = await client.V2.GetPerceiveOperationAsync(op.OperationId);

// Batches: small ones run inline, larger ones return "queued" so you poll.
var batch = await client.V2.PerceiveBatchAsync(
    new[] { "https://a.example.com", "https://b.example.com" },
    new PerceiveBatchOptions { Outputs = new[] { "markdown" }, OutputMode = "zip" });

var done = await client.V2.GetPerceiveBatchAsync(batch.JobId);
Console.WriteLine($"{done.Completed}/{done.Total} done, {done.Failed} failed, zip {done.Zip?.Url}");

// Direct download: exactly one artifact-producing output required.
var direct = await client.V2.PerceiveDirectAsync("https://example.com", new PerceiveOptions { Outputs = new[] { "pdf" } });
await File.WriteAllBytesAsync(direct.Filename ?? "page.pdf", direct.Content);

// Re-download a stored artifact from an earlier operation.
var artifact = await client.V2.DownloadPerceiveArtifactAsync(op.OperationId, "markdown");
```

`PerceiveDirectAsync` throws `ArgumentException` locally unless exactly one of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, or `images` is requested, since `structured` is inline JSON rather than a stored file. Both direct methods return a `PerceiveDirectResult` carrying `Content`, `ContentType`, `Filename`, `OperationId`, `ObjectKey`, `CacheHit`, `RenderQuality`, `SourceStatusCode`, `ContentHash`, and `WarningsCount`. `DownloadPerceiveArtifactAsync` takes the output name only when the operation produced more than one artifact, and returns `410` once the artifact passes its retention window.

### Discover

Enumerate a site's URLs with no browser rendering, which makes it far cheaper than a crawl.

```csharp
var found = await client.V2.DiscoverAsync("https://example.com", new DiscoverOptions
{
    Mode = "hybrid",
    MaxUrls = 200,
    ExcludePatterns = new[] { "/tag/" },
});

Console.WriteLine($"{found.Total} urls, truncated: {found.Truncated}");
foreach (var url in found.Urls) Console.WriteLine(url);
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Mode` | `string?` | `"hybrid"` | `"sitemap"`, `"crawl"`, or `"hybrid"` (sitemap plus HTTP crawl). |
| `MaxUrls` | `int?` | `100` | 1 to 1000. |
| `MaxDepth` | `int?` | `2` | Crawl depth, 1 to 5. |
| `IncludePatterns`, `ExcludePatterns` | `IReadOnlyList<string>?` | -- | Regex allowlist and denylist, max 50 entries each. The denylist is applied second. |
| `SameDomainOnly`, `RespectRobots` | `bool?` | `true`, -- | Stay on the seed domain; honor robots.txt during discovery. |

`DiscoverResult` reports `Total`, `Urls`, `PagesCrawled`, `Truncated`, `RobotsRespected`, a per-source count map in `Sources`, and `Warnings`.

### Lookup

Run a categorized web search, and optionally render the top results in the same call.

```csharp
var search = await client.V2.LookupAsync("best static site generators", new LookupOptions
{
    Category = "web",
    NumResults = 10,
    TimeFilter = "month",
    PerceiveTop = 3,
});

foreach (var hit in search.Results)
{
    Console.WriteLine($"{hit.Position}. {hit.Title} {hit.Url}");
    if (hit.Perceive is { } page)
        Console.WriteLine($"   quality {page.RenderQuality}, markdown {page.Outputs["markdown"].Url}");
}
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Category` | `string?` | `"web"` | `"web"`, `"news"`, `"images"`, `"scholar"`, `"patents"`, `"maps"`. |
| `Country`, `Locale` | `string?` | -- | Google `gl` country code (`"us"`, `"in"`) and `hl` interface language (`"en"`). |
| `TimeFilter` | `string?` | -- | `"hour"`, `"day"`, `"week"`, `"month"`, `"year"`. |
| `NumResults`, `Page` | `int?` | `10`, `1` | 1 to 100 results; page 1 to 10. |
| `Location`, `Autocorrect` | `string?`, `bool?` | --, `true` | Free-text location such as `"Austin, Texas"`; let the provider fix obvious typos. |
| `PerceiveTop` | `int?` | `0` | 0 to 10. Auto-perceive the top N result URLs with a full browser render. |

`LookupResult` also carries `AnswerBox`, `KnowledgeGraph`, `PerceiveOperationIds`, and `Warnings`.

### Distill

Schema-driven structured extraction. A free CSS pass runs first when you supply one, and only the fields it misses escalate to the LLM tier.

```csharp
using System.Text.Json.Nodes;

var extraction = await client.V2.DistillAsync(new DistillOptions
{
    Urls = new[] { "https://example.com/pricing" },
    Schema = new JsonObject { ["plans"] = "list of plan names with monthly prices" },
    CssSchema = new CssSchema
    {
        BaseSelector = ".plan-card",
        Fields = new[]
        {
            new CssField { Name = "name", Type = "text", Selector = "h3" },
            new CssField { Name = "price", Type = "text", Selector = ".price" },
        },
    },
});

var first = extraction.Results[0];
Console.WriteLine($"{first.Data} via {first.ExtractionTier}");
Console.WriteLine($"css fields {first.FieldsFromCss}, llm fields {first.FieldsFromLlm}");

// Or discover a site first, then distill every page it finds.
await client.V2.DistillAsync(new DistillOptions
{
    DiscoverFrom = new DistillDiscoverFrom { Url = "https://example.com", Mode = "sitemap", MaxPages = 10 },
    Schema = new JsonObject { ["title"] = "page title" },
});
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Schema` | `JsonObject` | required | A JSON-Schema object or a flat `{field: description}` map. The response `Data` matches this shape. |
| `Urls` | `IReadOnlyList<string>?` | -- | Explicit URLs, max 50. Exactly one of `Urls` or `DiscoverFrom`. |
| `DiscoverFrom` | `DistillDiscoverFrom?` | -- | `Url`, `Mode` (`"sitemap"`, `"crawl"`, `"hybrid"`), and `MaxPages` 1 to 50, default 10. |
| `CssSchema` | `CssSchema?` | -- | `BaseSelector` plus `Fields`, with optional `Name` and `TargetField`. |
| `WaitFor`, `WaitTimeoutMs` | `string?`, `int?` | --, `30000` | Wait condition before extraction. |
| `Headers`, `Cookies`, `RespectRobots` | -- | -- | Same shapes as the render options above. |

`CssField.Type` is one of `text`, `attribute`, `html`, `regex`, `nested`, `list`, or `nested_list`, nesting up to five levels deep. Passing both `Urls` and `DiscoverFrom`, or neither, throws `ArgumentException` before any request goes out.

### Ingest

Turn a whole site, or a stack of uploaded documents, into chunked JSONL that a vector store can read. Ingest is always asynchronous.

```csharp
// From a site.
var job = await client.V2.IngestAsync(new IngestOptions
{
    Mode = "sitemap",
    Url = "https://docs.example.com",
    MaxPages = 100,
    Chunk = new IngestChunkOptions { MaxWords = 512 },
    WebhookUrl = "https://my.app/hooks/enconvert",
});

// From uploaded files: PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, legacy and ODF office.
var fileJob = await client.V2.IngestFilesAsync(
    new[] { new FileInput(await File.ReadAllBytesAsync("handbook.pdf"), "handbook.pdf") },
    new IngestFilesOptions { Chunk = new IngestChunkOptions { MaxWords = 512 } });

// Poll until the JSONL is ready.
var status = await client.V2.GetIngestJobAsync(job.JobId);
Console.WriteLine($"{status.Status}: {status.PagesProcessed}/{status.PagesDiscovered} pages, {status.TotalChunks} chunks");
if (status.Status == "completed") Console.WriteLine(status.OutputUrl);

var recent = await client.V2.ListIngestJobsAsync(new V2ListOptions { Limit = 20 });
await client.V2.CancelIngestJobAsync(job.JobId); // idempotent
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `Mode` | `string?` | `"urls"` | `"urls"`, `"sitemap"`, or `"crawl"`. |
| `Url` | `string?` | -- | Seed URL. Required for `sitemap` and `crawl`, rejected for `urls`. |
| `Urls` | `IReadOnlyList<string>?` | -- | Explicit URLs, max 1000. Required for `urls`, rejected otherwise. |
| `MaxPages`, `MaxDepth`, `SameDomainOnly` | `int?`, `int?`, `bool?` | `50`, `2`, `true` | Discovery cap 1 to 1000 for `sitemap` and `crawl`, crawl depth 1 to 5, stay on the seed domain. |
| `IncludePatterns`, `ExcludePatterns` | `IReadOnlyList<string>?` | -- | Regex allowlist and denylist. |
| `RespectRobots`, `WaitFor`, `WaitTimeoutMs` | -- | -- | Per-page render controls. |
| `Chunk` | `IngestChunkOptions?` | -- | `MaxWords` 32 to 4000, default 512. `SentenceOverlap` 0 to 10, default 1. |
| `WebhookUrl` | `string?` | -- | Completion webhook, HMAC signed. |

Mode rules are enforced client side, so a `urls` job that also sets `Url` throws `ArgumentException` immediately. A job moves through `queued`, `discovering`, `processing`, and then `completed`, `failed`, or `canceled`. Webhook delivery is verifiable end to end:

```csharp
var secret = await client.V2.GetWebhookSecretAsync();
Console.WriteLine($"{secret.SignatureHeader} using {secret.SignatureScheme}, replay tolerance {secret.ReplayToleranceSeconds}s");

await client.V2.RotateWebhookSecretAsync();  // old signatures stop verifying
var retry = await client.V2.RetryIngestWebhookAsync(job.JobId);
Console.WriteLine($"delivered: {retry.Delivered} after {retry.Attempts} attempts");
```

### Watch

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

```csharp
var watcher = await client.V2.CreateWatcherAsync("https://example.com/pricing", new WatchCreateOptions
{
    FrequencyMinutes = 60,
    DiffMode = "auto",
    WebhookUrl = "https://my.app/hooks/changes",
});

var page = await client.V2.ListWatchersAsync(new V2ListOptions { Limit = 20 });
var one = await client.V2.GetWatcherAsync(watcher.WatcherId);
var history = await client.V2.GetWatcherSnapshotsAsync(watcher.WatcherId, new SnapshotListOptions { Limit = 10 });
foreach (var snap in history.Snapshots)
    Console.WriteLine($"{snap.CheckedAt} changed: {snap.HasChanges} similarity: {snap.Similarity}");

await client.V2.UpdateWatcherAsync(watcher.WatcherId, new WatcherUpdate { Status = "paused" });
await client.V2.UpdateWatcherAsync(watcher.WatcherId, new WatcherUpdate { WebhookUrl = "" }); // clears it
await client.V2.DeleteWatcherAsync(watcher.WatcherId); // soft delete, idempotent
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `FrequencyMinutes` | `int?` | `60` | 60 to 43200. The hourly floor is hard. |
| `DiffMode` | `string?` | `"auto"` | `"auto"`, `"text"`, `"structured"`, `"tables"`, `"metadata"`. |
| `TrackFields` | `JsonObject?` | -- | Field or selector subset for the diff engine. |
| `WebhookUrl` | `string?` | -- | Change webhook, HMAC signed. Set to `""` in an update to clear it. |
| `NotifyEmail` | `bool?` | `true` | Email the project owner on changes. |

`WatcherUpdate` also accepts `Status` (`"active"` or `"paused"`) and throws `ArgumentException` if you pass an update with no fields set.

<div class="alert alert-warning">
<strong>Snapshot diffs contain untrusted page content.</strong> <code>WatcherSnapshot.Changes</code> is raw JSON lifted from the watched page. Escape it before rendering it in a dashboard or an email.
</div>

---

## PDF options

`PdfOptions` is shared by `ConvertUrlToPdfAsync`, `ConvertDocumentAsync`, `ConvertToPdfAsync` (grayscale only), and `PerceiveOptions` when `pdf` is among the outputs.

```csharp
await client.ConvertUrlToPdfAsync("https://internal.example.com/report", new UrlToPdfOptions
{
    PdfOptions = new PdfOptions
    {
        PageSize = "A4",
        Orientation = "landscape",
        Margins = new PdfMargins { Top = 10, Bottom = 10, Left = 15, Right = 15 },
        Scale = 0.9,
        Header = new PdfHeaderFooter { Content = "Quarterly Report", Height = 15 },
        Footer = new PdfHeaderFooter { Content = "Confidential", Height = 12 },
    },
    Auth = new HttpBasicAuth { Username = "user", Password = "pass" },
    SaveTo = "report.pdf",
});
```

| Field | Type | Description |
|-------|------|-------------|
| `PageSize` | `string?` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and friends. |
| `PageWidth`, `PageHeight` | `double?` | Custom dimensions. Set together, they override `PageSize`. |
| `Orientation` | `string?` | `"portrait"` or `"landscape"`. |
| `Margins` | `PdfMargins?` | `Top`, `Bottom`, `Left`, `Right`, all optional. |
| `Scale` | `double?` | Render scale, for example `0.9` for 90 percent. |
| `Grayscale` | `bool?` | Post-process the PDF to grayscale. |
| `Header`, `Footer` | `PdfHeaderFooter?` | `Content` up to 2000 characters, plus `Height`. |

## Error handling

Every failure surfaces as a typed exception, so `catch` blocks read from most specific to least.

```csharp
using Enconvert;

try
{
    await client.V2.PerceiveAsync("https://example.com");
}
catch (AuthenticationException) { Console.Error.WriteLine("Invalid or missing API key."); }
catch (QuotaException) { Console.Error.WriteLine("Request rejected with 402."); }
catch (RateLimitException) { Console.Error.WriteLine("Too many requests, back off and retry."); }
catch (ApiException e) { Console.Error.WriteLine($"API error [{e.StatusCode}]: {e.Message}"); }
```

| Class | Raised on | Status code |
|-------|-----------|-------------|
| `AuthenticationException` | Invalid, missing, or revoked key | `401`, `403` (reported as `401`) |
| `QuotaException` | Raised on HTTP 402 | `402` |
| `RateLimitException` | Rate limit exceeded | `429` |
| `ApiException` | Any other 4xx or 5xx | the actual code |
| `EnconvertException` | Base class for all of the above | -- |

The hierarchy runs `EnconvertException` to `ApiException` (which carries `StatusCode`) to the three specific classes, so a single `catch (EnconvertException)` catches everything the SDK raises. Messages are lifted from the response body's `detail` or `error` field when present. Validation the SDK performs locally, such as an unsupported conversion pair or a malformed distill request, throws `ArgumentException` instead and never reaches the network. Response codes are catalogued in [Error codes](/docs/error-codes).

## Timeout recovery

Long URL renders and large document conversions can outlive a 60 to 120 second reverse-proxy timeout even when the conversion itself succeeds. The client absorbs that for you:

1. Before each single-file or single-URL conversion, the SDK generates a job id and sends it with the request.
2. If that request comes back 5xx, the SDK switches to polling `GET /v1/convert/status/{jobId}` every 3 seconds instead of failing.
3. A job recorded as `success` returns the normal `ConversionResult`. A job recorded as `failed` throws `ApiException` with status `500` and the server's error message.
4. The polling deadline is 5 minutes, after which the SDK throws `ApiException(504, "Conversion timed out")`.

This covers `ConvertUrlToPdfAsync`, `ConvertUrlToScreenshotAsync`, `ConvertUrlToMarkdownAsync`, and all four file-upload methods. Website batch submissions deliberately opt out, because a failed submission has no job row to poll and must surface immediately. When a response omits `job_id`, the SDK backfills the id it generated, so `ConversionResult.JobId` is always usable with `GetJobStatusAsync`.

## Configuration

```csharp
using var client = new EnconvertClient(
    apiKey: Environment.GetEnvironmentVariable("ENCONVERT_API_KEY")!,
    baseUrl: null,       // defaults to https://api.enconvert.com
    timeoutMs: 300_000);
```

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `apiKey` | `string` | required | Private API key. An empty value throws `ArgumentException`. |
| `baseUrl` | `string?` | `https://api.enconvert.com` | Override the API base URL. Trailing slashes are stripped. |
| `timeoutMs` | `int` | `300_000` | Request timeout in milliseconds, 5 minutes by default. |

The key travels in an `X-API-Key` header on every request. Artifact downloads go straight to signed storage URLs on a second `HttpClient` that sends no key and applies no timeout, so a large ZIP can stream for as long as it needs. Dispose the client once at the end of your process, or register it as a singleton rather than constructing one per request.

<div class="alert alert-warning">
<strong>Never hardcode the key.</strong> Read it from an environment variable, user secrets, or your secret manager. Anyone holding your private key can run conversions against your project.
</div>

## Result shape

Every conversion method returns a `ConversionResult`:

```csharp
public sealed record ConversionResult
{
    public required string PresignedUrl { get; init; }  // signed download URL
    public required string ObjectKey { get; init; }     // storage object key
    public required string Filename { get; init; }      // server-side filename
    public int? FileSize { get; init; }                 // bytes
    public double? ConversionTimeSeconds { get; init; }
    public string? JobId { get; init; }                 // usable with GetJobStatusAsync
}
```

Async work returns its own records: `JobStatus` (`Status`, `PresignedUrl`, `ObjectKey`, `Error`), `BatchSubmission` (`BatchId`, `Status`, `UrlCount`, `TotalDiscovered`, `DiscoveryMethod`, `OutputFormat`), and `BatchStatus` (aggregate counts, `OutputMode`, `ZipDownloadUrl`, and per-URL `Items`).

V2 artifacts arrive as `V2OutputArtifact` values keyed by output name, each with `Url` (signed for 15 minutes), `ObjectKey`, `SizeBytes`, `ContentType`, and `ExpiresIn` (900 seconds by default). Signed URLs expire, so for permanent access download the bytes (pass `SaveTo`, use `PerceiveDirectAsync`, or fetch the URL yourself) and store them in your own bucket. Calling `GetPerceiveOperationAsync` again re-signs the artifact URLs of an operation still inside its retention window.

## Source and issues

- **NuGet:** `Enconvert`
- **GitHub:** [conversionapi/csharp-sdk](https://github.com/conversionapi/csharp-sdk)
- **License:** MIT
- **Other languages:** [SDK index](/docs/sdks) · **API keys:** [dashboard](/dashboard) · [pricing](/pricing)

---

## Frequently asked questions

### How do I convert files in C# with a NuGet package?

Run `dotnet add package Enconvert`, create a client with your key (`new EnconvertClient(apiKey)`), and call an async method such as `ConvertUrlToPdfAsync`, `ConvertImageAsync`, or `ConvertDocumentAsync`. Pass `SaveTo` on the options record to stream the output straight to a local path, or read `result.PresignedUrl` to download it yourself.

### How do I convert a URL to PDF in .NET?

Call `await client.ConvertUrlToPdfAsync("https://example.com", new UrlToPdfOptions { SaveTo = "page.pdf" })`. Set `SinglePage = false` to paginate, and pass a `PdfOptions` record for page size, orientation, margins, scale, grayscale, header, and footer. The viewport defaults to 1920 x 1080.

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

Call `ConvertDocumentAsync("report.docx", new ConvertDocumentOptions { SaveTo = "report.pdf" })`. PDF is the default output format, so `OutputFormat` is optional here. The same method handles XLSX, PPTX, ODF, Pages, Numbers, HTML, Markdown, CSV, JSON, XML, YAML, and TOML input.

### How do I scrape a web page with the C# SDK?

Use the V2 namespace: `await client.V2.PerceiveAsync(url, new PerceiveOptions { Outputs = new[] { "markdown", "structured" } })`. You get Markdown, cleaned or raw HTML, screenshots, PDF, links, images, and structured extraction, each as a signed artifact, plus a `RenderQuality` score for the read. To enumerate a site's URLs first without rendering anything, call `DiscoverAsync`.

### What does render quality mean and why is it on every read?

`RenderQuality` is a 0.0 to 1.0 honesty score attached to every V2 read. A low score means the page did not render cleanly: a bot challenge, a cookie or login wall, an HTTP error, or an empty single-page-app shell. The content still comes back, along with a `Deductions` map naming which checks fired and a `Warnings` list, so your agent can reject a bad read instead of treating it as fact.

### Does the SDK handle conversions that outlive the proxy timeout?

Yes. It sends a generated job id with each single-file or single-URL conversion, and if the request returns 5xx it polls `GET /v1/convert/status/{jobId}` every 3 seconds for up to 5 minutes. Success returns the normal result, a recorded failure throws `ApiException`, and hitting the deadline throws `ApiException(504, "Conversion timed out")`. Website batch submissions are excluded on purpose.

### Can I use this SDK from Blazor WebAssembly or a mobile app?

No. The client authenticates with a private API key that must never ship in code a user can read. Run it from ASP.NET Core, a worker service, an Azure Function, or any other server-side .NET 8 host, and have your front end call your own endpoint instead.

### Which image conversions are supported?

Every pair among `jpeg`, `png`, `svg`, `heic`, and `webp`, which is 20 combinations, plus `pdf` to `jpeg` rasterization. The input format comes from the filename extension, and unsupported pairs throw `ArgumentException` before any network call. Check the table yourself with `Formats.ValidOutputsFor("heic")`.

### How do I turn a documentation site into RAG-ready chunks?

Call `client.V2.IngestAsync(new IngestOptions { Mode = "sitemap", Url = "https://docs.example.com", MaxPages = 100 })`, then poll `GetIngestJobAsync` until `Status` is `"completed"` and read `OutputUrl` for the JSONL. For local documents, `IngestFilesAsync` runs uploads through the same chunker. Tune `Chunk.MaxWords` and `Chunk.SentenceOverlap` to match your embedding model.

### How long are the download URLs valid?

V2 artifact URLs are signed for 15 minutes (`ExpiresIn` is 900 seconds) and are re-signed every time you fetch the operation with `GetPerceiveOperationAsync`. Conversion results return a presigned URL too. Either way, if you need the file to outlive the signature, download it and store it in your own bucket.
