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.

  1. 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.
  2. 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 under only_main_content. See Markdown quality.
  3. Extract. If you requested structured output, perceive runs a heuristic pass for page metadata, JSON-LD, headings, and tables. If you also send a schema and your plan carries the LLM tier, an LLM fills the schema when the heuristic pass comes up short.
  4. 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 deductions for the reason and status_code for 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 bare language attribute, or a highlighter wrapper) and normalised, so ```python comes 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 YesNo and EvaluationDeploymentProduction; 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, or data-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.
Reserved, not yet live. 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 warnings rather than overspending. On a plan without the LLM tier, you get heuristic structured data 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=markdown streams one artifact from a past operation. output is required when the operation produced more than one artifact. An artifact past your plan's retention window answers 410.
  • GET /v2/perceive/batch/{job_id}?direct_download=true streams the batch ZIP for output_mode: "zip" batches whose archive is ready, and answers 400 otherwise.

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.