---
seo_title: Ruby File Conversion SDK: RubyGems API Client | EnConvert
meta_desc: Official EnConvert Ruby SDK for Ruby 3.0+. Install the enconvert gem to convert files and to perceive, discover, distill, ingest, and watch web pages from Ruby.
keywords: ruby file conversion sdk, convert files ruby, url to pdf ruby, ruby web scraping api, docx to pdf ruby, enconvert ruby sdk, rubygems file conversion api client, heic to webp ruby, html to pdf ruby gem, web page to markdown ruby, structured data extraction ruby, ruby site crawler gem
---

# Ruby File Conversion SDK

`enconvert` is the official Ruby gem for the EnConvert API. It converts URLs, images, and documents across 43 implemented conversion endpoints, and it exposes a second namespace, `client.v2`, that reads live web pages into agent-ready Markdown, JSON, screenshots, and PDFs. Every V2 read carries a `render_quality` score, so a blocked page or an empty SPA shell never passes as real content. The gem targets Ruby 3.0+ and ships with zero runtime dependencies, built on `net/http`, `json`, and `securerandom` from the standard library. Responses come back as Structs with snake_case readers.

<div class="alert alert-info">
<strong>RubyGems:</strong> <code>enconvert</code> · <strong>Source:</strong> <a href="https://github.com/conversionapi/ruby-sdk">conversionapi/ruby-sdk</a> · <strong>Ruby:</strong> 3.0.0 or newer · <strong>Runtime dependencies:</strong> none
</div>

---

## Install

```bash
gem install enconvert
```

Or add it to your `Gemfile`:

```ruby
gem "enconvert"
```

```bash
bundle install
```

---

## Quick start

```ruby
require "enconvert"

client = Enconvert::Client.new(api_key: ENV.fetch("ENCONVERT_API_KEY"))

# Convert a live page to PDF and stream it to disk.
result = client.convert_url_to_pdf("https://example.com", save_to: "page.pdf")
puts result.presigned_url

# Read the same page the way an agent should, with a quality score attached.
op = client.v2.perceive("https://example.com", outputs: %w[markdown structured])
puts op.outputs["markdown"].url, op.render_quality  # e.g. 0.93
```

The gem is server-side only. Your private API key must stay on the server, so read it from an environment variable or a secret manager and never ship it to a browser or a mobile binary.

---

## What the client exposes

`Enconvert::Client` carries the file conversion surface directly, and the whole web intelligence surface hangs off `client.v2`.

| Group | Methods | Returns |
|-------|---------|---------|
| Single URL | `convert_url_to_pdf`, `convert_url_to_screenshot`, `convert_url_to_markdown` | `ConversionResult` |
| Uploaded file | `convert_image`, `convert_document`, `convert_to_markdown`, `convert_to_pdf` | `ConversionResult` |
| Whole site batch | `convert_website_to_pdf`, `convert_website_to_screenshot` | `BatchSubmission` |
| Status and polling | `get_job_status`, `get_batch_status`, `wait_for_batch` | `JobStatus`, `BatchStatus` |
| Web intelligence | `client.v2.*`, 23 methods across six capabilities | V2 Structs |
| Format helpers | `Enconvert.valid_outputs_for`, `Enconvert::IMPLEMENTED_CONVERSIONS` | `Array`, `Set` |

Every response type is a `Struct` created with `keyword_init: true`, so you read fields as ordinary methods: `result.presigned_url`, `op.render_quality`, `job.total_chunks`.

---

## File conversion

### convert_url_to_pdf

Render any public URL to PDF.

```ruby
result = client.convert_url_to_pdf(
  "https://example.com",
  single_page: false,
  pdf_options: { page_size: "A4", orientation: "landscape" },
  viewport_width: 1440,
  save_to: "report.pdf"
)

puts result.filename, result.file_size
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `save_to` | `String` | `nil` | Local path to download the PDF to. Parent directories are created for you. |
| `single_page` | `Boolean` | `true` | `true` produces one continuous page. `false` paginates using `pdf_options[:page_size]`. |
| `pdf_options` | `Hash` | `nil` | Page geometry. See [PDF options](#pdf-options). |
| `viewport_width` | `Integer` | `1920` | Browser viewport width in pixels. |
| `viewport_height` | `Integer` | `1080` | Browser viewport height in pixels. |
| `load_media` | `Boolean` | `true` | Wait for images and video before capture. |
| `enable_scroll` | `Boolean` | `true` | Scroll top to bottom so lazy loaders fire. |
| `output_filename` | `String` | `nil` | Override the generated filename. |
| `auth` | `Hash` | `nil` | HTTP basic credentials, for example `{ username: "user", password: "pass" }`. |
| `cookies`, `headers` | `Array`, `Hash` | `nil` | Cookies to set and extra request headers. All three browser-access fields are passed through unchanged. |

<div class="alert alert-warning">
<strong>Do not combine <code>auth</code> with an <code>Authorization</code> header.</strong> The API rejects the conflict, so pick one or the other.
</div>

### convert_url_to_screenshot

Capture a full-page PNG of any URL.

```ruby
client.convert_url_to_screenshot("https://example.com", viewport_width: 1440, save_to: "shot.png")
```

Accepts the same viewport, media, scroll, filename, and browser-access options as `convert_url_to_pdf`. It does not take `single_page` or `pdf_options`.

### convert_url_to_markdown

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 with the title, description, url, links, and images.

```ruby
client.convert_url_to_markdown("https://example.com/article", save_to: "article.md")
```

Useful for feeding a RAG pipeline, importing third-party content into a CMS, or building a training corpus.

### convert_image

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

```ruby
# From a path on disk.
client.convert_image("photo.heic", output_format: "webp", save_to: "photo.webp")

# From bytes you already hold.
bytes = File.binread("photo.heic")
client.convert_image({ data: bytes, filename: "photo.heic" }, output_format: "webp", save_to: "photo.webp")

# Rasterize the first page of a PDF.
client.convert_image("invoice.pdf", output_format: "jpeg", save_to: "invoice.jpg")
```

The input format is resolved from the filename extension, so a raw `IO` without a filename cannot be used here. Pass a path or a `{ data:, filename: }` Hash instead.

| Option | Type | Required | Description |
|--------|------|----------|-------------|
| `output_format` | `String` | Yes | Target format. Aliases `jpg`, `yml`, `htm`, and `md` are normalized for you. |
| `save_to` | `String` | No | Local path to download the result to. |
| `output_filename` | `String` | No | Override the generated filename. |

### convert_document

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

```ruby
# docx to pdf
client.convert_document("report.docx", save_to: "report.pdf")

# json to yaml
client.convert_document("data.json", output_format: "yaml", save_to: "data.yaml")

# markdown to pdf with custom page setup
client.convert_document(
  "README.md",
  output_format: "pdf",
  pdf_options: { page_size: "A4", margins: { top: 20, bottom: 20, left: 25, right: 25 } },
  save_to: "readme.pdf"
)
```

**Recognized input extensions:** `.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. Send `.epub` files through `convert_to_pdf` or `convert_to_markdown` instead.

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `output_format` | `String` | `"pdf"` | Target format. |
| `save_to` | `String` | `nil` | Local path to download the result to. |
| `output_filename` | `String` | `nil` | Override the generated filename. |
| `pdf_options` | `Hash` | `nil` | Page setup, honored when the output is PDF. |

### convert_to_markdown

Convert an uploaded file of almost any document format to clean Markdown. The format is detected on the server.

```ruby
client.convert_to_markdown("handbook.docx", save_to: "handbook.md")
```

**Accepted inputs:** PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy and ODF office formats. Images are not supported on this endpoint.

The output is a single heading-aware `.md` file, which makes it a good building block for RAG ingestion: a semantic chunker can split on the document's own heading hierarchy instead of arbitrary character counts. The only options are `save_to:` and `output_filename:`.

### convert_to_pdf

Convert an uploaded file of almost any format to PDF.

```ruby
# pptx to pdf
client.convert_to_pdf("slides.pptx", save_to: "slides.pdf")

# pdf passthrough, normalized to grayscale
client.convert_to_pdf("scan.pdf", pdf_options: { grayscale: true }, save_to: "gray.pdf")
```

**Accepted inputs:** office, ODF, Pages, Numbers, RTF, CSV, HTML, Markdown, plain text, raster images, SVG, EPUB, or an existing PDF that is passed through and normalized.

<div class="alert alert-warning">
<strong>Only <code>pdf_options[:grayscale]</code> is honored here.</strong> The other page geometry fields are ignored on this endpoint. When you need full control over page size, orientation, and margins, use <code>convert_document</code> or <code>convert_url_to_pdf</code>.
</div>

Options are `save_to:`, `output_filename:`, and `pdf_options:` (grayscale only).

### convert_website_to_pdf and convert_website_to_screenshot

Discover every page of a website, convert each one in the background, and collect the results as a single ZIP. Both methods are asynchronous and return a `BatchSubmission` immediately.

```ruby
batch = client.convert_website_to_pdf(
  "https://example.com",
  crawl_mode: "sitemap",
  exclude_patterns: ["/tag/"],
  notification_email: "me@example.com"
)

status = client.wait_for_batch(batch.batch_id, save_to: "site.zip")
puts "#{status.completed} of #{status.total} pages converted"
status.items.each { |item| puts "#{item.status}\t#{item.source_url}" }
```

`convert_website_to_screenshot` works identically and produces a ZIP of PNGs.

| Option | Type | Description |
|--------|------|-------------|
| `crawl_mode` | `String` | How pages are discovered, for example `"sitemap"`. |
| `include_patterns`, `exclude_patterns` | `Array` | Only crawl, or skip, URLs matching these patterns. |
| `notification_email` | `String` | Email address to notify when the batch finishes. |
| `callback_url` | `String` | Webhook called on completion. |
| `output_filename` | `String` | Override the generated ZIP filename. |
| `viewport_width`, `viewport_height`, `load_media`, `enable_scroll` | `Integer`, `Boolean` | Per-page render behavior. Sent only when you set them, otherwise the gateway applies its own defaults. |
| `auth`, `cookies`, `headers` | `Hash`, `Array`, `Hash` | Browser access for protected pages. |
| `single_page`, `pdf_options` | `Boolean`, `Hash` | PDF batches only. |

`wait_for_batch(batch_id, interval: 5, timeout: 1800, save_to: nil)` polls until the batch leaves `"processing"`, then returns the final `BatchStatus`. With `save_to` it also downloads the ZIP. It raises `Enconvert::APIError` with status `504` when the timeout is reached, and status `500` when a batch finishes without a ZIP to save.

### get_job_status

Poll a single conversion job by id.

```ruby
status = client.get_job_status("job_abc123")
case status.status
when "success" then puts status.presigned_url
when "failed"  then warn status.error
else                puts "still processing"
end
```

Returns a `JobStatus` with `status`, `presigned_url`, `object_key`, and `error`. You rarely need to call it yourself, because the SDK already polls it on your behalf. See [Timeout recovery](#timeout-recovery).

### Supported conversions

The gem carries the full table of implemented `{input}-to-{output}` endpoints and validates every pair locally, so an unsupported pair raises `Enconvert::Error` before a request leaves your process.

```ruby
Enconvert.valid_outputs_for("json")      # => ["csv", "toml", "xml", "yaml"]
Enconvert.valid_outputs_for("pdf")       # => ["jpeg"]
Enconvert::IMPLEMENTED_CONVERSIONS.size  # => 43
```

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

That is 43 endpoints: 13 structured text, 9 document, and 21 image conversions.

---

## Web intelligence (V2)

`client.v2` turns live web pages into agent-ready data: render, enumerate, search, extract, ingest, and monitor. Every render carries `render_quality`, a score from 0.0 to 1.0 attached to every read. A low score means the page did not render cleanly, typically a challenge page, a cookie wall, a login gate, or an empty JavaScript shell. The content is still returned, but flagged, along with a `warnings` array and a `deductions` Hash that says which checks fired. Read the score before you put the content anywhere, and a bad read will never quietly enter your agent's context.

All V2 endpoints require a private API key. See [V2 overview](/docs/v2-overview) for the endpoint-level reference.

### Perceive

Render one URL into the artifacts you ask for. `perceive` is synchronous and returns the completed operation with freshly signed artifact URLs.

```ruby
op = client.v2.perceive(
  "https://example.com",
  outputs: %w[markdown screenshot structured],
  extract: %w[tables metadata],
  only_main_content: true
)

puts op.operation_id, op.render_quality          # "per_...", then 0.0 to 1.0
puts op.outputs["markdown"].url                  # signed artifact URL
puts op.outputs["markdown"].expires_in           # seconds until it stops working
puts op.structured, op.warnings.inspect          # plain Ruby Hash, then Array of String
```

Re-sign the artifact URLs of an earlier operation at any time:

```ruby
again = client.v2.get_perceive_operation(op.operation_id)
```

Perceive up to 1000 URLs with one shared options block. Small batches run inline and come back completed. Larger ones come back with status `"queued"`, so poll them by `job_id`:

```ruby
batch = client.v2.perceive_batch(
  ["https://a.example.com", "https://b.example.com"],
  outputs: %w[markdown],
  output_mode: "zip"
)

done = client.v2.get_perceive_batch(batch.job_id)
puts "#{done.completed}/#{done.total} complete, #{done.failed} failed"
puts done.zip&.url
```

Skip the signed-URL round trip entirely with `perceive_direct`, where the HTTP response body is the artifact itself:

```ruby
direct = client.v2.perceive_direct("https://example.com", outputs: %w[markdown])
puts direct.filename, direct.content_type, direct.content.bytesize
File.binwrite("page.md", direct.content)

# Re-download a stored artifact from an earlier operation.
raw = client.v2.download_perceive_artifact(op.operation_id, output: "markdown")
```

`perceive_direct` needs exactly one artifact-producing output and raises `Enconvert::Error` locally otherwise. `structured` may ride along, because it stays inline server-side and is never streamed. `download_perceive_artifact` takes an optional `output:`, which you can omit when the operation produced exactly one artifact and must name when it produced more than one, and it returns `410` once an artifact is past its retention window.

| Option | Type | Description |
|--------|------|-------------|
| `outputs` | `Array` | Any of `markdown`, `html_cleaned`, `html_raw`, `screenshot`, `screenshot_full_page`, `pdf`, `links`, `images`, `structured`. Defaults server-side to `["markdown", "structured"]`. |
| `extract` | `Array` | Any of `tables`, `prices`, `contacts`, `metadata`, `main_content`, `headings`, `structured_data`, `technologies`, `all`. |
| `schema` | `Hash` | Field descriptions for structured extraction. |
| `wait_for`, `wait_timeout_ms` | `String`, `Integer` | CSS selector to wait for before capture, and the cap on that wait. |
| `js_code` | `String` | JavaScript to run in the page before capture. |
| `viewport`, `mobile` | `Hash`, `Boolean` | Viewport dimensions, and whether to render with a mobile profile. |
| `headers`, `cookies`, `auth` | `Hash`, `Array`, `Hash` | Browser access for protected pages. |
| `proxy_url` | `String` | Route the render through your own proxy. |
| `geolocation` | `Hash` | Spoofed geolocation for the browser context. |
| `action_chain` | `Array` | Scripted clicks, scrolls, and inputs before capture. |
| `cache_mode` | `String` | `enabled`, `bypass`, or `refresh`. |
| `pdf_options` | `Hash` | Page geometry when `pdf` is among the outputs. |
| `block_resources` | `Array` | Resource types to block, for example `image`, `font`, `script`. |
| `respect_robots` | `Boolean` | Honor `robots.txt`. |
| `only_main_content` | `Boolean` | Strip navigation and boilerplate from the Markdown output. |
| `direct_download` | `Boolean` | Accepted by `perceive` only. `perceive_batch` rejects it. |

Full parameter semantics live in [Perceive](/docs/v2-perceive) and [Parameters and options](/docs/parameters-options).

### Discover

Enumerate a site's URLs without rendering anything. No browser is involved, so it is fast and cheap.

```ruby
found = client.v2.discover(
  "https://example.com",
  mode: "hybrid",            # "sitemap", "crawl", or "hybrid"
  max_urls: 200,
  max_depth: 3,
  exclude_patterns: ["/tag/"],
  same_domain_only: true,
  respect_robots: true
)

puts found.total, found.urls.first(10)
puts found.truncated        # true when max_urls capped the result
puts found.sources.inspect  # where each URL came from
```

See [Discover](/docs/v2-discover) for mode-by-mode behavior.

### Lookup

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

```ruby
search = client.v2.lookup(
  "best static site generators",
  category: "web",           # web, news, images, scholar, patents, maps
  num_results: 10,
  time_filter: "month",      # hour, day, week, month, year
  perceive_top: 3
)

search.results.each do |hit|
  puts hit.position, hit.title, hit.url
  puts hit.perceive&.render_quality  # present for auto-perceived hits
end

puts search.answer_box.inspect, search.knowledge_graph.inspect
```

With `perceive_top: 3`, the first three result URLs are rendered and carry a full `PerceiveResult` inline on `hit.perceive`. Their operation ids are also collected in `search.perceive_operation_ids`. More in [Lookup](/docs/v2-lookup).

### Distill

Schema-driven structured extraction. Provide exactly one of `urls:` or `discover_from:`, and `schema:` is always required. The SDK raises `Enconvert::Error` locally if you get that wrong.

```ruby
extraction = client.v2.distill(
  urls: ["https://example.com/pricing"],
  schema: { plans: "list of plan names with monthly prices" },
  css_schema: {
    base_selector: ".plan-card",
    fields: [
      { name: "name",  type: "text", selector: "h3" },
      { name: "price", type: "text", selector: ".price" }
    ]
  }
)

item = extraction.results.first
puts item.data.inspect
puts item.extraction_tier   # "css", "llm", "mixed", or "none"
puts item.fields_from_css, item.fields_from_llm
```

The optional `css_schema` runs first and answers whatever it can from selectors alone. Only the fields it misses escalate to the language-model tier, which is why `fields_from_css` and `fields_from_llm` are reported separately.

Discover and distill in one call:

```ruby
client.v2.distill(
  discover_from: { url: "https://example.com", mode: "sitemap", max_pages: 10 },
  schema: { title: "page title", summary: "one-line summary" }
)
```

CSS field types are `text`, `attribute`, `html`, `regex`, `nested`, `list`, and `nested_list`. Nested field lists are serialized recursively. See [Distill](/docs/v2-distill).

### Ingest

Turn a whole site, or a set of uploaded documents, into chunked RAG-ready JSONL through one pipeline. Ingest is always asynchronous.

```ruby
job = client.v2.ingest(
  mode: "sitemap",           # urls, sitemap, crawl
  url: "https://docs.example.com",
  max_pages: 100,
  chunk: { max_words: 512, sentence_overlap: 1 },
  webhook_url: "https://my.app/hooks/enconvert"
)

status = client.v2.get_ingest_job(job.job_id)
puts status.status                                   # queued, discovering, processing, completed, failed, canceled
puts status.pages_processed, status.total_chunks
puts status.output_url if status.status == "completed"  # JSONL
```

Mode `"urls"` takes a `urls:` list and rejects `url:`. Every other mode takes a seed `url:` and rejects `urls:`. Both rules are enforced locally before the request is sent.

Upload files instead of crawling:

```ruby
file_job = client.v2.ingest_files(
  ["handbook.pdf", "notes.docx"],
  chunk: { max_words: 512, sentence_overlap: 1 }
)
```

`ingest_files` accepts PDF, DOCX, PPTX, XLSX, CSV, HTML, EPUB, TXT and MD, plus legacy and ODF office formats. Each entry can be a path, an IO-like object, or a `{ data:, filename: }` Hash.

Manage jobs and webhook delivery:

```ruby
list = client.v2.list_ingest_jobs(limit: 20)
list.jobs.each { |j| puts "#{j.job_id}\t#{j.status}\t#{j.total_chunks}" }
puts list.has_more

client.v2.cancel_ingest_job(job.job_id)   # idempotent

secret = client.v2.get_webhook_secret
puts secret.secret, secret.signature_header, secret.timestamp_header,
     secret.signature_scheme, secret.replay_tolerance_seconds

client.v2.rotate_webhook_secret            # old signatures stop verifying at once
client.v2.retry_ingest_webhook(job.job_id) # re-deliver a completed job's webhook
```

`retry_ingest_webhook` returns a `WebhookRetryResult` with `delivered`, `attempts`, `status_code`, and `detail`. It responds `409` when the job is not completed and `400` when the job has no webhook configured. See [Ingest](/docs/v2-ingest).

### Watch

Re-render a URL on a fixed cadence and get notified when it changes.

```ruby
watcher = client.v2.create_watcher(
  "https://example.com/pricing",
  frequency_minutes: 60,     # hourly floor
  diff_mode: "auto",         # auto, text, structured, tables, metadata
  webhook_url: "https://my.app/hooks/changes",
  notify_email: true
)

puts watcher.watcher_id, watcher.next_check_at

history = client.v2.get_watcher_snapshots(watcher.watcher_id, limit: 10)
history.snapshots.each do |snap|
  puts snap.checked_at, snap.has_changes, snap.similarity, snap.change_count, snap.changes.inspect
end

client.v2.list_watchers(limit: 20)
client.v2.get_watcher(watcher.watcher_id)
client.v2.update_watcher(watcher.watcher_id, status: "paused")
client.v2.update_watcher(watcher.watcher_id, webhook_url: "")  # clears the webhook
client.v2.delete_watcher(watcher.watcher_id)                   # soft delete, idempotent
```

`update_watcher` raises `Enconvert::Error` if you call it with no fields to update. `delete_watcher` returns the tombstoned watcher with status `"deleted"`, and a deleted watcher reads back as `404`. Full semantics in [Watch](/docs/v2-watch).

---

## PDF options

`pdf_options` is a plain Hash with symbol keys, accepted by `convert_url_to_pdf`, `convert_website_to_pdf`, `convert_document`, `convert_to_pdf` (grayscale only), and `client.v2.perceive` when `pdf` is among the outputs. Only the keys you set are sent on the wire.

```ruby
client.convert_url_to_pdf(
  "https://example.com",
  pdf_options: { page_size: "A4", orientation: "landscape", scale: 0.9,
                 margins: { top: 10, bottom: 10, left: 15, right: 15 } },
  save_to: "report.pdf"
)
```

| Key | Type | Description |
|-----|------|-------------|
| `page_size` | `String` | `"A4"`, `"A3"`, `"Letter"`, `"Legal"`, and similar. |
| `page_width` | `Number` | Explicit page width, as an alternative to `page_size`. |
| `page_height` | `Number` | Explicit page height. |
| `orientation` | `String` | `"portrait"` or `"landscape"`. |
| `margins` | `Hash` | `{ top:, bottom:, left:, right: }`, all optional. |
| `scale` | `Number` | Render scale, for example `0.9` for 90 percent. |
| `grayscale` | `Boolean` | Post-process the PDF to grayscale. |
| `header` | `Hash` | Header text per page region. |
| `footer` | `Hash` | Footer text per page region. |

---

## Error handling

Every failure is an `Enconvert::Error` or one of its subclasses, so `rescue` clauses can be as broad or as narrow as you want. Client-side validation failures, such as an unsupported conversion pair or a missing `schema`, raise the base `Enconvert::Error` before any HTTP request is made.

```ruby
begin
  client.v2.perceive("https://example.com")
rescue Enconvert::AuthenticationError
  warn "Invalid or missing API key"
rescue Enconvert::QuotaError
  warn "Request was rejected with 402"
rescue Enconvert::RateLimitError
  warn "Too many requests, back off and retry"
rescue Enconvert::APIError => e
  warn "API error [#{e.status_code}]: #{e.message}"
rescue Enconvert::Error => e
  warn "Client-side validation failed: #{e.message}"
end
```

| Class | Raised on | Status code |
|-------|-----------|-------------|
| `Enconvert::AuthenticationError` | Invalid, missing, or revoked API key | `401`, `403` |
| `Enconvert::QuotaError` | Raised on HTTP 402 | `402` |
| `Enconvert::RateLimitError` | Too many requests | `429` |
| `Enconvert::APIError` | Any other response of 400 or above | the actual code |
| `Enconvert::Error` | Base class, plus all local validation failures | none |

`APIError#status_code` gives you the HTTP status, and the message is pulled from the response body's `detail` or `error` field when the body is JSON. The full message map lives in [Error codes](/docs/error-codes).

---

## Timeout recovery

A long URL-to-PDF render or a large document conversion can outlive the reverse-proxy timeout even when the conversion itself succeeds on the server. The SDK recovers from that transparently, with no code on your side.

1. Before each conversion request the client generates a 32-character job id and sends it as `job_id` in the body or the multipart form.
2. If the request comes back with a status of 500 or above, the client switches to polling `GET /v1/convert/status/{job_id}` every 3 seconds. A `404` while the job row is still being written is ignored, and polling continues.
3. As soon as the job reads `success`, the result is returned. As soon as it reads `failed`, `Enconvert::APIError` is raised with status `500` and the server's error message.
4. The polling deadline is 300 seconds. Past that, the client raises `Enconvert::APIError` with status `504` and the message `Conversion timed out`.

The client-generated `job_id` is also merged into every successful response, so `result.job_id` is always populated and you can poll `get_job_status` yourself later if you want to.

<div class="alert alert-info">
<strong>Website batch submissions opt out.</strong> <code>convert_website_to_pdf</code> and <code>convert_website_to_screenshot</code> do not create a per-job row, so a 5xx there means the submission itself failed and is surfaced immediately rather than polled.
</div>

---

## Configuration

```ruby
client = Enconvert::Client.new(
  api_key: ENV.fetch("ENCONVERT_API_KEY"),
  timeout: 300,
  base_url: "https://api.enconvert.com"
)
```

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_key` | `String` | required | Your private API key. A `nil` or blank value raises `Enconvert::Error` at construction. |
| `timeout` | `Integer` | `300` | Open and read timeout in **seconds**, applied to every request. |
| `base_url` | `String` | `"https://api.enconvert.com"` | API base URL. Trailing slashes are stripped. Override it for a self-hosted gateway. |

Requests authenticate with the `X-API-Key` header. When you pass `save_to`, the download deliberately bypasses that header, because the presigned storage URL must not receive your API key.

<div class="alert alert-warning">
<strong>Never hardcode the API key.</strong> Read it from an environment variable, Rails credentials, or your secret manager. Anyone holding your private key can run conversions as you. Rotate it from the <a href="/dashboard">dashboard</a> if it leaks.
</div>

---

## Result shape

Every single-file and single-page conversion returns an `Enconvert::ConversionResult`:

```ruby
result = client.convert_document("report.docx", save_to: "report.pdf")

result.presigned_url            # signed URL for the converted output
result.object_key               # storage object key
result.filename                 # server-side filename
result.file_size                # bytes, or nil
result.conversion_time_seconds  # seconds, or nil
result.job_id                   # always populated by the client
```

The other V1 Structs are `JobStatus` (`status`, `presigned_url`, `object_key`, `error`), `BatchSubmission` (`batch_id`, `status`, `url_count`, `total_discovered`, `discovery_method`, `output_format`), `BatchStatus` (`batch_id`, `status`, `total`, `completed`, `failed`, `in_progress`, `output_mode`, `zip_download_url`, `items`), and `BatchItem` (`source_url`, `status`, `download_url`, `output_file_size`, `duration`).

On the V2 side, `PerceiveResult` is the one to know:

```ruby
op.operation_id                              # "per_..."
op.status                                    # queued, processing, completed, failed
op.url, op.url_final, op.content_hash
op.render_quality, op.cache_hit              # 0.0 to 1.0 (or nil), and whether it came from cache
op.outputs                                   # Hash of name => V2OutputArtifact
op.structured, op.extraction_tier            # Hash (or nil), and how it was extracted
op.tokens.input, op.tokens.output, op.cost_cents, op.duration_ms
op.error, op.warnings                        # String or nil, and Array of String
op.status_code                               # upstream HTTP status of the page
op.deductions, op.options_echo               # why render_quality dropped, and the options used
```

A `V2OutputArtifact` carries `url`, `object_key`, `size_bytes`, `content_type`, and `expires_in` (900 seconds by default), so signed artifact URLs are short-lived. Re-sign them with `get_perceive_operation`, or download the bytes with `download_perceive_artifact`. User-supplied payloads such as schemas, extracted data, tracked fields, and diff changes pass through untouched as plain Ruby Hashes.

Presigned URLs from V1 conversions are also temporary. Pass `save_to` when you want the bytes on disk, or copy them into your own bucket for permanent storage.

---

## Source and issues

- **RubyGems:** [enconvert](https://rubygems.org/gems/enconvert)
- **GitHub:** [conversionapi/ruby-sdk](https://github.com/conversionapi/ruby-sdk)
- **Ruby:** 3.0.0 or newer, no runtime dependencies
- **License:** MIT
- **Other languages:** [All SDKs](/docs/sdks)
- **API reference:** [Endpoints overview](/docs/endpoints-overview), [Authentication](/docs/authentication)

---

## Frequently asked questions

### How do I convert files in Ruby with a gem?

Run `gem install enconvert`, build a client with `Enconvert::Client.new(api_key: ENV.fetch("ENCONVERT_API_KEY"))`, and call a method such as `convert_document`, `convert_image`, or `convert_url_to_pdf`. Pass `save_to:` and the SDK downloads the result to that path for you, creating parent directories as needed.

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

`client.convert_document("report.docx", save_to: "report.pdf")`. The output format defaults to `"pdf"`, so you only need `output_format:` when you want something else. The same call works for `.doc`, `.xls`, `.xlsx`, `.ppt`, `.pptx`, `.odt`, `.ods`, `.odp`, `.ots`, `.pages`, and `.numbers`.

### How do I convert a URL to PDF in Ruby?

`client.convert_url_to_pdf("https://example.com", save_to: "page.pdf")`. Set `single_page: false` to paginate instead of producing one continuous page, and pass `pdf_options:` for page size, orientation, margins, and scale. For a page behind HTTP basic auth, add `auth: { username: ..., password: ... }`.

### How do I convert HEIC to WebP in Ruby?

`client.convert_image("photo.heic", output_format: "webp", save_to: "photo.webp")`. The input format comes from the filename extension, and the gem converts freely between `jpeg`, `png`, `svg`, `heic`, and `webp`. It also rasterizes a PDF to JPEG with `output_format: "jpeg"`.

### How do I scrape a web page into Markdown from Ruby?

Two options. `client.convert_url_to_markdown(url, save_to: "article.md")` gives you clean GitHub-Flavored Markdown with YAML frontmatter. `client.v2.perceive(url, outputs: %w[markdown])` gives you the same content plus a `render_quality` score, warnings, and optional structured extraction, which is what you want when an agent is going to read the result.

### What does render_quality mean and why should I check it?

It is a score from 0.0 to 1.0 attached to every V2 read, reflecting how cleanly the page actually rendered. A challenge page, a cookie wall, a login gate, an HTTP error page, or an empty JavaScript shell all score low. The content is still returned so you can inspect it, with `warnings` and `deductions` explaining the score. Gate on it before you store the text or feed it to a model.

### Does the Ruby SDK retry when a long conversion times out?

Yes. Every conversion request carries a client-generated `job_id`, and if the request fails with a status of 500 or above the SDK polls `GET /v1/convert/status/{job_id}` every 3 seconds for up to 300 seconds. It returns the result once the job succeeds and raises `Enconvert::APIError` with status `504` if the deadline passes. Whole-site batch submissions are excluded, because they have no per-job row.

### What happens if I request a conversion pair the API does not implement?

The gem raises `Enconvert::Error` locally, before any network call, and the message lists the valid outputs for that input. You can check the same table yourself with `Enconvert.valid_outputs_for("json")`, which returns `["csv", "toml", "xml", "yaml"]`.

### Can I use the enconvert gem inside Rails or a background job?

Yes, and a background job is the right place for it. The gem is plain `net/http` with no runtime dependencies and no global state, so an `Enconvert::Client` instance is safe to build per job or memoize per process. Long conversions block the calling thread up to the configured `timeout`, which defaults to 300 seconds, so keep them out of a web request cycle.

### Which Ruby versions does the SDK support?

Ruby 3.0.0 and newer, as declared by `required_ruby_version` in the gemspec. There are no runtime gem dependencies, so it installs into any Bundler group without pulling a dependency tree behind it.

### How do I verify an ingest webhook signature?

Call `client.v2.get_webhook_secret`, which creates the project's signing secret on first use and returns it alongside `signature_header`, `timestamp_header`, `signature_scheme`, and `replay_tolerance_seconds`. Compute the HMAC over the delivered body and compare it against the signature header. Use `rotate_webhook_secret` to invalidate the old secret, and `retry_ingest_webhook(job_id)` to re-deliver a completed job's notification.
