Changelog

New features, improvements, and fixes — everything we ship, as we ship it.

Subscribe via RSS
Clear Pick a year to narrow by month; a date range overrides both pickers.
Improved Security

V2 Competitive Hardening #

Summary

A round of V2 improvements closing the biggest gaps against dedicated web-data APIs: durable batch perception that survives restarts, high-value search enrichment (concurrent multi-format rendering + a synthesized cited answer + structured extraction across results), prompt-only structured extraction, more capable site discovery (gzipped sitemaps, WAF-resilient fetches, a far higher crawl ceiling), and a security-hardening pass (DNS-rebind protection on the render path, an opt-in local URL threat policy, and CSP respected by default).


/v2/perceive/batch — durable, resumable batches + cancel

Batches are now restart-safe. Previously the batch envelope (the shared render options and output mode) lived only in memory, so a server restart mid-batch failed the whole batch and you had to resubmit. The envelope is now persisted, so an interrupted batch resumes automatically and re-renders only the URLs that had not finished — already-completed URLs keep their artifacts.

  • New: DELETE /v2/perceive/batch/{job_id} — cancel a running batch. The worker stops between URLs; already-completed URLs remain available. Idempotent; a terminal batch is unchanged. Project-scoped 404.
  • New batch status: canceled (joins queued|processing|completed|partial|failed).
  • The batch status endpoint now reports the true output_mode and the canceled state from the durable batch record.

No request-shape change for submitting a batch — existing calls keep working.


/v2/lookup — high-value enrichment (enrich)

A new optional enrich object turns "search, then read the top results" from a slow, markdown-only, one-at-a-time pass into a fast, configurable one — and can synthesize a grounded answer across the results.

enrich fields: - outputs[] — which perceive outputs to produce per enriched result (e.g. markdown, html_cleaned, links, screenshot, structured). Defaults to ["markdown"]. - concurrency (1–5, default 3) — how many result URLs to enrich in parallel. Markdown/HTML renders use the no-browser path and truly parallelize; screenshot/PDF renders serialize on the shared browser. - schema (aliased) — run schema-driven structured extraction against each enriched result; the data appears under each result's perceive.structured. - synthesize_answer (default false) — synthesize one cited, grounded answer to the query across the enriched results, returned on the response as answer (with answer_sources). Uses the perceived page content when available, otherwise result snippets. - answer_prompt — an optional question to answer instead of the raw query.

New response fields: answer, answer_sources[]. When enrich is omitted, perceive_top keeps its previous behavior (markdown-only, sequential). Structured extraction and answer synthesis require an LLM-enabled plan; on plans without it they degrade to a warning.


/v2/distill — prompt-only extraction

You can now distill without writing a schema. Supply a natural-language prompt and the extraction schema is synthesized from it (single model), then the normal two-pass CSS→LLM engine runs.

  • schema is now optional; provide either schema or prompt (schema wins if both are given).
  • New response field synthesized_schema echoes the fields that were derived from the prompt.
  • Prompt-only mode requires an LLM-enabled plan; without one it returns a clear warning.

/v2/discover — gzipped sitemaps, WAF resilience, higher ceiling

  • Gzipped sitemaps (sitemap.xml.gz and any Content-Type: application/gzip sitemap) are now decompressed and parsed instead of silently failing.
  • WAF resilience: sitemap and robots fetches now send realistic browser headers, so sites that 403/503 a bare client are far more likely to return their sitemap.
  • Higher crawl ceiling: crawl-mode HTTP fetches are no longer capped at 50. The ceiling now equals your max_urls (up to 1000), so large sites are enumerated far more completely. Operators can lower it with DISCOVER_CRAWL_MAX_PAGES if needed. This also lifts the same ceiling for /v2/ingest crawl-mode jobs.

Security hardening

  • DNS-rebind / redirect protection on the render path: every request the browser makes (the initial navigation, redirects, and subresources) is re-validated against the SSRF rules at request time — not only the seed URL before navigation — so a hostname that rebinds to a private address, or a redirect to an internal one, is blocked before the browser connects. The no-browser TLS engine additionally pins each connection to the validated IP.
  • Local URL threat policy (opt-in, no external dependency): a configurable denylist of domains, TLDs, and host patterns enforced on every fetched URL, with an audit trail of blocks. Configure via THREAT_BLOCKED_DOMAINS, THREAT_BLOCKED_TLDS, or a THREAT_POLICY_FILE. Empty by default (blocks nothing).
  • Content-Security-Policy respected by default for the raw browser-context path (previously bypassed). Callers that need CSP bypass for scripted DOM interaction opt in explicitly.

These changes require no API changes on your side; blocked URLs return 400.

New Security Improved

Hardened URL rendering: SSRF screening, ad/media blocking, selector waits, and clearer errors #

Summary

The URL rendering endpoints — url-to-pdf, url-to-screenshot, url-to-markdown, website-to-pdf, and website-to-screenshot — get new rendering controls, stronger URL-safety screening, and clearer error responses. Existing requests are unaffected: every new option defaults to its previous behavior.

New rendering options

  • wait_for_selector (+ wait_for_selector_timeout, default 10000 ms, max 60000): wait for a CSS selector before capture so single-page apps that hydrate after load are captured fully. Returns 422 if the element never appears.
  • block_ads: abort requests to known ad and tracker domains so they never load, render, or slow the capture.
  • block_media: abort image and audio/video requests entirely for a faster, lighter render (distinct from load_media, which only controls waiting).

Stronger URL safety (SSRF)

Every target URL is now screened before the browser fetches it. Requests to private, loopback, link-local, reserved, or cloud-metadata addresses — plus embedded-credential URLs, non-http(s) schemes, and non-standard IP notations — are rejected with 400. This applies to single URLs, every URL in a batch, and pages discovered by the website-to-* crawl endpoints.

Credential scoping

An Authorization header (for example a Bearer token) passed via headers, or credentials from the auth object, are now sent only to the target origin — never to the third-party ad, analytics, or CDN subresources a page requests.

Clearer error responses

URL conversions now tell a target/input problem apart from an engine problem, using a structured {error, code, detail} body:

  • 415 unsupported_content_type — a non-HTML response such as JSON sent to url-to-pdf/url-to-screenshot (use url-to-markdown for JSON).
  • 422 selector_not_found — a wait_for_selector that never appeared.
  • 502 upstream_unreachable / empty_render — the target could not be reached, or rendered nothing.
  • 504 upstream_timeout — the target took too long.
  • 503 with a Retry-After header when the render pool is momentarily at capacity.

A 500 now specifically means our engine faulted, rather than a catch-all.

Non-HTML URLs

url-to-markdown now returns application/json and text/plain bodies verbatim inside a fenced code block instead of running article extraction over the browser's viewer.

Fixed Security

LibreOffice conversion timeouts now return 504 instead of 500 or 400 #

A LibreOffice/unoserver conversion that exceeds its 120-second subprocess budget now returns 504. The same timeout previously surfaced as 500 on the PDF path and 400 on the markdown path.

The 120-second budget itself is unchanged — only the status, the message, and the analytics fields changed. Nothing got faster and nothing new times out.

Endpoints moving 500504:

Endpoint Before After
POST /v1/convert/doc-to-pdf 500 504
POST /v1/convert/excel-to-pdf 500 504
POST /v1/convert/ppt-to-pdf 500 504
POST /v1/convert/odt-to-pdf 500 504
POST /v1/convert/ods-to-pdf 500 504
POST /v1/convert/odp-to-pdf 500 504
POST /v1/convert/ots-to-pdf 500 504
POST /v1/convert/pages-to-pdf 500 504
POST /v1/convert/numbers-to-pdf 500 504
POST /v1/convert/anything-to-pdf — for .doc .docx .xls .xlsx .ppt .pptx .odt .ods .odp .ots .pages .numbers .rtf .csv input 500 504

Endpoint moving 400504:

Endpoint Before After
POST /v1/convert/anything-to-markdown — for .doc .ppt .xls .odt .ods .odp .rtf input only 400 504

The two paths' format sets differ. .docx, .pptx, .xlsx and .csv go through LibreOffice on the PDF path and can time out at 504 there — on the markdown path they use pure-Python readers, never reach unoserver, and are unaffected, as are .pdf and .epub.

Response: {"detail": "Document conversion timed out after 120 seconds."}detail is a plain string, identical text from both paths. Unlike the adjacent 500, it carries no Conversion failed: prefix.

Breaking — retry semantics on POST /v1/convert/anything-to-markdown. Moving 400504 reclassifies these responses from client error to server error. A client that treats 4xx as terminal and 5xx as retryable will now retry a timed-out legacy-office markdown conversion instead of failing it outright. That is the intent — the document was valid and the request was not the caller's fault — but it is a change in your request volume against these endpoints and in what your own users see on failure. If you retry 5xx with a backoff policy, check it before deploying against large .doc/.xls/.odt inputs.

Breaking — message text. anything-to-markdown previously returned "Document conversion timed out.". The text is now "Document conversion timed out after 120 seconds." A client string-matching the old message breaks.

Fixed — the previous 500 leaked server internals. On the PDF path the subprocess timeout propagated uncaught and its string form was interpolated into the response body, so callers received the full unoconvert argv and both server-side temporary file paths — e.g. Conversion failed: Command '['unoconvert', '--convert-to', 'pdf', '/tmp/tmpXXXX.docx', '/tmp/tmpXXXX.pdf']' timed out after 120 seconds. The 504 body carries the fixed message and nothing else.

Analytics. Failure events for these timeouts now carry error_type="conversion_timeout" and error_code=504. They previously reported error_type="TimeoutExpired" / error_code=500 on the PDF path and error_type="value_error" / error_code=400 on the markdown path. Dashboards or alerts filtering on those old values will stop seeing these events.

Scope. v1 conversion endpoints only. POST /v2/ingest/files reaches the same LibreOffice path for legacy-office input but does not use the v1 error mapping — a timeout there is still recorded as a failed page on the job, not returned as a 504. Unsupported input formats continue to return 400.

Security Fixed

Filename and title normalization in JSONL deliverable metadata #

Uploaded filenames flowed verbatim into the metadata.source_url and metadata.title fields of the JSONL deliverable produced by POST /v2/ingest/files. The same path also covered the URL side of ingest, where metadata.title is the <title> lifted from an arbitrary remote page.

This was not a JSON injection. JSON encoding already neutralizes quotes, newlines and C0 controls inside the file — the deliverable always parsed. The problems were what a downstream consumer does with the decoded value:

  • Line framing. U+2028 and U+2029 survive non-ASCII JSON encoding and are emitted raw, and Python's str.splitlines() treats them as line breaks — a label containing one splits a record for any line-oriented reader.
  • Display spoofing. Bidi override characters let invoice<U+202E>fdp.exe render as invoice.pdf.
  • Path prefixes. ../../../../etc/passwd.csv reached the label intact, carrying a traversal prefix into the deliverable.
  • Index bloat. The label had no length cap and is duplicated into every chunk record of the page.

Both labels are now normalized at emit time. Stripped: bidi formatting controls (U+200E, U+200F, U+202AU+202E, U+2066U+2069), line/paragraph separators (U+2028, U+2029), and all Unicode category Cc — which covers C0 controls, DEL, C1 controls and U+0085 — except \t. Whitespace runs collapse to a single space; the value is NFC-normalized first, so a decomposed name like café.pdf now emits as the composed café.pdf.

Kept deliberately. Spaces and punctuation survive — Q3 Report (final) v2.pdf is emitted unchanged. U+200C (ZWNJ) and U+200D (ZWJ) are not stripped: the filter targets Cc and the specific bidi/separator codepoints, not all of category Cf, because ZWNJ/ZWJ are required in legitimate Devanagari/Indic and emoji filenames. Clean URLs pass through untouched.

Caps. metadata.title is capped at 512 characters. metadata.source_url is capped at 2048 — generous enough that a real URL is never truncated, since truncating source_url destroys URL-page provenance. For file pages the label is built at the 512 cap, so a file page's source_url is effectively 512-capped.

Path stripping is file-only. An uploaded filename is reduced to its basename across both / and \ before normalization, which removes traversal prefixes without losing legitimate information. URL-sourced source_url is not basenamed — a URL keeps its full path.

Two other file-label changes. A file page whose filename is empty — or which normalizes to empty, such as a bidi-only name or a trailing-separator path like a/b/ — previously emitted metadata.title: "" and fell back to the internal storage object key for metadata.source_url, leaking an internal key into the deliverable. Both fields now emit upload in that case.

New 400 conditions on POST /v2/ingest/files:

  • Filename longer than 255 characters → 400 {"detail": "Filename exceeds the 255-character limit."}. The filename is deliberately not echoed back.
  • Filename containing a null byte → 400 {"detail": "Filename contains an invalid null byte."}. Previously an opaque 500 — Postgres TEXT cannot hold NUL and the insert failed deep in the write path.

Both checks run before extension parsing. The pre-existing content-type and unsupported-extension 400s are unchanged and still echo the filename.

Scope and limitations — read these:

  • A file page's source_url is a display label, not an identifier. It is not unique across files, and basenaming means two uploads sharing a basename — including two different paths — now emit an identical source_url. Do not key deduplication or provenance on it for file pages.
  • Normalization is emit-time only. The uploaded filename is still stored raw in the ingest page record for audit. Any consumer of that value — admin UI, support tooling, log readers — is unprotected and must escape for itself.
  • Chunk content is not sanitized. Only metadata.source_url and metadata.title pass through the normalizer. The document body is emitted as extracted.
  • Filename gates are POST /v2/ingest/files only. POST /v1/convert/anything-to-markdown and the other v1 convert endpoints gain no length or null-byte gate.
  • The storage object key is derived by a separate, more aggressive filename sanitizer and is unaffected by this change.
Fixed

Chunk ids for uploaded files on POST /v2/ingest/files no longer collide for same-named files #

Chunk ids in the assembled JSONL from POST /v2/ingest/files were derived from the uploaded filename. Two files sharing a filename in one job produced byte-identical ids across both pages' chunks — a downstream upsert keyed on id silently overwrote one document with the other, and no error was raised. Ids are now seeded from the page's storage object key, which is unique per file per job.

The id format is unchanged: 12 lowercase hex characters, a hyphen, a 4-digit zero-padded chunk index. Only the seed changed.

Breaking: every file-page chunk id changes. The seed moves from the filename (report.pdf) to the storage object key (prod/files/42/v2-ingest-uploads/ing_abc_1_report.pdf) — a different input to the same hash, so a completely different id. There is no overlap with the old ids and no migration path. Consequences:

  • Re-ingesting a file corpus yields new ids. An upsert keyed on id will insert duplicates rather than replace. Delete the old vectors first, or re-index.
  • The object key embeds the job id, so the same file uploaded in a later job gets different ids. File-page ids are stable within one job, not across jobs.
  • A file-page id is not reproducible from the deliverable. The seed never appears in the JSONL (metadata.source_url carries the filename label), and the source object is deleted at assembly.
  • A file-mode job that is in flight across the deploy assembles a mixed-id JSONL — pages completed before the deploy keep filename-seeded ids, pages completed after get key-seeded ids. Per-page JSONL is concatenated verbatim at assembly; nothing rewrites it. Drain in-flight file-mode jobs before deploying.

URL-mode ids are byte-identical to before. For URL pages the seed and the previous derivation are the same value, and the seed is deliberately hashed raw. A re-ingested URL corpus produces the same ids and existing vector-store rows upsert in place — no re-index. This covers ids only: the metadata-label sanitization shipping alongside it can still change metadata.title on URL pages, since that title is lifted from the remote page's HTML.

Determinism is preserved for both modes: the seed is a stored column, so a resumed or re-run page within a job re-derives the same id.

Fixed

POST /v2/ingest/files orphaned staged uploads on rejection and cancel #

POST /v2/ingest/files staged uploads one at a time, interleaved with per-file validation — file N was written to object storage before file N+1 was gated. A gate failure on any file after the first rejected the whole request, but the files already staged stayed in storage: the request never created a job row, so nothing referenced those objects and nothing removed them. Separately, a job canceled before assembly returned without deleting its staged sources — only the assembled-successfully path cleaned up.

Staging is now two passes. Every file in the batch is validated first; only then is any object written. A rejected batch stages nothing, so there is no orphan window to clean up. Peak memory is unchanged — one file's bytes at a time in both passes.

Every staged object is registered for a timed cleanup sweep at stage time, with a TTL of 24 hours. This is a backstop, not the prompt path: it covers a worker crash and any terminal path that never reaches the eager delete. Registration is idempotent and never fails a submit. If staging itself faults partway (a storage or database error), the partial batch is discarded on a best-effort basis immediately rather than waiting on the sweep; a discard failure is logged and never masks the underlying error.

Source cleanup now runs on every terminal path — assembled, all pages failed or skipped, all completed pages lost before assembly, canceled during the page loop, and canceled at assembly. Only the assembled path did this before. Each delete is now individually guarded: previously the cleanup loop was unguarded, so a storage error on page 3 of 10 propagated out and left pages 4–10 orphaned. A cleanup failure is logged and never sinks an otherwise-finished job. Cleanup is gated on source type — URL-mode pages are never touched.

Object-key collision on path-like filenames. The per-file {job}_{index}_ uniqueness prefix was applied before filename sanitization, and sanitization takes the basename — so for a filename containing a path separator (../../etc/passwd.csv) the prefix was stripped off again and two such uploads in one job collided onto a single key, one silently overwriting the other. The filename is sanitized before the prefix is applied. For filenames without a separator the resulting key is byte-identical to before.

Two new filename gates, both 400. A filename longer than 255 characters is now rejected with Filename exceeds the 255-character limit. — the filename is deliberately not echoed back. A filename containing a null byte is rejected with Filename contains an invalid null byte.; this previously surfaced as an opaque 500, so it is a 500 → 400 status change on an existing condition. Both are checked before the extension and content-type gates, which are unchanged and still echo the filename.

Residual — a job canceled in the queued window relies on the backstop. Eager cleanup runs where cancellation is observed: inside the page loop and at assembly. A job canceled before the worker begins that loop, or one whose job row is already gone when cleanup would run, has no eager path — its staged sources are removed by the 24-hour sweep, not immediately.

Constraints: scoped to POST /v2/ingest/files. No request or response fields changed and no schema change — the backstop writes rows through the existing retention path. Status codes are unchanged for every existing condition except the null-byte filename noted above.

Security Fixed

Upload size limit on anything-to-pdf enforced against received bytes, not Content-Length #

POST /v1/convert/anything-to-pdf enforced the upload size ceiling by reading the content-length request header. When the header was absent — a chunked or HTTP/2 client — the check was skipped in its entirety, not merely relaxed: an over-ceiling file was accepted and converted. The ceiling is now measured against the bytes actually received.

Scope: this changed POST /v1/convert/anything-to-pdf and nothing else. Every other upload route that calls the shared size check still measures content-length and still performs no size check at all when the header is absent. The defect class is fixed at one endpoint, not across the API.

What the 413 does and does not do: the multipart body is fully parsed — and spooled, to disk above 1 MB — before the handler runs, so the size check happens after receipt. It prevents the oversized file being loaded and converted; it does not abort the upload. An oversized body still crosses the wire and still occupies the spool. Do not treat the 413 as a bandwidth or ingress control.

Behavior changes on anything-to-pdf:

  • Chunked/HTTP2 request with no content-length, file over the ceiling — was 200, now 413.
  • Request with content-length where the file is under the ceiling but the multipart envelope is over it — was 413, now 200. The old check counted boundaries and other form fields against the file's budget; the new one measures the file. This is a relaxation, and near-limit uploads that previously failed will now go through.
  • detail.file_size now reports the file's byte count rather than the envelope's.

The 413 body shape, the default ceiling applied when the subscription carries none, and the rejection analytics event are unchanged.

Separately, a non-numeric content-length on the fallback path previously surfaced as a 500; it is now ignored.

Fixed

pdf_options no longer silently discarded on anything-to-pdf and the office PDF endpoints #

POST /v1/convert/anything-to-pdf and the nine dedicated office conversion endpoints accepted and validated a pdf_options payload, then discarded everything except grayscale — page geometry never reached the converter. Geometry is now honoured wherever the engine can apply it, and rejected with a 400 wherever it cannot. No endpoint validates an option it then throws away.

Geometry now applied on anything-to-pdf. The geometry fields — page_size, page_width, page_height, orientation, margins, scale, header, footer — are honoured for HTML (.html, .htm, .xhtml), Markdown (.md, .markdown, .mdown, .mkd), plain text (.txt, .text), .epub, raster images (.png, .jpg, .jpeg, .gif, .bmp, .tiff, .tif, .webp, .heic, .heif), and .svg.

Geometry now rejected with 400 on anything-to-pdf. For office/ODF/iWork/RTF/CSV input (.doc, .docx, .xls, .xlsx, .ppt, .pptx, .odt, .ods, .odp, .ots, .pages, .numbers, .rtf, .csv) and .pdf passthrough, page layout comes from the source document. Previously these options were silently dropped and the request returned 200 with the source document's layout. An explicitly-set geometry option now returns 400 before any conversion runs:

pdf_options ['page_size', 'scale'] cannot be applied to '.docx' input: page geometry for this format is determined by the source document (LibreOffice), not by the converter. Remove these options (only 'grayscale' is supported for '.docx'), or set the page layout in the source file before uploading.

detail is a plain string. The engine clause reads LibreOffice for office input and PDF passthrough for .pdf.

Constraints: the trigger is explicitness, not value. A field counts as set if it appears in the request body at all — sending {"page_size": "A4"} (the default value) on a .docx returns 400, while {"grayscale": true} returns 200. Unsupported extensions still fail as unsupported formats, unchanged.

grayscale is unaffected on every input family, including office and .pdf. It was never broken and is applied as a post-process regardless of input format.


Behaviour change: geometry options rejected on the dedicated office endpoints

The same rejection now applies to POST /v1/convert/doc-to-pdf, excel-to-pdf, ppt-to-pdf, odt-to-pdf, ods-to-pdf, odp-to-pdf, ots-to-pdf, pages-to-pdf and numbers-to-pdf. All nine render through LibreOffice, which takes no page-geometry arguments — page size, orientation and margins are properties of the source document's page style, applied at layout time before PDF export. These endpoints previously accepted and validated the geometry fields, then dropped them and returned 200 with the source document's own layout.

This is breaking for callers who send geometry to these endpoints. Such a request returns 400 before conversion instead of 200, with the same message shape shown above. The options never had any effect, so the PDF a caller receives is unchanged for every request that does not set geometry — but a caller that has been sending page_size and ignoring the fact that it did nothing must now remove it.

grayscale is unchanged on all nine and still applies. Omitting pdf_options entirely is unchanged. A wrong-extension upload (for example .html sent to doc-to-pdf) still fails with the unsupported-format error first — the format check takes precedence over the options check.


Behaviour change: scale now takes effect on html-to-pdf and markdown-to-pdf

POST /v1/convert/html-to-pdf and POST /v1/convert/markdown-to-pdf already honoured page_size, orientation, margins, header and footer. scale was parsed and range-checked, then dropped — nothing consumed it. It is now applied.

This changes the output of existing requests. A caller that has been sending scale on either endpoint has been receiving unscaled PDFs and will now receive scaled ones. Callers relying on the old no-op must remove scale from the request. The accepted range is unchanged: 0.1 to 2.0 inclusive, default 1.0; scale: 1.0 remains a no-op.

Semantics: page geometry is fixed, content scales. An A4 request at scale: 2.0 returns an A4 page with content rendered at 2×, not an A2 page. This matches the scale semantics already in effect on the url-to-pdf path. The same semantics apply to scale on anything-to-pdf for every geometry-capable input family listed above.


Other observable effects

  • Requesting geometry on an image or SVG changes the rendering engine. Raster input moves from the direct image path to an HTML layout pass, with a PNG re-encode of the raster; .svg moves from CairoSVG to the same layout pass (still vector). Output is not byte- or pixel-comparable to a no-options run on the same file. A request with no geometry options is byte-identical to before on both paths.
  • SVG with geometry: external resources are hard-blocked. Neither path fetches external resources, but on the geometry path an .svg that references one now fails with a 400 — SVG to PDF conversion failed: External resources are not fetched: <url>.
  • .txt default margin. Plain text with no geometry option (including a grayscale-only request) keeps its hardcoded 2cm margin. Sending any geometry option drops that default so the supplied margins win.
  • header / footer content containing a double quote or backslash now renders literally on anything-to-pdf, html-to-pdf and markdown-to-pdf. On html-to-pdf and markdown-to-pdf — the two endpoints that honoured header/footer before this change — such content terminated the generated CSS string early, breaking the render or injecting caller-supplied rules into the document. That is fixed. On anything-to-pdf the fields were never applied before, so the escaping is in place from the first request that reaches it.

No new request or response fields. pdf_options is unchanged as a schema.

New

New endpoint: POST /v1/convert/anything-to-markdown #

Summary

POST /v1/convert/anything-to-markdown accepts an uploaded document and returns a single UTF-8 Markdown file, routing to a per-format extractor by the uploaded filename's extension. 22 extensions are accepted. Conversion is synchronous and in-process — no job queue, no polling requirement. This is a v1 conversion route and is not part of the /v2 surface; it authenticates via the existing X-API-Key / Authorization: Bearer mechanism used by the other /v1/convert/* routes.

POST /v1/convert/anything-to-markdown

Accepted extensions, by dispatch group:

  • Native extractors.pdf, .docx, .pptx, .xlsx, .csv, .epub
  • Markup.html, .htm, .xhtml
  • Legacy / ODF office.doc, .ppt, .xls, .odt, .ods, .odp, .rtf — converted through headless LibreOffice to HTML first, then to Markdown. Fidelity for this group is LibreOffice's HTML export, not a native reader.
  • Plain text / Markdown.txt, .text, .md, .markdown, .mdown, .mkd — encoding-normalised passthrough (tries utf-8-sig, utf-8, cp1252, latin-1, then lossy utf-8).

Known catalog gap — client-side filtering. The public endpoint catalog that drives the dashboard playground's file picker currently declares only 17 of these 22. .xhtml, .text, .markdown, .mdown and .mkd are accepted by the API but may be filtered out by the picker's accept= list until the catalog is refreshed. Direct API calls with those extensions succeed today. The catalog is a strict subset of what the code accepts, so no advertised extension is rejected — the failure mode is only that five working extensions are hidden.

Request: multipart/form-data. file (required). output_filename (default null — falls back to the input basename). job_id (optional, client-supplied; echoed back in the response and readable afterwards via GET /v1/convert/status/{job_id} if the connection drops mid-request). direct_download (accepted, default true, but inert on this route — the response is always the JSON body below; the parameter exists for request-shape parity with the *-to-pdf routes). Unlike html-to-pdf / markdown-to-pdf / anything-to-pdf, this route takes no pdf_options.

Response: 200 with JSON containing a pre-signed URL — never raw bytes, never a server-minted job handle.

{
  "presigned_url": "…",
  "object_key": "…",
  "filename": "report_20260715_142233123.md",
  "file_size": 18432,
  "conversion_time_seconds": 1.84,
  "job_id": null
}

The same values are mirrored on X-Object-Key, X-File-Size, X-Conversion-Time and X-Filename headers. The .md is written to object storage before the response returns; output name is {base}_{YYYYmmdd_HHMMSSfff}.md. job_id echoes the caller's value (null if none was sent) — it does not indicate async processing.

Constraints: request timeout 300 s → 504 {"error": "Request timeout"}. The legacy/ODF/RTF conversion subprocess is capped at 120 s, and both its failure and its timeout surface as 400, not 504. Upload size is checked from Content-Length before the body is read → 413. PDFs are capped at 2000 pages (400 above that). ZIP-backed formats (.docx, .pptx, .xlsx, .epub) reject archives declaring more than 400 MB uncompressed or more than 10000 entries → 400. Concurrency is bounded in-process; a full pending queue returns 503 "Server is at capacity. Please retry shortly." with Retry-After: 10.

Silent truncation points — these do not error, they quietly shorten output: PDF words beyond 20000 per page are dropped; PDF pages carrying more than 4000 vector edges skip table detection entirely, so tables on those pages are simply not extracted; table cells are truncated at 500 chars ( appended); table body rows are capped at 5000 (_(table truncated to 5000 rows)_ appended); EPUB chapter text is bounded at 100 MB total, and exceeding it stops the chapter loop.

Fidelity

Tables — preserved as GFM pipe tables across PDF, .xlsx, .csv, .pptx and .docx. Cells are flattened to a single line (\r/\n → space) and | is escaped to \|. Spreadsheet and CSV values are read as strings, so leading zeros (007), long integers (no scientific notation) and trailing-zero decimals (1.50) survive verbatim.

Headings — preserved as real ATX, with a per-format mechanism:

  • .docx — Word Heading 1–6 styles map to #######.
  • PDF — inferred from font size, not from structure. The most common rounded size is treated as body text; larger sizes are ranked and mapped largest → #, and the result is capped at ###. A line is only promoted if it is ≤14 words, ≥3 characters and contains a letter. Prose lines beginning with # are escaped to \# so they are not misread as headings.
  • .pptx — synthetic ## Slide N: Title per slide, plus ### Notes for speaker notes.
  • .xlsx — synthetic ## SheetName per sheet; an empty sheet emits _(empty sheet)_.

Images — effectively dropped. Do not rely on them. There is no asset extraction and nothing is uploaded. An <img> becomes a Markdown image link to its original src, which for file input is relative and unresolvable by the caller; an <img> with no src is removed. PDF extraction is word- and table-only, so images never appear at all. .pptx picture shapes are skipped. One exception: .docx embedded images arrive as base64 data: URIs and pass straight through as ![](data:image/png;base64,…). Nothing strips them — expect substantially inflated output for image-heavy Word documents.

OCR — not performed. Scanned or image-only PDFs raise rather than returning an empty file: 400 "No extractable text found in the PDF. Scanned or image-only PDFs require OCR, which this endpoint does not perform." Image uploads (.png, .jpg, .jpeg, .gif, .bmp, .webp, .tiff, .tif) are not in the allowlist and are rejected with 400 by the extension gate, carrying the standard invalid-format message below.

PDF layout — multi-column PDFs read column-interleaved; this is a known limitation of the v1 extractor. Running headers/footers repeating on ≥60% of pages (minimum 3 pages) are dropped. Words inside a detected table region are removed from the prose stream so content is not duplicated. Consecutive prose lines merge into a paragraph, breaking on a heading, a table, or a vertical gap greater than 1.7× the previous font size.

HTML, EPUB, DOCX and legacy office — whole-document and faithful. Only <script>, <style>, <noscript>, <iframe>, <svg>, <canvas> and <template> are stripped — <nav>, <footer>, <aside>, <form> and <button> are kept. style/class/id and all on* handlers are removed; XML processing instructions are removed rather than leaking as literal text.

EPUB — chapters in spine (reading) order. The EPUB3 nav document is skipped so the table of contents does not pollute the output. Only .xhtml/.html/.htm spine items are read, and XML declaring a DTD or entity is rejected outright.

Links and code — anchor-only (#…), javascript: and empty-href links are unwrapped to plain text; bare URLs become autolinks (<url>); relative links stay relative. Fenced code blocks carry a language hint when one can be sniffed from class="language-*" or data-lang.

Output — always UTF-8. Runs of three or more blank lines collapse to one, CRLF/CR normalise to LF, and the file ends with exactly one newline. No YAML frontmatter.

Errors

400 — extension outside the allowlist, checked before any extractor runs: "Invalid file format '{ext}' for anything-to-markdown. Allowed: {list}". This is also what image uploads receive. Also returned for a filename with no extension; for corrupt, empty or text-free documents ("The document contains no extractable text.", "Invalid EPUB: the file is not a valid ZIP archive.", "The CSV file contains no rows.", "Archive rejected: decompressed content is too large.", "Invalid EPUB: XML declaring a DTD/entity is not allowed."); for a scanned PDF; and for legacy-office conversion failure or its 120 s timeout ("Document conversion timed out.").

400 — content/extension mismatch. Magic bytes are checked for .pdf, .docx, .xlsx, .pptx, .odt, .ods, .odp, .epub, .doc, .xls and .ppt; a high-confidence mismatch returns "File content does not match the 'anything-to-markdown' input type." .rtf, .csv, .html, .txt and .md are not sniffed at all.

413Content-Length above the permitted request size. 503 — capacity gate, with Retry-After: 10. 504 — the 300 s request timeout. 500 — anything else, as "Conversion failed: {message}"; extractor-library exceptions surface here rather than as a typed error.

Choosing between anything-to-markdown and url-to-markdown

Separate routes, separate implementations, different inputs. POST /v1/convert/url-to-markdown is unchanged by this release.

POST /v1/convert/anything-to-markdown POST /v1/convert/url-to-markdown
Input uploaded file (multipart/form-data) JSON body with URL(s) — no file input
Rendering no browser; per-format extractor headless browser render, sees JS-injected content
Modes synchronous only sync, async and batch (ZIP)
Extra params none viewport, scroll, cookies, auth, headers
HTML handling whole document, faithful main-article extraction — nav/footer/aside dropped
Output body Markdown, no frontmatter Markdown with YAML frontmatter (title, description, url, links, images)

The two share only their HTML-to-Markdown conversion. Uploaded files sent to /v2/ingest run through the same converter as this route; /v1/convert/anything-to-markdown is the standalone one-file-in, one-file-out form of it.

New

New endpoint: POST /v1/convert/anything-to-pdf #

Summary

POST /v1/convert/anything-to-pdf accepts 36 input file extensions and returns a PDF. The engine is selected from the file extension — callers do not specify a source format. Synchronous multipart/form-data; file upload only, no URL input.

POST /v1/convert/anything-to-pdf

Accepted extensions (36):

Family Extensions Engine
Office .doc .docx .xls .xlsx .ppt .pptx LibreOffice headless
OpenDocument .odt .ods .odp .ots LibreOffice headless
Apple iWork .pages .numbers LibreOffice headless
Other document .rtf .csv LibreOffice headless
Markup .html .htm .xhtml WeasyPrint
Markdown .md .markdown .mdown .mkd Markdown → HTML → WeasyPrint
Plain text .txt .text WeasyPrint — HTML-escaped, wrapped in a monospace <pre>, 2cm page margins
Ebook .epub text extraction → Markdown → WeasyPrint
Raster image .png .jpg .jpeg .gif .bmp .tiff .tif .webp .heic .heif Pillow, embedded at 100 DPI
Vector image .svg CairoSVG — true vector output, not rasterized
PDF .pdf validated passthrough

For the LibreOffice families, conversion fidelity is bounded by LibreOffice's — relevant in particular for iWork and legacy binary Office formats.

Request: file (required). output_filename (default: input basename). job_id (default null, used only for timeout-recovery polling). pdf_options (JSON string). direct_download (default true).

pdf_options is parsed and validated, but only grayscale has any effect on this endpoint — it is applied to the finished PDF. page_size, orientation, margins, scale, header, and footer are accepted and validated (an invalid page_size returns 400), but are never passed to the conversion engine and have no effect on the output. Do not rely on them here. direct_download is likewise accepted for request-shape parity with other convert routes and has no effect — the response is always JSON.

Response: 200 with JSON — presigned_url, object_key, filename (timestamped, e.g. report_20260714_101530123.pdf), file_size, conversion_time_seconds, job_id. The same values are mirrored on the X-Object-Key, X-File-Size, X-Conversion-Time, and X-Filename headers. This is a completed conversion, not a queued job.

Constraints:

  • Routing is by extension alone. A file with no extension returns 400. Magic bytes never select an engine — content checking is a veto that runs before dispatch and rejects only a high-confidence mismatch between a declared binary type and recognizably different bytes (400, "File content does not match the 'anything-to-pdf' input type."). It fails open: unrecognized signatures pass.
  • 21 of the 36 extensions are byte-checked (.doc .docx .epub .gif .heic .heif .jpeg .jpg .numbers .odp .ods .odt .ots .pages .pdf .png .ppt .pptx .webp .xls .xlsx). The other 15 are not, and .bmp, .tif, and .tiff are binary formats that are currently not sniffed despite having well-known signatures. Practical consequence: a .docx that is really a PNG is caught by the veto (400 content mismatch), but a .rtf that is really a PNG is not — it passes to the LibreOffice engine and surfaces as a 400 conversion error instead.
  • Animated GIF and multi-page TIFF convert first frame only — a multi-frame raster always yields a one-page PDF. Transparency (RGBA/LA/palette-alpha) is flattened onto white.
  • No page-count limit. Images beyond Pillow's decompression-bomb threshold return 400 ("Image is too large to process safely.").
  • Max upload size is read from the caller's subscription and enforced against the content-length header, not against bytes read. A request that omits content-length is not size-checked. Oversized returns 413.
  • Concurrent conversions are capped; requests beyond the pending queue depth return 503 with Retry-After: 10.
  • Global request timeout is 300s → 504 {"error": "Request timeout"}. The LibreOffice subprocess has its own 120s cap, which surfaces as 500, not 504 — a slow Office/iWork/RTF/CSV conversion fails with Conversion failed: ... before the global timeout is reached. The WeasyPrint, Pillow, and CairoSVG paths have no per-conversion timeout and are bounded only by the 300s global.
  • Corrupt or malformed input (broken image, invalid SVG, malformed EPUB, a .pdf that isn't a PDF, LibreOffice failure) returns 400 with the underlying reason. Unsupported extension returns 400 with the full allowed list in detail. Malformed pdf_options JSON returns 400.