Web Scraping API for Markdown, Screenshots, and Structured Data#
POST /v2/perceive is EnConvert's web scraping API: it renders a URL
once in a real headless browser (JavaScript executed, lazy content
loaded) and hands back every output you ask for from that single
render: clean Markdown (main content only by default, site chrome
stripped), cleaned or raw HTML, a screenshot, a PDF, the link and image
inventory, and structured data (page metadata, JSON-LD, headings,
tables). File outputs come back as short-lived pre-signed download URLs,
the structured block inline, and batches over 10 URLs run asynchronously
behind a polled job_id. One request replaces a whole stack of separate
calls: url-to-markdown, url-to-screenshot, url-to-pdf, plus your own
scraping.
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_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": "Studio", "offers": {"price": "99"}}
]
},
"extraction_tier": "heuristic",
"tokens": {"input": 0, "output": 0},
"cost_cents": 0.0,
"duration_ms": 6230,
"warnings": []
}
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. |
DELETE |
/v2/perceive/batch/{job_id} |
Cancel a running 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_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 is fetched through an automatic multi-engine fallback: a fast real-browser TLS fingerprint first, escalating to headless Chrome when the page is blocked or needs JavaScript, and once more to a stealth-hardened render when a page still looks blocked by anti-bot protection, so more real-world pages come back with usable content. In the browser, cookie banners are dismissed, the page is scrolled to trigger lazy-loaded content, sticky headers are handled, and images are given time to load. This is 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. The DOM is normalised first so the Markdown reflects what a reader sees, code fences keep their language, card links keep their structure, and interface furniture is removed underonly_main_content. See Markdown quality. - 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 LLM fills the schema when the heuristic pass comes up short. - Score. A render-quality score (0.0-1.0) tells a real render from
a failed one. Scores below 0.40 mean a failed render: an anti-bot
page, a login wall, an HTTP error page, a soft 404, or an empty
shell. Consult
deductionsfor the reason andstatus_codefor the upstream status.
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#
Validation is strict: a request key the schema does not know is rejected
with 422 naming the offending field. Unknown keys are never silently
ignored. Every 422 body also carries a top-level errors array of
human-readable messages alongside the machine-readable detail list.
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. |
only_main_content |
boolean |
true |
Strip site chrome (navigation, header, footer, sidebars, cookie banners, hidden nodes) plus interface furniture (buttons, tab strips, "Was this page helpful?" widgets, screen-reader-only labels, breadcrumbs) from the markdown output and the main_content extract, behind a fidelity guard: if stripping would remove too much real content, the full page is returned instead and a warning is added. Image URLs are rendered as their alt text (the full image list stays available via outputs: ["images"]). Set false for the full page, nothing stripped. See Markdown quality. |
truncate_data_arrays |
boolean |
unset | Collapse long runs of numeric literals (raw embedding vectors, tensor dumps printed in notebook output cells) to a leading sample plus a count, e.g. ... [truncated 1520 of 1536 values]. Unset follows only_main_content: on when the page is being curated, off when you asked for the page as-is. Set true or false to control it explicitly. |
allow_degraded |
boolean |
false |
Return the render even when it is an anti-bot challenge or block page carrying no page content. By default such a render fails with 502 instead of delivering the interstitial's text as if it were the page. |
direct_download |
boolean |
false |
Return the artifact bytes directly as the HTTP response body instead of a JSON envelope. Requires exactly one artifact-producing output. Single-URL requests only, because the batch endpoint rejects it with 422. See Direct download. |
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 of the page. With only_main_content (default true) site chrome such as navigation, header, footer, sidebars, cookie banners, and hidden nodes is stripped behind a fidelity guard, and image URLs are rendered as their alt text. Code blocks keep their language on the fence (```python) in both modes. Set only_main_content: false for the full page. See Markdown quality. |
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). |
Markdown quality#
Before the page is converted, the rendered DOM is normalised so that the Markdown reflects what a reader sees rather than how the page was built. This runs on every render, so the result does not depend on which extraction strategy wins for a given page.
Always applied, in both only_main_content modes:
- Code fences keep their language. The language is read from
whichever convention the site uses (
class="language-python",data-lang, a barelanguageattribute, or a highlighter wrapper) and normalised, so```pythoncomes through instead of a bare fence. - Card links stay readable. A link wrapping a heading and a
description becomes a linked title followed by its description,
instead of one run-together link such as
[DatabaseSupabase provides a full Postgres database...]. The destination URL is preserved. - Headings stay on one line. A heading whose text sits inside a
nested element no longer emits a bare
##with the text stranded below it. - Adjacent elements no longer concatenate. Layouts that space
their items with CSS rather than whitespace produced
YesNoandEvaluationDeploymentProduction; those now read as separate words. - Invisible characters are removed: zero-width spaces used as anchor labels, soft hyphens, and Private Use Area glyphs from icon fonts, which arrive as unprintable tokens.
- Empty elements are dropped: icon-only
<i>elements that rendered as stray__, and links whose label is empty.
Additionally, with only_main_content: true:
- Interface controls are removed: buttons, tab strips, keyboard shortcut hints, "Copy page" / "On this page" actions, and "Was this page helpful? Yes/No" rating widgets. A control that carries real content (an FAQ question, a clickable card body) is kept.
- Screen-reader-only text is removed: skip links, and the "Section titled ..." labels many documentation themes attach to every heading.
- Site-declared non-content is honoured: blocks marked
data-nosnippet,data-pagefind-ignore, ordata-noindex, unless they contain headings or code. - Duplicate blocks are collapsed: responsive designs that ship a desktop and a mobile copy of the same bar, and carousels that pre-render every frame, appear once.
- Breadcrumbs and eyebrow labels above the page title are dropped.
Deferred content is deliberately kept: an inactive tab panel inside the content region holds a real code sample (the Python example in one tab, the JavaScript one in another), so both reach the Markdown rather than only whichever tab happened to be selected at render time.
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 (Production+),
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 live: returns a warning, omitted |
contacts |
-- | Not yet live: returns a warning, omitted |
technologies |
-- | Not yet live: returns a warning, omitted |
To be straight about it: prices, contacts, and technologies are
reserved names. Request one today and it does not error. The name 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 fills the schema, and it
fires only when all of these hold: you sent a schema, your plan
carries the LLM tier (Indie 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 project spend draws on your monthly AI-credit balance ($5 / $15 / $40 per month on Indie / Studio / Production; unused credits roll over). LLM extraction spends credits, not ops. If a cap is hit or the balance is exhausted, 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. Scores below 0.40 mean a failed render: an anti-bot page, a login wall, an HTTP error page, a soft 404, or an empty shell. Consult deductions for the reason and status_code for the upstream status. |
status_code |
integer |
HTTP status of the final main-document response (e.g. 200, 404). null when unknown. |
deductions |
object |
Named render-quality deductions that fired, e.g. {"http_error": 0.7}, {"soft_404": 0.65}, {"login_wall": 0.65}. Empty on a clean render. |
options_echo |
object |
Echo of the request options the server honoured. Secrets are redacted to booleans (auth_provided, cookies_provided, headers_provided, js_code_provided, schema_provided, pdf_options_provided). The plain options (outputs, only_main_content, truncate_data_arrays, allow_degraded, extract, cache_mode, mobile, respect_robots, direct_download, wait_for, wait_timeout_ms, viewport, block_resources) are echoed as honoured. truncate_data_arrays is echoed as the resolved boolean, so leaving it unset still tells you which way it went. |
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, an only_main_content fallback to the full page, a note that long numeric data arrays were truncated. |
Retrieve an operation#
Signed URLs expire after 15 minutes. To download an output later, re-fetch the operation and perceive re-signs every URL from the stored object keys. No re-render happens, so this does not consume ops.
curl https://api.enconvert.com/v2/perceive/per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7 \
-H "X-API-Key: sk_your_private_key"
An unknown operation ID, or one that belongs to a different project,
returns 404. Existence is never leaked across projects.
Direct download#
By default every file output comes back as a pre-signed URL you fetch in
a second request. Set direct_download: true on the POST to skip the
envelope: the HTTP response body is the artifact bytes, with no JSON,
no signed URL, and no second fetch. The request must produce exactly one
artifact output (outputs: ["markdown"], outputs: ["pdf"], …),
otherwise it is rejected with 400. The metadata that would have been
in the JSON rides in response headers instead: Content-Disposition,
X-Operation-Id, X-Object-Key, X-Cache-Hit, X-Render-Quality,
X-Source-Status-Code, X-Content-Hash, and X-Warnings-Count.
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_your_private_key" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/blog/post",
"outputs": ["markdown"],
"direct_download": true
}' \
-o post.md
The GET endpoints stream stored artifacts the same way:
GET /v2/perceive/{operation_id}?direct_download=true&output=markdownstreams one artifact from a past operation.outputis required when the operation produced more than one artifact. An artifact past your plan's retention window answers410.GET /v2/perceive/batch/{job_id}?direct_download=truestreams the batch ZIP foroutput_mode: "zip"batches whose archive is ready, and answers400otherwise.
direct_download is for single URLs only: POST /v2/perceive/batch
rejects it with 422. Set output_mode to "zip" and download the
archive instead. See Batch perception.
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_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_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, partial
(some URLs succeeded, some failed), or canceled. Set output_mode to
zip to bundle every artifact into a single ZIP, returned under the zip
field once the batch finishes.
Durable and resumable#
Batches are restart-safe. If the service restarts while a batch is in flight, the batch resumes automatically and re-renders only the URLs that had not finished, so already-completed URLs keep their artifacts. You never need to resubmit a batch because of a restart.
Cancelling a batch#
DELETE /v2/perceive/batch/{job_id} cancels a running batch. The worker
stops between URLs, so URLs already rendered keep their results and the
rest are left unstarted. The call is idempotent, so cancelling a batch
that has already finished simply returns its current state, and the
batch's status becomes canceled.
curl -X DELETE https://api.enconvert.com/v2/perceive/batch/{job_id} \
-H "X-API-Key: sk_your_private_key"
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 bills one op against your monthly ops allowance. The allowance meters operations rather than browser renders, so the cache saves you render time, not ops.
Code examples#
curl: Markdown only#
curl -X POST https://api.enconvert.com/v2/perceive \
-H "X-API-Key: sk_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_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_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_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_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.
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 ops allowance 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, an unknown request key). |
422 Unprocessable Entity |
proxy_url, geolocation, or action_chain was sent. All three are reserved for a later release. |
500 Internal Server Error |
The render failed. The message includes the operation_id to quote to support. |
502 Bad Gateway |
Every engine was blocked and the origin served an anti-bot challenge with no page content behind it. Retry later, or send allow_degraded: true to receive the challenge page as-is. |
Unknown request keys are rejected with a 422 naming the field, on
/v2/perceive, /v2/perceive/batch, /v2/discover, and /v2/lookup
alike. They are never silently ignored. Every 422 body carries a
top-level errors array of human-readable messages alongside the raw
detail list.
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) |
| Inline batch threshold | 10 URLs (larger batches run asynchronously) |
| Result cache TTL | 1 hour |
| Signed URL expiry | 15 minutes |
| Monthly ops (shared across every endpoint) | 500 / 3,000 / 15,000 / 50,000 by tier; see pricing |
Frequently asked questions#
How do I convert a web page to Markdown with a REST API?#
Send POST /v2/perceive with {"url": "...", "outputs": ["markdown"]}. The page is rendered in headless Chrome and the response carries a pre-signed download URL for the Markdown file. By default only_main_content strips site chrome so you get the article, not the navigation; set "only_main_content": false for the full page, or add "direct_download": true to receive the Markdown bytes directly in the response body.
Can I get a screenshot and Markdown from the same render?#
Yes. outputs accepts any combination, so ["markdown", "screenshot"] (or screenshot_full_page for the entire scroll height) produces both from a single browser render. You never pay for the same page twice in one call.
Does /v2/perceive render JavaScript pages?#
Yes. Every request runs a real headless-Chrome render: cookie banners are dismissed, the page is scrolled to trigger lazy-loaded content, and you can control the page before capture with wait_for (a CSS selector or JS expression), js_code, and block_resources.
Why did my signed download URL stop working?#
Signed URLs expire after 15 minutes (expires_in: 900). Re-fetch the operation with GET /v2/perceive/{operation_id} to get freshly signed URLs. No re-render happens and no ops are consumed.
Does a cached result still count against my quota?#
Yes. A cache hit bills one op, because the monthly allowance meters operations rather than browser renders. Set cache_mode to bypass to skip the 1-hour cache, or refresh to render fresh and replace the cached entry.