Distill
POST /v2/distill pulls structured data out of one or more URLs to match
a schema you supply. It runs a two-pass engine: a free CSS pass first
(Crawl4AI's JsonCssExtractionStrategy driven by your selectors), then —
only for the fields the CSS pass left empty — a capped LLM pass on
Anthropic's claude-haiku-4-5. The response data is guaranteed to come
back in the exact shape you asked for. It is EnConvert's answer to
Firecrawl /extract.
Here is the smallest useful call. Send one URL and a flat
{field: description} schema, and get the extracted fields back:
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com/product/widget"],
"schema": {
"name": "the product name",
"price": "the listed price",
"in_stock": "whether it is in stock"
}
}'
The response carries one result per URL, the extracted data, and which
tier produced it:
{
"operation_id": "dst_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
"total": 1,
"completed": 1,
"failed": 0,
"results": [
{
"url": "https://example.com/product/widget",
"url_final": "https://example.com/product/widget",
"status": "completed",
"data": {
"name": "Widget Pro",
"price": "$49.00",
"in_stock": "yes"
},
"extraction_tier": "llm",
"fields_from_css": 0,
"fields_from_llm": 3,
"render_quality": 0.91,
"tokens": {"input": 4120, "output": 38},
"cost_cents": 0.45,
"warnings": []
}
],
"total_cost_cents": 0.45,
"warnings": []
}
Worth flagging up front.
/v2/distillis a V2 endpoint with its own quota counter,distill_operations, where one operation equals one URL distilled. V1 conversion plans include no distill operations — you need a V2-inclusive plan to call it. Distill bills only for URLs that complete, so an EnConvert render failure never costs you a unit. See the V2 web intelligence overview for how the counters relate, and the pricing page for per-tier allowances.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v2/distill |
Distill an explicit URL list, or discover a site's URLs first and distill each, against one schema. |
Content-Type: application/json.
Unlike perceive, distill is a single synchronous endpoint — there is no separate GET re-fetch or async batch path. Every URL renders sequentially through the shared headless Chrome singleton and the full result set comes back in one response.
Authentication
Authenticate with a private key in the X-API-Key header for
server-to-server calls. This is the path the examples below use.
X-API-Key: sk_live_your_private_key
Public keys with a JWT bearer token also work, using the same flow as
every other endpoint — generate a token with your pk_ key, then send it
as Authorization: Bearer <token>. The full flow, including domain
locking and token refresh, is in the authentication guide.
Each API key carries an allowed-endpoints allowlist. If /v2/distill is
not on the key's list, the request is rejected with 403.
How distill works
One request distills a list of URLs against one schema. The flow is the same for every URL:
- Resolve the URL list. With
urls, the list is exactly what you sent (deduplicated, order preserved). Withdiscover_from, distill runs discover on the seed URL first — parsing the sitemap, crawling, or both — then distills the discovered URLs up tomax_pages. - Render. Each URL renders once in headless Chrome through the same
capture pipeline that powers perceive. The render
is screened for SSRF and, if
respect_robots=true, checked against the site'srobots.txt. No artifacts are uploaded to storage — distill needs only the rendered DOM. - Pass 1 — CSS (free). If you supplied a
css_schema, the CSS extractor runs over the rendered HTML and fills every selector- addressable field at zero LLM cost. This pass is time-bounded to 10 seconds; on timeout the URL falls through to the LLM pass with a warning. - Pass 2 — LLM (capped, only when needed). Distill collects the
schema fields the CSS pass left missing or empty and escalates only
those fields to
claude-haiku-4-5, under hard per-call and per-period budget caps. If your plan has no LLM tier, the page was flagged as blocked, or a budget cap is hit, the LLM pass is skipped and the missing fields come back asnullwith a warning. - Normalize. The merged result is reshaped to exactly your schema's
keys — missing scalars become
null, missing arrays become[], and any extra keys are dropped. The shape guarantee holds regardless of what CSS or the LLM produced.
The quota counter increments by one only after a URL completes. Render
failures (SSRF rejection, robots block, a crashed render) produce a
failed result row and cost no quota.
Request parameters
You must provide exactly one of urls or discover_from, plus a
schema. Sending both, or neither, is a 422.
Source — explicit URLs
| Parameter | Type | Default | Description |
|---|---|---|---|
urls |
string[] |
— | Explicit URLs to distill. Each must start with http:// or https:// and be at most 2,048 characters. Max 50 URLs per request (MAX_DISTILL_URLS). Mutually exclusive with discover_from. |
Source — discover then distill
| Parameter | Type | Default | Description |
|---|---|---|---|
discover_from |
object |
— | Discover a site's URLs first, then distill each. Mutually exclusive with urls. Requires the discover_enabled plan flag (otherwise 402). |
discover_from.url |
string |
— | Seed URL. Must start with http:// or https://. Max 2,048 characters. |
discover_from.mode |
string |
hybrid |
sitemap, crawl, or hybrid. Same modes as the discover endpoint. |
discover_from.max_pages |
integer |
10 |
Cap on URLs discovered and distilled. 1–50 — each is a full render, so it is bounded by MAX_DISTILL_URLS. |
Schema (required)
| Parameter | Type | Default | Description |
|---|---|---|---|
schema |
object |
— | The output shape, sent under the JSON key schema. Either a JSON-Schema object ({"type": "object", "properties": {...}}) or a forgiving flat {field: description} map. Max 200 top-level properties. The response data is guaranteed to match this shape. A structurally invalid schema is a 422. Required. |
The schema is the contract. If you send a JSON-Schema object, distill
reads its properties; if you send a flat map, each key names a field and
each value is the description handed to the LLM. Either way, data comes
back with exactly the schema's top-level keys.
CSS schema (optional)
Supply a css_schema to answer fields for free before any LLM call.
Without it, every field falls straight to the LLM pass.
| Parameter | Type | Default | Description |
|---|---|---|---|
css_schema.baseSelector |
string |
— | CSS selector for the repeating container; one record is extracted per match. 1–1,024 characters. Required when css_schema is present. |
css_schema.fields |
CssField[] |
— | 1–128 field definitions read out of each container. Required. |
css_schema.name |
string |
"distill" |
Optional label for the schema. Max 128 characters. |
css_schema.target_field |
string |
inferred | Which top-level output property the CSS records fill — an array property gets the full record list, a scalar/object property gets the first record. When omitted, distill infers it if the schema has exactly one array property. Max 128 characters. |
Each entry in fields is a CssField:
| Field | Type | Default | Description |
|---|---|---|---|
name |
string |
— | Output key for this field. 1–128 characters. Required. |
type |
string |
— | One of text, attribute, html, regex, nested, list, nested_list. Required. |
selector |
string |
null |
CSS sub-selector. Optional for leaf types (text/attribute/html/regex); required for nested/list/nested_list. Max 1,024 characters. |
attribute |
string |
null |
Attribute name to read. Required when type is attribute. Max 128 characters. |
pattern |
string |
null |
Regex pattern. Required when type is regex. Compiled at the edge; a pattern with nested re-quantified groups (a ReDoS shape like (a+)+) is rejected with 422. Max 1,024 characters. |
default |
any | null |
Value when the selector matches nothing. |
transform |
string |
null |
One of lowercase, uppercase, strip. |
fields |
CssField[] |
null |
Child fields, for nested/list/nested_list. Max 64 children; total nesting depth max 5. |
Worth flagging: the computed field type from Crawl4AI is deliberately
not accepted. Its expression form runs eval on caller input, and its
callable form cannot cross a JSON boundary, so distill enumerates only
the seven safe types above.
Render knobs
Distill renders only the DOM, so it exposes a small subset of the perceive render options.
| Parameter | Type | Default | Description |
|---|---|---|---|
wait_for |
string |
null |
Wait after navigation for a CSS selector or a JS expression ("js:window.dataReady === true"). Max 1,024 characters. |
wait_timeout_ms |
integer |
30000 |
How long wait_for may wait, in milliseconds. 0–60,000. |
headers |
object |
null |
Custom request headers for the render. |
cookies |
array |
null |
Cookies to inject before navigation. |
respect_robots |
boolean |
false |
When true, a URL disallowed by the site's robots.txt is rejected and that URL's result is marked failed. |
Response
POST /v2/distill returns a DistillResponse:
| Field | Type | Description |
|---|---|---|
operation_id |
string |
Opaque ID (dst_...). Quote it to support. |
total |
integer |
Number of URLs processed (rows in results). |
completed |
integer |
URLs that rendered and produced a data object. |
failed |
integer |
URLs whose render was rejected or crashed. |
results |
object[] |
One DistillItemResult per URL — see below. |
total_cost_cents |
number |
Sum of per-URL LLM cost across the request, in cents. |
warnings |
string[] |
Request-level notes (e.g. quota exhausted mid-list, discover crawl faults). |
Each entry in results is a DistillItemResult:
| Field | Type | Description |
|---|---|---|
url |
string |
The URL you sent (or discovered). |
url_final |
string |
The URL after redirects. Omitted on a failed row. |
status |
string |
completed or failed. |
data |
object |
The extracted data, normalized to exactly your schema's keys. null on a failed row. |
extraction_tier |
string |
css (CSS only), llm (LLM only), mixed (both contributed), or none (nothing found). |
fields_from_css |
integer |
Count of fields the CSS pass filled. |
fields_from_llm |
integer |
Count of fields the LLM pass filled. |
render_quality |
number |
0.0–1.0. Low scores flag anti-bot challenges or login walls. |
tokens |
object |
{input, output} LLM tokens used. Zero unless the LLM pass ran. |
cost_cents |
number |
LLM cost in cents for this URL. Zero unless the LLM pass ran. |
error |
string |
Set only when status is failed. A generic message — internal render detail stays server-side. |
warnings |
string[] |
Per-URL notes: a CSS timeout, a skipped LLM pass, a budget cap hit. |
To be straight about it: the response carries no signed download URLs and
no stored artifacts. Distill returns the structured data inline and
nothing else — if you also want the page's Markdown, HTML, a screenshot,
or a PDF, that is what perceive is for.
The two-pass cost model
The CSS pass is free. The LLM pass costs money, so distill fires it as narrowly as possible and caps it from several directions.
It escalates only the missing fields. After the CSS pass, distill
computes which schema fields are still empty — a scalar that came back
null/"", an array that came back empty, or an array whose items lack a
declared sub-field. Only those field names go into a reduced schema for
the LLM call, which keeps the prompt and the cost minimal.
It skips the LLM pass entirely when any of these hold, returning the CSS-only result with a warning instead of overspending:
- Your plan has no LLM tier (
llm_extraction_enabledplus a non-noneagent_model_tier). - The page's
render_qualityflagged it as blocked by anti-bot protection. - The per-request LLM budget for this call is reached, or the per-period budget cap is reached.
The budget caps are layered:
| Cap | Value | Scope |
|---|---|---|
| Per call | $0.05 (PER_REQUEST_CAP_CENTS) |
One LLM call's worst-case projected cost. Over it → skipped before any network I/O. |
| Per request | $0.50 (_REQUEST_LLM_BUDGET_CENTS) |
Total LLM spend across all URLs in one /v2/distill call. Remaining URLs return CSS-only. |
| Per request escalations | 50 (_MAX_LLM_ESCALATIONS) |
At most one LLM call per URL, hard-capped. |
| Per period | $5 default, $20 on Pro and above | ch_usage_periods.llm_cost_cents for your billing period (usage.reserve_llm_budget). |
Note. The per-period budget is reserved atomically before the call and settled down to the real cost after, so concurrent calls cannot collectively overshoot the cap. A project with no active usage-period row fails closed — spend EnConvert cannot account for is spend it does not make. When a cap is hit, the affected fields come back as
nullwith a warning; the request still succeeds.
When the LLM pass does run, extraction_tier reports llm or mixed,
and tokens and cost_cents report what it cost. When it does not run,
both are zero.
Mapping CSS records to your schema
The common case is a listing page: a repeating row, and an output schema
with one array property to hold the rows. Give distill a css_schema
whose baseSelector matches the row and whose fields read the columns,
and it fills the array for free:
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com/products"],
"schema": {
"type": "object",
"properties": {
"products": {
"type": "array",
"items": {
"type": "object",
"properties": {
"name": {"type": "string"},
"price": {"type": "string"},
"sku": {"type": "string"}
}
}
}
}
},
"css_schema": {
"baseSelector": ".product-card",
"target_field": "products",
"fields": [
{"name": "name", "type": "text", "selector": ".title"},
{"name": "price", "type": "text", "selector": ".price"},
{"name": "sku", "type": "attribute",
"selector": ".product-card", "attribute": "data-sku"}
]
}
}'
How records land on the schema:
target_fieldset to an array property → that property gets the full record list.target_fieldset to a scalar/object property → it gets the first record.target_fieldomitted, schema has exactly one array property → distill infers it and fills that property.- Otherwise → the first record is treated as a single flat object and its matching keys are lifted to the top level.
If CSS fills the array but some items miss a declared sub-field — say
sku is absent on half the cards — distill escalates products to the
LLM pass to fill the gaps. That is the two-pass differentiator over a
plain scraper: structured where it can be, model-backed where it has to be.
Code examples
curl — flat schema, LLM-only
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"urls": ["https://example.com/article"],
"schema": {
"headline": "the article headline",
"author": "the author name",
"published": "the publish date"
}
}'
curl — discover then distill
curl -X POST https://api.enconvert.com/v2/distill \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"discover_from": {
"url": "https://example.com/blog",
"mode": "sitemap",
"max_pages": 25
},
"schema": {
"title": "the post title",
"summary": "a one-line summary"
}
}'
Python
import requests
response = requests.post(
"https://api.enconvert.com/v2/distill",
headers={"X-API-Key": "sk_live_your_private_key"},
json={
"urls": ["https://example.com/product/widget"],
"schema": {
"name": "the product name",
"price": "the listed price",
"in_stock": "whether it is in stock",
},
},
)
response.raise_for_status()
result = response.json()
for item in result["results"]:
if item["status"] == "completed":
print(item["url"], "->", item["data"])
else:
print(item["url"], "FAILED:", item["error"])
print("total cost (cents):", result["total_cost_cents"])
Node.js
const res = await fetch("https://api.enconvert.com/v2/distill", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "sk_live_your_private_key"
},
body: JSON.stringify({
urls: ["https://example.com/product/widget"],
schema: {
name: "the product name",
price: "the listed price",
in_stock: "whether it is in stock"
}
})
});
const result = await res.json();
for (const item of result.results) {
if (item.status === "completed") {
console.log(item.url, "->", item.data);
} else {
console.log(item.url, "FAILED:", item.error);
}
}
console.log("total cost (cents):", result.total_cost_cents);
Plan gating
Distill operations are metered on their own distill_operations counter,
separate from V1 conversions and from the perceive
counter. One operation equals one URL distilled, and only URLs that
complete are counted. V1 conversion plans include no distill quota — you
need a V2-inclusive plan.
The schema-driven LLM pass (claude-haiku-4-5) is a further plan gate: it
runs only when your plan carries llm_extraction_enabled and a non-none
agent model tier. Without it, distill returns CSS-only results and notes
the skipped fields in warnings. The per-period LLM budget is $5 on the
default tier and $20 on Pro and above.
Using discover_from additionally requires the discover_enabled plan
flag — otherwise the request is rejected with 402, so a distill-only
plan cannot reach discover for free by routing through here.
Per-tier distill_operations allowances and the plans that include the
LLM tier live on the pricing page.
Error responses
| Status | Condition |
|---|---|
400 Bad Request |
A URL (or the discover_from seed) resolves to a private, loopback, or link-local address, has no hostname, or otherwise fails the SSRF screen. Raised per URL during render. |
401 Unauthorized |
Missing or invalid API key / JWT token. |
402 Payment Required |
Distill is not on your current plan, your monthly distill_operations quota is exhausted, or discover_from was sent without the discover_enabled plan flag. |
403 Forbidden |
/v2/distill is not in the API key's allowed endpoints. |
422 Unprocessable Entity |
No schema, both or neither of urls/discover_from, a structurally invalid schema, over 200 schema properties, an invalid CssField (missing attribute/pattern/fields for its type, an uncompilable or ReDoS-prone regex), or CSS field nesting deeper than 5. |
500 Internal Server Error |
The orchestration failed unexpectedly. The message includes the operation_id to quote to support. |
A few status notes worth keeping straight: a single URL whose render is
rejected for SSRF surfaces a 400 only when it is the seed of a
discover_from request; for an explicit urls list, a per-URL render
rejection becomes a failed result row rather than failing the whole
request. An LLM budget cap is never an error — it degrades to a CSS-only
result with a warning. The full status-code reference is in
the error-codes guide.
Limits
| Limit | Value |
|---|---|
URLs per request (urls) |
50 (MAX_DISTILL_URLS) |
discover_from.max_pages |
1–50 |
| URL length | 2,048 characters |
| Schema top-level properties | 200 (MAX_SCHEMA_PROPERTIES) |
css_schema.fields |
1–128 |
CssField.fields children |
64 |
| CSS field nesting depth | 5 (MAX_CSS_FIELD_DEPTH) |
wait_for length |
1,024 characters |
wait_timeout_ms |
0–60,000 ms |
| CSS pass timeout | 10 seconds per URL |
| LLM per-call cap | $0.05 |
| LLM per-request cap | $0.50 |
| LLM escalations per request | 50 |
| LLM per-period cap | $5 default / $20 Pro+ |
Monthly distill_operations |
Plan-dependent — see pricing |