---
title: Native API
description: POST /scrape - the native TRAWL endpoint with full tier control.
---
# `POST /scrape` - Native API
The native endpoint exposes TRAWL's full feature set: tier capping, session IDs, and rich timing metadata.
Create and manage named sessions through [Browser Sessions](/api-reference/browser-sessions). They preserve cookies and localStorage across requests and always use the browser.
For an existing session, `POST /sessions/:id/scrape` accepts the same options and returns the same response as `/scrape` with `sessionId`. Omit `sessionId` from the body, or set it to the ID in the URL; conflicting IDs return 400.
## Request
```typescript
interface ScrapeRequest {
url: string
maxTimeout?: number // ms, default SCRAPE_MAX_TIMEOUT_MS (60000)
solveEmbeddedCaptchas?: boolean // attempt embedded form widgets, default true
skipHttp?: boolean // skip Tier 1 (plain fetch), default false
maxTier?: 1 | 2 | 3 | 4 // cap escalation at this tier
sessionId?: string // existing isolated browser session ID
headers?: Record // custom headers forwarded to the target
proxy?: string // per-request proxy override for Tier 3/4
screenshot?: boolean // capture a viewport screenshot, default false
screenshotFullPage?: boolean // capture the whole page within fixed canvas limits
screenshotWaitForSelector?: string // wait up to 10 seconds for a visible CSS selector
screenshotSelector?: string // capture the first visible matching element
contentWaitForSelector?: string // wait before reading browser HTML, independent of response capture
consoleLogs?: boolean // capture browser console messages, default false
networkLogs?: boolean // capture per-request resource timings, default false
redirectChain?: boolean // capture the main document's redirect chain, default false
captureResponses?: string[] // URL patterns whose response bodies to capture, default none
settleTimeout?: number // ms to wait after load for a match, default 15000
waitForSelector?: string // CSS selector that ends the settle window early
blockedEvidence?: boolean // return the challenge wall on the error, default false
mhtml?: boolean // assemble an MHTML archive of the page, default false
ignoreCertificateErrors?: boolean // load the page even if its TLS certificate fails verification, default false
followMetaRefresh?: boolean // follow bounded meta refresh redirects, default false
allowShortContent?: boolean // return a browser page under 100 characters instead of failing it, default false
favicons?: boolean // fetch the page's declared icons from inside the page, default false
}
```
Meta refresh following is opt-in. Without `followMetaRefresh`, existing HTTP and browser
behavior is preserved; browsers may still follow refreshes naturally. With the flag enabled,
relative targets use the document's base URL, and existing cookies, proxy routing, outbound
validation and TLS checks remain active. The final URL and requested `redirectChain` describe
the browser landing. The flag is currently available only through the native API, not MCP
or the FlareSolverr `/v1` adapter.
When combined with `ignoreCertificateErrors`, a cross-host meta refresh can still be refused
by the crossed-landing guard: its HTTP probe does not follow meta refreshes. This option does
not relax that guard.
### Fields
| Field | Type | Default | Description |
| --------------------------- | -------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `url` | string | - | The URL to scrape |
| `maxTimeout` | number | `SCRAPE_MAX_TIMEOUT_MS` (60000) | Max total time in milliseconds; an explicit value overrides the deployment default |
| `skipHttp` | boolean | false | Skip Tier 1 (go straight to browser) |
| `maxTier` | 1-4 | 4 | Never escalate beyond this tier |
| `sessionId` | string | hostname | Override the Redis session key |
| `headers` | object | - | Custom headers forwarded to the target across all tiers - see [Custom Headers](/api-reference/custom-headers) |
| `proxy` | string | - | Strict proxy route for this request. HTTP(S) proxies are used by Tier 1 and browser tiers; SOCKS proxies skip Tier 1. Direct Tier 1 and the unproxied Tier 2 cache are never used - see [Configuration ยง Proxies](/getting-started/configuration#proxies) |
| `screenshot` | boolean | false | Capture a base64 JPEG of the viewport on browser tiers (2-4) and return it as `screenshot`. Tier 1 never produces one; use `skipHttp: true` to force a browser attempt |
| `screenshotFullPage` | boolean | false | Capture the whole page when `screenshot: true`, up to 6,000 pixels high and 12 million pixels total. Oversized captures leave `screenshot` unset |
| `screenshotWaitForSelector` | string | - | Wait up to 10 seconds for a visible CSS selector before capturing a screenshot; a timeout leaves `screenshot` unset |
| `screenshotSelector` | string | - | Capture the first visible element matching a CSS selector when `screenshot: true`. Element captures keep the 6,000-pixel height, 12-million-pixel canvas and JPEG byte limits. Cannot be combined with `screenshotFullPage` |
| `contentWaitForSelector` | string | - | On browser tiers, wait up to 10 seconds within the request budget for a visible CSS selector before reading the final HTML. A timeout returns the current HTML. Use `skipHttp: true` to ensure a browser tier is used |
| `consoleLogs` | boolean | false | Capture the page's console messages on the browser tiers (2-4) and return them as `consoleLogs` |
| `networkLogs` | boolean | false | Capture per-request resource timings on the browser tiers (2-4) and return them as `networkLogs` |
| `redirectChain` | boolean | false | Capture the URLs the main document walked on the browser tiers (2-4) and return them as `redirectChain` |
| `captureResponses` | string[] | - | URL patterns - a substring, or a glob matched against the whole URL when the pattern contains `*` or `?` - whose response bodies are returned as `capturedResponses` (browser tiers 2-4) |
| `settleTimeout` | number | 15000 | Milliseconds to hold the page open after load waiting for a match; ends early on the first captured body, on `waitForSelector`, or on network idle. Only read alongside `captureResponses` |
| `waitForSelector` | string | - | CSS selector that also ends the settle window early. Only read alongside `captureResponses` |
| `blockedEvidence` | boolean | false | When no tier clears the challenge, attach the wall the last browser tier stopped at to the 500 body as `blockedEvidence`. It is never attached to a successful result - see the note below. The image rides along only when `screenshot` is also set |
| `mhtml` | boolean | false | Assemble a `multipart/related` MHTML archive of the page on the browser tiers (2-4) and return it as `mhtml`. An approximation of "Save as MHTML", not an engine snapshot - see the note below |
| `ignoreCertificateErrors` | boolean | false | Load the page even when its TLS certificate fails verification (expired, self-signed, issued for another host) instead of failing the fetch. Off by default, so every other caller keeps a verified connection. An unverified connection no longer proves whose page came back, so the request also gets the crossed-landing guard - see the note below |
| `followMetaRefresh` | boolean | false | Follow HTTP(S) meta refresh redirects with delays up to 10 seconds, allowing at most 3 browser-followed hops. Tier 1 escalates eligible HTML forwarders to a browser; Tiers 2-4 follow in the existing context. Reload-only refreshes are ignored. Navigation failures, loops, and exhausted budgets fail the attempt instead of returning the forwarder. |
| `allowShortContent` | boolean | false | Return a browser-tier (3-4) document under 100 characters as a success instead of failing it with `page returned empty content`, for callers that judge a tiny page themselves. Browser network-error pages and challenge walls are still rejected. |
| `favicons` | boolean | false | Fetch the apex `/favicon.ico` and every declared link whose `rel` contains `icon` from inside the page on the browser tiers (2-4) and return them as `favicons`. Tier 1 never produces them; use `skipHttp: true` to force a browser attempt - see the note below |
### Embedded CAPTCHA widgets
Detected blocking challenges must still pass the normal solver and wall checks. Afterward, the browser tiers
also attempt embedded form widgets by default. Set `solveEmbeddedCaptchas: false` when you
only need the page content and do not need a form token. MCP `scrape` accepts the same flag.
This does not bypass interstitial checks or submit forms.
Widget solving is best effort and leaves time within `maxTimeout` to capture the loaded page.
A successful scrape confirms page delivery, not that every embedded widget was solved.
Only verified solutions appear in `captchasSolved`; an empty token is never reported as solved.
For Turnstile, a token in a response field removed by a callback still counts as observed.
Widget disappearance alone is not treated as a solution.
The FlareSolverr `/v1` adapter defaults this flag to `false` for page-access compatibility.
Captured response bodies, headers, console messages, URLs, blocked-page HTML, screenshots,
favicons, and MHTML archives can contain credentials, tokens, personal data, or active scripts.
Treat these opt-in fields as sensitive and open archives only when you trust their source.
## Response
```typescript
interface ScrapeResult {
url: string // final URL after redirects
html: string
cookies: Cookie[]
userAgent: string
statusCode: number
tier: 1 | 2 | 3 | 4 // which tier succeeded
sessionCached: boolean // true if a cached session was used
timings: TierResult[] // per-tier attempt history
totalMs: number
captchasSolved?: string[] // captcha types solved on the page itself (e.g. ['turnstile'])
proxyUsed?: boolean // true if the winning tier routed through a proxy, including cached Tier 2 reuse
screenshot?: string // base64 JPEG of the viewport, only when requested and a browser tier served the page
consoleLogs?: ConsoleLogEntry[] // console messages, only when requested and a browser tier served the page
networkLogs?: NetworkLogEntry[] // resource timings, same presence rules as consoleLogs
redirectChain?: string[] // URLs the main document walked, same presence rules as consoleLogs
capturedResponses?: CapturedResponseEntry[] // matched response bodies, [] when nothing matched
mhtml?: string // bounded multipart/related archive, only for requested successful HTML browser results
certificateError?: string // why the certificate failed verification, only when ignoreCertificateErrors was set and a verified attempt observed it
favicons?: FaviconEntry[] // the page's icons, only when requested and a browser tier served the page
}
interface ConsoleLogEntry {
level: 'SEVERE' | 'WARNING' | 'INFO' | 'DEBUG'
message: string
timestamp: number // epoch milliseconds
source: string // console type that produced the message (e.g. 'error')
}
interface NetworkLogEntry {
name: string // request URL
entryType: 'navigation' | 'resource'
startTime: number // ms since the capture attached
duration: number // ms from request start to last byte, 0 if it never completed
initiatorType: string // resource type (document, script, xhr, image, ...)
transferSize: number | null // response body + headers on the wire
encodedBodySize: number | null
decodedBodySize: number | null // always null - knowing it would mean reading every body
}
interface CapturedResponseEntry {
url: string
status: number
headers: Record
body: string | null // text, or base64 when binary/unknown; null when unreadable
base64Encoded: boolean
truncated: boolean // body trimmed to CAPTURE_MAX_BODY_BYTES
error?: string // why the body is null (read failed, budget spent, ...)
}
interface FaviconEntry {
url: string // absolute icon URL, or `data:` for an inline icon
contentType?: string // the icon response's Content-Type
data?: string // base64 bytes, no data: prefix; absent when the icon could not be read
error?: string // why `data` is absent (http-403, a fetch error, a byte cap, ...)
}
interface TierResult {
tier: 1 | 2 | 3 | 4
status: 'success' | 'blocked' | 'needs-js' | 'timeout' | 'error' | 'skipped'
durationMs: number
reason?: string
}
```
## Blocked-Outcome Evidence
A scrape that runs a browser but never clears the challenge is still a failure: it answers
**500**, and `ScrapeResult` never carries a challenge wall dressed up as content.
Google Search's `/sorry` and `/sorry/` URLs on `google.com`, `www.google.com`, `ipv4.google.com` and `ipv6.google.com` are recognized as challenge destinations. Tier 1 reports `google-sorry-challenge` and escalates; browser tiers report `google-sorry-persistent` if the final URL remains there after the existing solver attempts. This does not guarantee a successful Google CAPTCHA solve.
`blockedEvidence: true` attaches the wall to that failure instead, so a caller can tell
"blocked by a challenge" from "TRAWL broke" and can keep the page as evidence:
```typescript
interface BlockedEvidence {
tier: 2 | 3 | 4 // which browser tier rendered the wall
status: 'blocked' | 'timeout'
reason?: string // identical to the matching timings[].reason
url: string // where the browser stood, after any challenge redirects
statusCode?: number
html: string // the wall's markup
htmlTruncated?: boolean // html is the head of a page over BLOCKED_EVIDENCE_MAX_HTML_CHARS
screenshot?: string // base64 JPEG, only when `screenshot` was also requested
}
```
The wall reported is the **last attempt from the deepest** browser tier that rendered one -
a Tier 3 wall is replaced by Tier 4's when Tier 4 also fails, and a later proxy attempt
replaces an earlier one in the same tier. Some failures have no page to hand
back at all and carry no evidence: Tier 1 (a plain HTTP fetch, no browser), a tier that
could not open a context or a page, a hard network failure (DNS, connection refused, TLS),
an `about:neterror` page, an empty document, and a pool that was exhausted or still
initializing. In those cases `timings` alone tells the story.
Capturing the wall never changes the outcome: markup is truncated to its configured cap,
the optional screenshot respects the remaining request budget, and a capture failure
degrades or omits `blockedEvidence`. The status code and `timings` are unchanged either way.
## MHTML Archives
`mhtml: true` returns a single bounded `multipart/related` document for a successful HTML
page: the rendered DOM first, then safely readable stylesheets, scripts, images and fonts
that were observed loading. It is pure 7-bit
ASCII with CRLF line endings, so it can be written straight to a `.mhtml` file, and every
part carries a `Content-Location` so a reader can resolve it back to its URL.
It is an **assembled approximation, not an engine snapshot.** Firefox exposes no
equivalent of Chromium's `Page.captureSnapshot`, so the archive is built from what the
response listener saw. Resources served from cache, fetched before the listener attached,
compressed, missing a valid `Content-Length`, or refused by the browser are absent. These
omissions and anything dropped for a size budget are counted in the
`X-Trawl-Omitted-Resources` header and, up to the configured record cap, listed in a final
`text/plain` part, so an archive that hits a cap is still a valid MHTML that says what it
is missing. Non-HTML responses and roots too large for the total cap leave `mhtml` unset.
The field is excluded from `timings` and tier telemetry. Bounds are tunable via `MHTML_*` - see
[Configuration](/getting-started/configuration#mhtml-archives).
MHTML may contain credentials, personal data and executable JavaScript from the target.
Treat it as sensitive untrusted content; do not log it or open it outside an appropriate
sandbox unless you trust the page.
## Invalid Certificates
`ignoreCertificateErrors: true` lets a page load even though a certificate in its redirect
chain is expired, self-signed or issued for another host - without it the fetch fails
outright and nothing about the page is readable. The relaxation is per request: Tier 1
retries only the TLS hop whose verified attempt failed, Tiers 3 and 4 set it on the temporary
context they create for that one request, and the pooled contexts every other caller uses
stay verified. Tier 2 is skipped for these requests (it replays its session
inside the shared pool context, whose TLS policy cannot be changed per request) and shows up
in `timings` as `skipped`.
When a verified Tier 1 hop failed, its reason comes back as `certificateError`, e.g.
`"DEPTH_ZERO_SELF_SIGNED_CERT: self signed certificate"`. The field is absent when the
certificate verified, when the flag was not set, and when no verified attempt was made
(`skipHttp: true`) - absence means "not observed", not "the certificate was valid".
### The crossed-landing guard
A verified certificate is what normally proves the bytes came from the host that was asked
for. With verification off, a connection that reaches the wrong origin would be accepted in
silence and another site's page returned under the requested domain's name. So an opted-in
request also runs a landing check: if the scrape ends on a host the requested URL is not part
of, TRAWL fetches the same URL over the same egress with a plain HTTP client. Only when that
probe stays on the requested host is the landing treated as crossed; the tier's attempt is
recorded as `crossed-landing on ` and the ladder moves to the next tier, which reaches
the origin over a different egress. A probe that fails, or that lands off-host as well (an
ordinary redirect, or cloaking), is inconclusive and the page is kept. The same off-host
landing reached from two independent egresses is taken as a redirect only a browser performs
and accepted. The probe runs inside what is left of the request's `maxTimeout`, sharing one
deadline across every redirect, and it validates every hop against the same outbound policy
the tiers enforce. A spent budget makes the check inconclusive instead of starting new I/O.
If no tier returns an uncrossed page the request fails, and the error names the refused
landing rather than returning another site's page.
An unverified page is untrusted content by definition. The guard establishes only that the
connection reached the host that was asked for; it says nothing about whether that host is
who it claims to be, which is exactly what the unverified certificate failed to establish.
## Favicons
A page may declare several icons - size and device variants, `apple-touch-icon`,
`mask-icon`, `shortcut icon` - and the browser renders exactly one of them. `favicons: true`
returns the whole declared set instead, plus the apex icon, so a caller that wants the
icons a site actually publishes is not limited to the one that happened to fit a tab.
They are fetched **from inside the page**, via `fetch()` in the document's own context, so
each request carries the origin's cookies, the session's challenge clearance and the same
egress the page itself was served over. Fetching an icon afterwards from outside the browser
arrives as a stranger with none of that, which is what a bot wall answers 403 to.
Two things are collected, in this order:
1. The apex `/favicon.ico`, whether or not the page declares it. It is what a browser falls
back to, and a headless browser paints no tab - so it requests at most the single icon it
would have drawn and never the apex one. A page declaring no icon at all yields nothing
whatsoever on the response stream.
2. Every `` whose `rel` contains `icon` - so `apple-touch-icon`, `mask-icon`,
`shortcut icon` and `alternate icon` all count - resolved against the document base. An
inline `data:` icon is fetched the same way and reported as `data:`: its href is the
payload rather than a name, and repeating it next to the bytes decoded from it would carry
the icon twice.
Duplicates are collapsed, and an icon that could not be read is still returned, with `data`
absent and `error` set, so "the site declares no icon there" stays distinguishable from "we
could not fetch it". Response bodies are read as a bounded stream: an icon declaring more
than `FAVICON_MAX_BYTES` is refused on its `Content-Length`, and an unknown-size or dishonest
response is cancelled as soon as its streamed bytes cross the same cap. An inline `data:`
href too long to decode within that cap is refused before it is fetched. URLs, content types,
and error messages are bounded separately. Collection is capped at `FAVICON_TIMEOUT_MS` and
at whatever is left of the request's own `maxTimeout`, and is skipped once that budget is
spent. The field is excluded from `timings` and tier telemetry.
Two limits are worth knowing. The in-page `fetch` is subject to the page's CORS policy, so a
cross-origin icon whose host sends no `Access-Control-Allow-Origin` fails and is reported as
an error entry. And collection runs after the response listeners are drained, so these
fetches never appear in `networkLogs`, `capturedResponses` or the MHTML archive. Bounds are
tunable via `FAVICON_*` - see [Configuration](/getting-started/configuration#favicons).
Icon bytes come from the target like any other scraped content. Treat them as untrusted:
an `image/svg+xml` icon is a document that can carry script.
Tier 1 is a plain HTTP fetch and never produces `favicons`. Set `skipHttp: true` when favicon
collection is required rather than merely accepted when escalation reaches a browser tier.
## Examples
### Minimal request
```bash
curl -s -X POST http://localhost:8191/scrape \
-H "Content-Type: application/json" \
-d '{ "url": "https://nowsecure.nl" }' | jq '{tier, totalMs, sessionCached}'
```
### Force browser only (skip plain HTTP)
```bash
curl -s -X POST http://localhost:8191/scrape \
-H "Content-Type: application/json" \
-d '{
"url": "https://nowsecure.nl",
"skipHttp": true,
"maxTier": 3
}'
```
### Inspect timing breakdown
```javascript
const res = await fetch('http://localhost:8191/scrape', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: 'https://nowsecure.nl' }),
})
const result = await res.json()
console.log(`Tier used: ${result.tier}`)
console.log(`Session cached: ${result.sessionCached}`)
console.log(`Total: ${result.totalMs}ms`)
for (const t of result.timings) {
console.log(` Tier ${t.tier}: ${t.status} in ${t.durationMs}ms`)
}
```
### Example response
```json
{
"url": "https://nowsecure.nl",
"html": "...",
"cookies": [
{ "name": "cf_clearance", "value": "abc123...", "domain": ".nowsecure.nl", "path": "/", "expires": 1700003600, "httpOnly": false, "secure": true }
],
"userAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)...",
"statusCode": 200,
"tier": 2,
"sessionCached": true,
"timings": [
{ "tier": 1, "status": "needs-js", "durationMs": 85 },
{ "tier": 2, "status": "success", "durationMs": 512 }
],
"totalMs": 600
}
```
## Error response
HTTP status codes:
| Code | Meaning |
| ---- | -------------------------------------------------------------------- |
| 200 | `tier` succeeded |
| 400 | Malformed request body |
| 429 | Pool exhausted - all browsers busy past `BROWSER_ACQUIRE_TIMEOUT_MS` |
| 503 | Browser pool initializing |
| 500 | Internal error |
For 429 pool-exhaustion errors, the body is a **FlareSolverr v2 envelope** (same shape `/v1` uses) so clients can parse both endpoints uniformly:
```json
{
"status": "error",
"message": "Browser pool saturated, retry shortly",
"startTimestamp": 1700000000000,
"endTimestamp": 1700000015000,
"version": "2.0.0",
"solution": {
"url": "https://nowsecure.nl",
"status": 0,
"headers": {},
"response": "",
"cookies": [],
"userAgent": ""
}
}
```
For 400 / 503 the body is the native shape `{ "error": "Human-readable message" }`.
For 500 errors raised after at least one tier was attempted, the body also includes the
per-tier attempt history, so a failed request is still fully diagnosable from the response
alone - no need to check server logs:
```json
{
"error": "All tiers exhausted. Last failure: http-403",
"timings": [
{ "tier": 1, "status": "needs-js", "durationMs": 50, "reason": "cloudflare-challenge" },
{ "tier": 3, "status": "blocked", "durationMs": 2942, "reason": "http-403" },
{ "tier": 4, "status": "blocked", "durationMs": 7890, "reason": "http-403" }
]
}
```
When the request set `blockedEvidence: true` and a browser tier rendered a challenge wall,
the same body also carries that page - see
[Blocked-Outcome Evidence](#blocked-outcome-evidence):
```json
{
"error": "All tiers exhausted. Last failure: cloudflare-persistent",
"timings": [
{ "tier": 1, "status": "needs-js", "durationMs": 50, "reason": "cloudflare-challenge" },
{ "tier": 3, "status": "blocked", "durationMs": 2942, "reason": "cloudflare-persistent" },
{ "tier": 4, "status": "blocked", "durationMs": 7890, "reason": "cloudflare-persistent" }
],
"blockedEvidence": {
"tier": 4,
"status": "blocked",
"reason": "cloudflare-persistent",
"url": "https://nowsecure.nl/",
"statusCode": 403,
"html": "Just a moment......",
"screenshot": "/9j/4AAQSkZJRgABAQAA..."
}
}
```