Perceive
POST /v2/perceive renders a URL once in a real browser and hands back
every output you ask for from that single render: Markdown, cleaned or
raw HTML, a screenshot, a PDF, the link and image inventory, and
structured data (page metadata, JSON-LD, headings, tables). It replaces
a stack of separate calls — url-to-markdown, url-to-screenshot,
url-to-pdf, plus your own scraping — with one request.
Here is the smallest useful call. Send a URL, get clean Markdown and the page's structured metadata back:
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"outputs": ["markdown", "structured"]
}'
The response carries a pre-signed download URL for the Markdown file and the structured block inline:
{
"operation_id": "per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
"status": "completed",
"url": "https://example.com/pricing",
"url_final": "https://example.com/pricing",
"content_hash": "9f2b8c1a...d4e5",
"render_quality": 0.93,
"cache_hit": false,
"outputs": {
"markdown": {
"url": "https://spaces.example.com/...signed...",
"object_key": "env/files/4127/v2-perceive/per_3f9a..._markdown.md",
"size_bytes": 8421,
"content_type": "text/markdown; charset=utf-8",
"expires_in": 900
}
},
"structured": {
"metadata": {
"title": "Pricing",
"description": "Simple, usage-based pricing."
},
"structured_data": [
{"@type": "Product", "name": "Pro", "offers": {"price": "99"}}
]
},
"extraction_tier": "heuristic",
"tokens": {"input": 0, "output": 0},
"cost_cents": 0.0,
"duration_ms": 6230,
"warnings": []
}
Worth flagging up front.
/v2/perceiveis a V2 endpoint with its own quota counter. V1 conversion plans do not include any V2 perceive operations — you need a V2-inclusive plan to call it. See the pricing page for the per-tier allowances.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v2/perceive |
Perceive a single URL and return the outputs you requested. |
GET |
/v2/perceive/{operation_id} |
Re-fetch a past operation with freshly signed download URLs. |
POST |
/v2/perceive/batch |
Perceive up to 1,000 URLs that share one set of options. |
GET |
/v2/perceive/batch/{job_id} |
Poll the status and per-URL results of a batch. |
Content-Type: application/json on every POST.
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/perceive
is not on the key's list, the request is rejected with 403.
How perceive works
One request triggers one browser render through a shared headless Chrome singleton, then materialises every output from that render. You never pay for the same page twice in a single call.
- Render. The page loads in headless Chrome. Cookie banners are dismissed, the page is scrolled to trigger lazy-loaded content, sticky headers are handled, and images are given time to load — the same capture pipeline that powers the url-to-pdf endpoint.
- Materialise. From the rendered DOM, perceive builds whatever you
listed in
outputs: Markdown, cleaned/raw HTML, links, images, a screenshot, a PDF. - Extract. If you requested
structuredoutput, perceive runs a heuristic pass for page metadata, JSON-LD, headings, and tables. If you also send aschemaand your plan carries the LLM tier, an Anthropic Claude Haiku model fills the schema when the heuristic pass comes up short. - Score. A render-quality score (0.0–1.0) flags pages that look blocked by anti-bot protection or hidden behind a login wall, so you can tell a real render from a challenge page.
Binary and text outputs (Markdown, HTML, screenshots, PDFs, the link and
image JSON) are uploaded to storage and returned as pre-signed URLs
that expire after 15 minutes. The structured block is returned inline
in the JSON. Re-fetch any operation with GET /v2/perceive/{operation_id}
to get a fresh set of signed URLs.
Request parameters
Core
| Parameter | Type | Default | Description |
|---|---|---|---|
url |
string |
— | The page to perceive. Must start with http:// or https://. Max 2,048 characters. Required. |
outputs |
string[] |
["markdown", "structured"] |
Which outputs to produce. See Outputs. |
extract |
string[] |
[] |
Which structured fields to pull when structured is in outputs. See Structured extraction. |
schema |
object |
null |
A JSON schema describing the fields you want extracted. Triggers the LLM extraction tier on plans that include it. |
cache_mode |
string |
"enabled" |
enabled, bypass, or refresh. See Caching. |
Outputs
outputs accepts any combination of these names:
| Output | Returned as | What you get |
|---|---|---|
markdown |
signed URL | Clean Markdown from the main content, links and images resolved to absolute URLs. |
markdown_fit |
signed URL | "Fit" Markdown — a pruned, denser variant tuned for LLM context windows. |
html_cleaned |
signed URL | The rendered HTML with scripts, styles, and boilerplate stripped. |
html_raw |
signed URL | The full rendered HTML exactly as the browser produced it. |
screenshot |
signed URL | A viewport PNG at the requested (or default) viewport size. |
screenshot_full_page |
signed URL | A full-page PNG capturing the entire scroll height. |
pdf |
signed URL | A PDF of the page. Accepts the full pdf_options surface (see below). |
links |
signed URL | A JSON array of every link found, with absolute URLs and anchor text. |
images |
signed URL | A JSON array of every image, with absolute src and alt text. |
structured |
inline JSON | Structured data extracted from the page (the structured response field). |
Rendering and waiting
| Parameter | Type | Default | Description |
|---|---|---|---|
viewport |
object |
1920 x 1080 |
{"width": <int>, "height": <int>}. Width 320–3840, height 240–2160. |
mobile |
boolean |
false |
Render at a mobile viewport (390 x 844) unless viewport is set explicitly. |
wait_for |
string |
null |
Wait after navigation for a CSS selector (".price" or "css:.price") or a JS expression ("js:window.dataReady === true"). |
wait_timeout_ms |
integer |
30000 |
How long wait_for may wait, in milliseconds. 0–60,000. A timeout degrades to a warning; the page is captured as-is. |
js_code |
string |
null |
JavaScript to run on the page after navigation. Max 20,000 characters. An error becomes a warning, not a failure. |
block_resources |
string[] |
[] |
Resource types to abort before they load. Any of image, media, font, stylesheet, script, xhr, fetch, websocket, manifest, other. Useful for faster, text-only renders. |
respect_robots |
boolean |
false |
When true, a URL disallowed by the site's robots.txt is rejected with 403. |
pdf_options |
object |
null |
Page format, margins, headers, footers, scale, and orientation for pdf output. Same object as url-to-pdf. With no pdf_options, perceive produces a single continuous page, byte-identical to V1 url-to-pdf. |
Authenticated and custom requests
| Parameter | Type | Default | Description |
|---|---|---|---|
auth |
object |
null |
HTTP Basic Auth for the target page: {"username": "...", "password": "..."}. |
cookies |
array |
null |
Cookies to inject before navigation. Max 50. Each needs name, value, and either domain or url. |
headers |
object |
null |
Custom request headers. Max 20. Blocked names: host, content-length, transfer-encoding, connection, upgrade, te, trailer. |
proxy_url (Business+),
geolocation, and action_chain are accepted by the
request schema but return 422 today. They arrive in a later
release; sending them now tells you exactly which knob is not ready
instead of silently ignoring it.
Structured extraction
When structured is in outputs, the extract list controls which
fields perceive pulls. Ask for nothing and it defaults to metadata and
structured_data.
extract value |
Field in structured |
Status |
|---|---|---|
metadata |
metadata |
Live |
structured_data |
structured_data (JSON-LD) |
Live |
headings |
headings |
Live |
tables |
tables |
Live |
main_content |
main_content (text, capped at 50,000 chars) |
Live |
all |
expands to every live field above | Live |
prices |
— | Not yet — returns a warning, omitted |
contacts |
— | Not yet — returns a warning, omitted |
technologies |
— | Not yet — returns a warning, omitted |
To be straight about it: prices, contacts, and technologies are
reserved names. Request one today and it does not error — it lands in the
warnings array and is dropped from structured.
Schema-driven extraction
Send a schema to pull specific fields into structured.extracted:
{
"url": "https://example.com/product/widget",
"outputs": ["markdown", "structured"],
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"price": {"type": "number"},
"in_stock": {"type": "boolean"}
}
}
}
The LLM extraction tier (Anthropic Claude Haiku) fills the schema, and it
fires only when all of these hold: you sent a schema, your plan
carries the LLM tier (Starter and up), the page was not scored as
blocked, and the heuristic pass left schema fields empty. When it runs,
extraction_tier is "llm", and tokens and cost_cents report what
that extraction cost; otherwise extraction_tier is "heuristic" and
both are zero.
Note. Schema extraction is hard-capped to protect your bill: a single extraction is capped per request, and per-project daily spend is capped by plan. If a cap is hit, perceive returns the heuristic result with a note in
warningsrather than overspending. On a plan without the LLM tier, you get heuristicstructureddata only.
Response
Both POST /v2/perceive and GET /v2/perceive/{operation_id} return the
same object.
| Field | Type | Description |
|---|---|---|
operation_id |
string |
Opaque ID (per_...). Use it with the GET endpoint and quote it to support. |
status |
string |
queued, processing, completed, or failed. |
url |
string |
The URL you sent. |
url_final |
string |
The URL after redirects. |
content_hash |
string |
SHA-256 of the rendered page. Drives the 1-hour cache. |
render_quality |
number |
0.0–1.0. Low scores flag anti-bot challenges or login walls. |
cache_hit |
boolean |
true when the result came from the cache instead of a fresh render. |
outputs |
object |
Map of output name to {url, object_key, size_bytes, content_type, expires_in}. Signed URLs expire in 900 seconds. |
structured |
object |
Inline structured data, present when structured was requested. |
extraction_tier |
string |
heuristic, css, or llm. |
tokens |
object |
{input, output} LLM tokens used. Zero unless the LLM tier ran. |
cost_cents |
number |
LLM cost in cents for this operation. Zero unless the LLM tier ran. |
duration_ms |
integer |
End-to-end render time. |
error |
string |
Set only when status is failed. |
warnings |
string[] |
Non-fatal notes: a wait_for timeout, a skipped extract, a blocked-page flag. |
Retrieve an operation
Signed URLs expire after 15 minutes. To download an output later, re-fetch the operation — perceive re-signs every URL from the stored object keys. No re-render happens, so this does not consume quota.
curl https://api.enconvert.com/v2/perceive/per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_live_your_private_key"
An unknown operation ID, or one that belongs to a different project,
returns 404 — existence is never leaked across projects.
Batch perception
POST /v2/perceive/batch perceives a list of URLs that share one
options block. Each URL is rendered through the same pipeline as a
single call and produces its own operation row.
curl -X POST https://api.enconvert.com/v2/perceive/batch \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://example.com/a",
"https://example.com/b",
"https://example.com/c"
],
"options": {"outputs": ["markdown"]},
"output_mode": "manifest"
}'
Batches of 10 URLs or fewer run inline and answer 200 with every result
populated. Larger batches answer 202 with a job_id; the URLs are
drained one at a time and you poll for results:
curl https://api.enconvert.com/v2/perceive/batch/{job_id} \
-H "X-API-Key: sk_live_your_private_key"
The batch response reports aggregate progress and carries one full perceive result per URL once rendered:
{
"job_id": "bat_8c1a...",
"status": "partial",
"output_mode": "manifest",
"total": 3,
"completed": 2,
"failed": 1,
"pending": 0,
"items": [
{"operation_id": "per_...", "status": "completed", "url": "https://example.com/a"},
{"operation_id": "per_...", "status": "completed", "url": "https://example.com/b"},
{"operation_id": "per_...", "status": "failed", "url": "https://example.com/c"}
]
}
status is queued, processing, completed, failed, or partial
(some URLs succeeded, some failed). Set output_mode to zip to bundle
every artifact into a single ZIP, returned under the zip field once the
batch finishes. Batch access and the maximum batch size are plan-gated;
see the pricing page.
Caching
cache_mode controls how perceive treats its 1-hour result cache, keyed
by your project, the URL, and the render-affecting request options.
cache_mode |
Behaviour |
|---|---|
enabled (default) |
Return a cached result when an identical request rendered within the last hour. cache_hit is true, cost_cents is 0. |
bypass |
Skip the cache and render fresh. |
refresh |
Render fresh and replace the cached entry. |
Worth flagging: a cache hit still counts as one perceive operation against your monthly quota. The quota meters operations, not browser renders — the cache saves you render time, not quota.
Code examples
curl — Markdown only
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/blog/post",
"outputs": ["markdown"]
}'
curl — Markdown plus structured data
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/pricing",
"outputs": ["markdown", "structured"],
"extract": ["metadata", "structured_data", "tables"]
}'
curl — Full outputs plus PDF
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_live_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/report",
"outputs": ["markdown", "html_cleaned", "screenshot_full_page", "pdf"],
"pdf_options": {"format": "A4", "print_background": true}
}'
Python
import requests
response = requests.post(
"https://api.enconvert.com/v2/perceive",
headers={"X-API-Key": "sk_live_your_private_key"},
json={
"url": "https://example.com/pricing",
"outputs": ["markdown", "structured"],
"extract": ["metadata", "tables"],
},
)
response.raise_for_status()
data = response.json()
# Download the Markdown artifact from its signed URL
markdown_url = data["outputs"]["markdown"]["url"]
markdown_text = requests.get(markdown_url).text
print(data["structured"])
print(markdown_text)
Node.js
const res = await fetch("https://api.enconvert.com/v2/perceive", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": "sk_live_your_private_key"
},
body: JSON.stringify({
url: "https://example.com/pricing",
outputs: ["markdown", "structured"],
extract: ["metadata", "tables"]
})
});
const data = await res.json();
// Download the Markdown artifact from its signed URL
const markdownText = await fetch(data.outputs.markdown.url).then(r => r.text());
console.log(data.structured);
console.log(markdownText);
If you call EnConvert from Claude, Cursor, or another MCP client, the
same capability is exposed as the perceive_url tool — see the MCP
server page.
Plan gating
V2 perceive operations are a separate counter from V1 conversions. V1 plans include no perceive quota; you need a V2-inclusive plan.
| Plan | Perceive operations / month | Schema extraction (LLM tier) |
|---|---|---|
| Free | 50 | Not included |
| Starter | 850 | Claude Haiku |
| Pro | 8,500 | Claude Haiku |
| Business | 42,500 | Claude Haiku |
| Enterprise | Unlimited | Included |
Batch access and per-batch size limits, basic-auth/cookie/header support,
and proxy_url (Business+, when it ships) are gated by plan. Current
numbers live on the pricing page.
Error responses
| Status | Condition |
|---|---|
400 Bad Request |
URL is not http(s), carries embedded credentials, or resolves to a private, loopback, or link-local address (SSRF protection). |
400 Bad Request |
Invalid auth (missing username/password), cookies (not an array, over 50 entries, missing fields), or headers (not an object, over 20 entries, blocked name). |
401 Unauthorized |
Missing or invalid API key / JWT token. |
402 Payment Required |
Perceive is not on your current plan, or your monthly perceive quota is exhausted. |
403 Forbidden |
/v2/perceive is not in the API key's allowed endpoints. |
403 Forbidden |
Batch not available on your plan, or batch size exceeds your plan's limit. |
403 Forbidden |
respect_robots=true and the site's robots.txt disallows the URL. |
404 Not Found |
Unknown operation_id or job_id, or one owned by another project. |
422 Unprocessable Entity |
Request validation failed (bad enum in outputs/extract, wait_timeout_ms out of range, viewport out of bounds). |
422 Unprocessable Entity |
proxy_url, geolocation, or action_chain was sent — reserved for a later release. |
500 Internal Server Error |
The render failed. The message includes the operation_id to quote to support. |
The full status-code reference is in the error-codes guide.
Limits
| Limit | Value |
|---|---|
| URL length | 2,048 characters |
wait_timeout_ms |
0–60,000 ms |
js_code length |
20,000 characters |
| Viewport width | 320–3,840 px |
| Viewport height | 240–2,160 px |
| Cookies per request | 50 |
| Custom headers per request | 20 |
main_content extract |
50,000 characters |
| Batch URLs per request | 1,000 (schema cap; your plan's batch limit is lower) |
| Inline batch threshold | 10 URLs (larger batches run asynchronously) |
| Result cache TTL | 1 hour |
| Signed URL expiry | 15 minutes |
| Monthly perceive operations | Plan-dependent (Free: 50) |