ScrapeNest API
One bearer token. Multi-engine web search, RAG answers with citations, Google Maps reviews, audio transcripts. Clean JSON in, clean JSON out. Failures don’t bill.
https://scrapenest.dev. Every /v1/* endpoint
takes JSON and returns JSON. Responses include credits_charged and (where
relevant) cache_hit so you can predict cost before you scale.
https://scrapenest.dev/llms.txt. It is the entire API
(endpoints, schemas, credit prices, MCP setup) in one plain-text file built for LLMs.
Paste that URL into ChatGPT, Claude, or your coding agent and it can write working
ScrapeNest calls on the first try.
Quickstart
Sign up at /signup, mint a key on /dashboard/keys (shown once, so copy it), then make your first request:
curl -X POST https://scrapenest.dev/v1/search \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"openai gpt-5 release notes","num_results":5}'
import os, httpx
API_KEY = os.environ["SCRAPENEST_API_KEY"]
r = httpx.post(
"https://scrapenest.dev/v1/search",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"query": "openai gpt-5 release notes", "num_results": 5},
timeout=30.0,
)
r.raise_for_status()
print(r.json()["results"][0]["title"])
const API_KEY = process.env.SCRAPENEST_API_KEY;
const r = await fetch("https://scrapenest.dev/v1/search", {
method: "POST",
headers: {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: "openai gpt-5 release notes", num_results: 5 }),
});
if (!r.ok) throw new Error(`HTTP ${r.status}`);
console.log((await r.json()).results[0].title);
That’s the full ergonomics: one Bearer header, one JSON body, one JSON response. The same shape works for every endpoint below.
null. Response bodies
are serialised with nulls stripped, so a field with no value is absent from the
JSON. Read them defensively — body.get("error_code") in Python,
body.error_code in JS — never body["error_code"], which
raises KeyError. Every response example on this page shows only the keys that
are actually present. GET /v1/me is the one exception: it returns its full
shape, nulls included.
Authentication
Every /v1/* endpoint uses a Bearer token in the Authorization header:
Authorization: Bearer sn_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Keys are minted at /dashboard/keys. The plaintext is shown once; we only store a salted hash and the displayable prefix. Lost the plaintext? Mint a new key and revoke the old one. Treat keys like passwords: never embed them in browser bundles or mobile apps.
Sanity-check a key with the free GET /v1/me endpoint (below).
If you get 401, the key is wrong, revoked, or you forgot the Bearer
prefix. /v1/me never returns 402: it isn’t credit-gated, so it
answers even at a zero balance and reports the remaining credits in
credit_balance. A 402 on any billed endpoint means that balance is
exhausted — top up at /dashboard/billing.
Errors
All errors are JSON with a consistent shape:
{
"error": {
"code": "<short_code>",
"message": "<human-readable explanation>",
"details": []
}
}
Every failure (validation, auth, rate-limit, upstream) uses this envelope.
details is an array that’s only present on validation errors and lists
the offending fields. Rate-limit errors additionally include source,
scope, limit, window_seconds, retry_after,
and reset_at alongside code/message, and set the
Retry-After response header.
Three endpoints stream their response — /v1/scrape/screenshot,
/v1/scrape/pdf, and the synchronous
/v1/scrape/google-maps/reviews. Once the connection is held open with
keep-alive whitespace, the status line is already committed to 200, so any
error after that point arrives inside the body as
{"error": {...}, "http_status": <N>} rather than as a real
4xx/5xx status line.
Screenshot and PDF hold a 20-second fast path before streaming begins. A
render that resolves inside that window — the common case — returns a normal
status line and the plain {"error": {...}} envelope, with no
http_status key. Only slower renders take the in-body form. Sync Maps reviews
has no fast path, so every error on it is in-body, including
4xxs like place_id_format and place_not_found.
Write clients to handle both: branch on the transport status code first, then, when it is
200, check for an error key before trusting the body. Auth,
rate-limit, and request-validation errors always fire before any of this and keep their
real status line on all three endpoints.
| HTTP | Code | Meaning | What to do |
|---|---|---|---|
400 | unsafe_url | URL points at a private/internal/loopback host. | Send a public http(s) URL. |
400 | redirect_to_unsafe_url | Target redirected the fetch to a private/internal address. | Use a different URL; we won't follow the redirect. |
400 | cookies_invalid | On /v1/audio/transcript only: the cookies field isn’t valid Netscape cookies.txt content. | Re-export and resend full file contents, including the # Netscape HTTP Cookie File header. |
400 | invalid_run_id | run_id is not a UUID. | Use the value from the 202 response. |
400 | place_id_format | On Google Maps endpoints: the place URL / ID isn’t a recognised format. | Pass a valid Maps place URL or CID. |
401 | unauthorized | Missing or invalid bearer token. | Check the key in /dashboard/keys. |
401 | login_required | On /v1/audio/transcript: the source (typically a YouTube Short or gated upload) requires a logged-in session. | Pass Netscape-format cookies from a burner account (see YouTube cookies). |
402 | insufficient_credits | Credit balance is exhausted. | Top up or upgrade at /dashboard/billing. |
402 | overage_cap_reached | Account hit the negative-balance floor (5,000 credits). | Top up at /dashboard/billing or wait for the monthly credit reset. |
402 | bandwidth_cap_exceeded | Free-tier daily bandwidth budget exhausted. | Upgrade to any paid plan to remove the cap. |
403 | no_owner | API key has no owning account (key revoked or detached). | Generate a new key in /dashboard/keys. |
403 | email_not_verified | Account’s email isn’t verified. | Click the verification link or hit /verify/resend. |
403 | account_suspended | Account suspended. | Contact [email protected]. |
402 | team_seat_blocked | The team is over its plan’s seat cap. | Upgrade the plan or remove members. |
404 | run_not_found | Async run doesn’t belong to this customer, or has expired. | Confirm the key + 7-day expiry. |
404 | not_found | Unknown route, or the requested place/resource doesn’t exist. | Verify the path, method, and inputs. |
404 | place_not_found | On Google Maps: the place_id didn’t resolve to a place with a reviews feed. An unknown FID fails within seconds. | Re-check the place_id, or pass place_url with the canonical Maps URL. |
405 | method_not_allowed | Wrong HTTP method on a valid /v1 route (e.g. GET on a POST-only endpoint). | Use the method documented for the endpoint; check the Allow response header. |
422 | validation_error | Request body has missing or invalid fields. | The response’s details array names each offending field. Fix and retry. |
422 | country_unavailable | On /v1/scrape/url: no residential exit is available for the requested country right now. Not billed; retryable: false. | Omit country or pick another country. |
422 | selector_not_found | On /v1/scrape/screenshot: nothing renderable matched selector. | Loosen the selector or omit it. Past the 20s fast path this arrives in-body with "http_status": 422. |
422 | target_error | On screenshot / PDF: the target itself refused the render. | Retry once, or try a different URL. Same in-body caveat as above. |
429 | rate_limited | Per-minute or per-day rate limit exceeded. | Honour Retry-After and back off. |
429 | too_many_active_runs | Too many async runs already queued or running for your plan. | Wait for one to finish (Retry-After: 30) or upgrade. See Rate limits. |
429 | abuse_throttled | Account temporarily throttled by abuse-detection (e.g. excessive refund rate). | Wait the indicated retry_after; contact support if persistent. |
500 | server_error | Unexpected server error. | Retry with backoff; contact [email protected] if it persists. |
502 | upstream_failed | Target site returned an error or could not be fetched. | Retry once with backoff. Credits are not consumed on errors. |
200 (in-body) | pdf_failed | Target could not be rendered to PDF. Streamed: arrives in-body with "http_status": 502 (or 504 on render timeout) inside a 200. | Branch on the in-body http_status, then retry once or try a different URL. |
200 (in-body) | screenshot_failed | Target could not be screenshotted. Streamed: arrives in-body with "http_status": 502 (or 504 on render timeout) inside a 200. | Branch on the in-body http_status, then retry once or try a different URL. |
502 | no_sources | /v1/search/answer couldn’t find usable sources. | Rephrase the query. |
502 | rpc_parse_failed | Google Maps returned a malformed response. | Retry once; if persistent, file a bug. |
502 | rpc_unexpected_body | Google Maps returned an unexpected (non-JSON) response. | Retry once; often a transient challenge. |
503 | upstream_not_configured | An upstream we depend on isn’t configured server-side. | Contact [email protected]. |
504 | gateway_timeout | Request exceeded the gateway timeout. | Use the /async variant for long-running scrapes. |
In-body failures on /v1/scrape/url. A fetch that reaches our
infrastructure but doesn’t yield usable content returns HTTP 200 with
success: false and a machine-readable error_code — not a
5xx status line. These are never charged. Branch on
success, then on error_code:
| error_code | Meaning | What to do |
|---|---|---|
anti_bot_block | The target’s anti-bot refused the request. | Already retried once in stealth (unless auto_escalate: false). Retry later, or leave auto-escalation on. |
timeout | The fetch exceeded the time budget. | Raise timeout_seconds for slow targets, or retry. |
not_found | The target returned 404/410. | Verify the URL. Delivered not-founds bill the flat 1-credit floor. |
empty_response | The page returned no usable content. | Try render: "browser" for JS-rendered pages, or a different URL. |
rate_limited | The target answered 429 without an anti-bot challenge. | Slow down for that site, then retry. |
upstream_failed | A transient fetch-infrastructure error, or the target answered a bare 503. | Retry once with backoff. |
Endpoint-specific codes. Some endpoints raise their own descriptive
code values beyond the table above: audio availability/geo/DRM
(geo_restricted, audio_not_available, audio_private,
drm_protected), audio resolution and transcription
(audio_resolve_failed — 400 when the URL yields no audio,
502 when the failure is upstream; transcribe_parse_failed —
502 when the transcription provider returns an unusable response), Maps place
resolution (place_id_unresolvable, parse_failed), translate
(unsupported_detected_source, translation_rejected; an unsupported
target or source is a 422 validation_error with
details[]), scrape country pinning (422 country_unavailable,
not retryable, not billed), and answer
(llm_not_configured, llm_upstream_failed). They follow the same
envelope shape.
Retry policy. On 5xx, retry with exponential backoff (1s, 3s, 9s)
up to 3 attempts. On 429, honour Retry-After. On 4xx,
don’t retry: fix the request first.
/v1/scrape/url and
/v1/scrape/batch, boolean fields are strictly typed: they accept
true/false only. Truthy strings ("yes",
"1", "true") and numeric 0/1 are
rejected with 422. full_page on /v1/scrape/screenshot
and landscape on /v1/scrape/pdf are strict the same way. Always
send a JSON boolean.
CORS. /v1/* and /mcp share one policy for browser
clients: no credentials (send the key in Authorization, never cookies); methods
GET, HEAD, POST, DELETE,
OPTIONS; request headers Authorization, Content-Type,
Accept, X-Request-Id, X-Client-Integration,
Mcp-Session-Id, MCP-Protocol-Version, Mcp-Method,
Mcp-Name, Last-Event-ID; exposed response headers
X-Request-Id, Mcp-Session-Id, Retry-After.
Rate limits
Per-key sliding-window limits, enforced server-side. Your plan sets the ceiling:
| Plan | Req / minute | Req / day |
|---|---|---|
| Free | 30 | 500 |
| Starter | 60 | 10,000 |
| Growth | 120 | 50,000 |
| Pro | 300 | 200,000 |
| Scale | 600 | 500,000 |
Both windows are checked on every call; the more restrictive one wins. Polling
GET /v1/scrape/runs/{run_id} counts toward your rate limit too, so don’t
poll faster than every 2–10 seconds. The live values for the calling key are in
GET /v1/me.
Concurrent async runs
Async submissions carry a second, independent limit: how many runs may be
queued or running at the same time. It applies to every
/async endpoint.
| Plan | Concurrent async runs |
|---|---|
| Free | 2 |
| Starter | 5 |
| Growth | 15 |
| Pro | 40 |
| Scale | 100 |
Submitting past the cap returns 429 too_many_active_runs with
Retry-After: 30. Because this bounds in-flight jobs rather than request rate,
backing off alone won’t clear it — wait for a run to finish, or upgrade.
Need higher ceilings than your plan allows? Pro and Scale customers can have per-key limits raised. Email [email protected] with your customer ID.
Caching
Responses are cached on a hash of the full normalized request (URL, schema, render mode, proxy flag, etc). An identical request inside the TTL window:
- returns the cached body with
cache_hit: true; - bills at 1 credit regardless of the original tier
(
credits_charged: 1and, wheninclude_usage=true,usage.breakdown: {"cache_hit": 1}).
Most endpoints accept an optional cache_ttl_seconds override (translate and batch
are never cached; screenshot and PDF cache only when you set a non-zero TTL):
0: bypass the cache, force a fresh fetch.1–86400: store with that TTL (max 1 hour for gas-prices, 30 days for audio).- omitted: use the per-endpoint default — 15 min for
/v1/scrape/url, 10 min for gas-prices, 1 hour for Maps reviews (but 0, i.e. uncached, whenmax_reviewsis above 2000), 30 days for audio.
Search caches by query intent, not a fixed default. On
/v1/search, /v1/search/deep, and /v1/search/answer,
omitting cache_ttl_seconds selects 1 hour for time-sensitive queries
(weather, markets, scores, flights, and wording like “today”,
“latest”, “breaking”) and 24 hours for everything else.
That’s what lets an evergreen query actually accumulate cache hits. If you are
diffing or change-detecting, don’t rely on a short default — pass
cache_ttl_seconds: 0 explicitly.
Degraded search responses. A search response with
partial: true is normally not cached, so an identical retry runs fresh. The
exception is a /v1/search response that is partial only because Google is
busy or unavailable: that is the best the other engines can give
while it lasts, so it is cached for at most 10 minutes. Empty responses are never
cached. Deep and answer responses are cached only when at least one page was fetched.
Only successful fetches are cached. Requests carrying cookies (YouTube
cookies on audio; cookies or custom headers on /v1/scrape/url, screenshot and PDF) or
a session_id are never cached, so session-specific results can’t leak to a
different caller.
Credits & pricing
Every request consumes credits based on the infrastructure it used. The cost is in the
response (credits_charged) and aggregated on
/dashboard/usage.
Generic operations
| Operation | Credits | PAYG $ | Used by |
|---|---|---|---|
| Plain HTTP fetch (datacenter, no JS) | 1 | $0.0002 | Static pages, JSON APIs, sitemaps. |
| Datacenter + JS render | 5 | $0.0010 | SPAs on soft targets. |
| Residential proxy, no JS | 10 | $0.0020 | Geo-blocked / soft anti-bot. |
| Residential + JS | 40 | $0.0080 | Hostile sites, full render. |
| Stealth (anti-bot bypass) | 40 | $0.0080 | /v1/scrape/url with render="stealth". Residential exit + a hardened real browser. Bypasses modern anti-bot protections. |
| Screenshot, viewport | 15 | $0.0030 | /v1/scrape/screenshot on cooperative sites. |
| Screenshot, full page | 25 | $0.0050 | /v1/scrape/screenshot with full_page=true. |
| Screenshot, selector-targeted | 15 | $0.0030 | /v1/scrape/screenshot with selector. |
| Screenshot, stealth fallback | 35 | $0.0070 | /v1/scrape/screenshot auto-escalated when the target blocks the standard browser engine. |
| PDF, standard paper | 25 | $0.0050 | /v1/scrape/pdf, Letter / Legal / A3-A6. |
| PDF, landscape or large paper | 35 | $0.0070 | /v1/scrape/pdf with landscape=true or paper format Tabloid / Ledger / A0-A2. |
| Cache hit (any tier) | 1 | $0.0002 | Repeat inside the TTL window. |
| Batch fetch (per URL) | 1–40 | $0.0002–$0.0080 | /v1/scrape/batch bills each URL at its own resolved tier; the total is the sum. Batch responses are never cached. |
| AI extraction surcharge | +5 | +$0.0010 | Added on top of the page tier when ai_query is set on /v1/scrape/url and the extraction runs on a delivered page. |
| Delivered 404 / 410 (not found) | 1 | $0.0002 | A page that loads but returns 404/410 bills a flat 1-credit floor. Undeliverable fetches (timeouts, blocks, 5xx) bill 0. |
Image sources
When include_images=true on /v1/search or
/v1/search/deep, results are merged
from one or more sources. Default is Bing only (fast). Add Brave for wider coverage
(slower, browser-rendered). Image costs stack on top of the standard
5-credit /v1/search base charged whenever results are delivered.
| Source | Add-on cost | Latency | Notes |
|---|---|---|---|
"bing" (default) | +5 | ~1 s | Bing Image Search. Returns direct image URLs when available. |
"brave" | +40 | ~8 s | Brave Search Images, a real-browser render. Wider sources beyond Bing's index. Runs within its own budget of max(timeout_seconds, 12 s), so a request asking for it can take up to ~12 s. |
Image sources start together with the web search and run concurrently; Bing runs
within the request’s timeout_seconds. A source is billed only when it adds
at least one image; one that times out or returns none is named in note and not
billed, and a timeout marks the response partial. With both sources delivering,
Bing only bills 5 (search) + 5 (bing) = 10;
image_sources: ["bing", "brave"] bills
5 (search) + 5 (bing) + 40 (brave) = 50 and merges deduped results.
Search & AI
| Endpoint | Credit formula |
|---|---|
/v1/search | 5 per call, plus the add-on of each image source that returned images; 0 when nothing is delivered (zero results and zero images, a page past the last result, or a search_deadline_exceeded response). |
/v1/search/deep | 5 + 5 × pages_fetched (pages that returned text), plus the image add-ons when include_images is set; 0 when nothing is delivered. Only usable pages on the query’s subject count: walls, consent and login pages, not-found pages and pages under 200 characters aren’t billed. |
/v1/search/answer | 5 + 5 × sources_fetched + 15 (answer overhead). A source that falls back to its search snippet (including a bot wall, consent gate, not-found page or page under 200 characters) isn’t counted. The call bills a flat 5 instead when: no source page could be fetched at all (the answer is grounded in search-result snippets, so the overhead is waived); none of the retrieved sources is relevant (the model isn’t called and the answer is a fixed “No relevant sources were found…” with no citations); or the answer’s first sentence says the sources don’t address the question. note says which. |
/v1/translate | 1 per call; 0 when the source equals (or auto-detects to) the target. |
Pre-built scrapers
| Endpoint | Credits |
|---|---|
/v1/audio/transcript | 20 per 2 minutes of audio (ceiling-rounded; minimum 20). |
/v1/scrape/google-maps/reviews | 100 per 50 reviews returned (rounded up), with a 40-credit floor when 50 or fewer come back. Hard cap 10,000, newest-first. |
/v1/scrape/gas-prices | 10 per call. Returns up to 50 stations near a US ZIP or City, ST. |
See pricing for monthly plan inclusions ($29 Starter through $599 Scale) and pay-as-you-go ($0.0002 / credit, no commit).
Endpoints
/v1/me
Free
Returns metadata for the API key making the request. Use it to sanity-check auth and read your live rate limits.
curl https://scrapenest.dev/v1/me \ -H "Authorization: Bearer $SCRAPENEST_API_KEY"
import os, httpx
r = httpx.get(
"https://scrapenest.dev/v1/me",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
)
r.raise_for_status()
print(r.json())
const r = await fetch("https://scrapenest.dev/v1/me", {
headers: { "Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}` },
});
console.log(await r.json());
Response
{
"prefix": "sn_AbCdEfGhi",
"label": "production",
"is_active": true,
"is_internal": false,
"rate_limit_per_minute": 60,
"rate_limit_per_day": 10000,
"credit_balance": 48230,
"created_at": "2026-05-01T12:00:00Z",
"last_used_at": "2026-05-14T15:24:11Z"
}
/v1/scrape/url
1–40 credits
Fetches a single URL and returns clean JSON. Use this when no dedicated endpoint exists for the source.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
url | string (http/https) | required | Absolute URL. Private/loopback hosts are blocked. |
render | "auto" | "direct" | "browser" | "stealth" | "auto" | direct = plain HTTP through a proxy (cheap, no JS); browser = headless browser; stealth = residential exit + hardened browser for anti-bot targets (40 credits); auto picks per-target. |
force_proxy | bool | false | Force a higher-tier proxy even when a direct fetch would work. |
auto_escalate | bool | true | When a non-stealth fetch is refused by the target’s anti-bot, automatically retry once in stealth mode. You are only charged the stealth rate if that retry succeeds; a request that stays blocked is free. Set false to keep the original render tier and receive the block as-is. |
timeout_seconds | 1–120 | ~100 | Hard time budget for the whole request, including any render-slot wait and any auto-escalation retry. Lower it (e.g. 20, or a few seconds for quick page reads) to fail fast on hard targets; raise it for slow stealth solves. On timeout the response has success: false and error_code: "timeout". |
block_assets | bool | true | When rendering with a browser, block images/fonts/media to save bandwidth. |
extract | bool | true | Run readability + JSON-LD extraction. Set false to get raw HTML in text. |
ai_query | string | null | Natural-language extraction prompt (e.g. "return the product name, price, and rating"). Populates ai_extract in the response. Adds 5 credits, billed only when the page is delivered and the extraction runs; a failed extraction sets ai_extract_error and adds nothing. |
ai_extract_schema | JSON Schema object | null | Constrains ai_extract to a typed object. Used together with ai_query. |
include_usage | bool | false | Include a usage block in the response with credit totals. |
include_html | bool | false | Include the raw page html in the response alongside the cleaned text. No extra credits. |
return_markdown | bool | false | Convert the page to Markdown and return it in a markdown field. No extra credits. |
markdown_mode | "main" | "full" | "main" | main converts only the main content: navigation, headers, footers, sidebars, forms and inline data: images are dropped; tables are kept. full converts the whole page as served. Also decides what ai_query reads. |
github_api | bool | false | For github.com repository URLs, read GitHub’s API and raw file host instead of rendering the page. Repo root: metadata (description, stars, forks, language, topics, license, default branch, last push, homepage, archived), top-level files and README. /tree/{ref}/{path}: that directory’s listing and README. /blob/{ref}/{path}: the raw file (first 1 MB). /issues/{n} and /pull/{n}: title, state, author, body and the first 30 comments. Content comes back in text (and markdown), structured fields in structured.github, with method: "github_api" and final_url set to the URL you sent. Billed as a plain fetch (1 credit). Other URLs, private or missing repos and GitHub failures fall back to the normal page fetch; under GitHub’s API rate limit, metadata and listings are omitted while READMEs and files are still served. Ignored when include_html, extract_rules or ai_query is set. |
max_chars | 200–2000000 | null | Return at most this many characters of text (and markdown). Longer content comes back as a window; see Long pages below. |
offset | int ≥ 0 | 0 | Where the window starts. Pass back the next_offset of the previous response to read on. |
focus | string (up to 500 chars) | null | Terms, identifiers or a "quoted phrase". When the content exceeds max_chars, the best-matching passages are returned in page order with the skipped spans marked, instead of only the beginning. |
extract_rules | object <string, string | object> (up to 50) | null | CSS-based extraction. Each value is a selector string (returns first match's text) or {"selector": "...", "attr": "href", "all": true} for finer control. Result returned in extracted. No extra credits. |
wait_ms | 0–10000 | null | Extra wait in ms after load before reading the DOM. Browser render only. |
wait_for_selector | string | null | CSS selector to wait for before returning. Browser render only. |
wait_for_timeout_ms | 100–30000 | 5000 | Max ms to wait for wait_for_selector. |
window_width | 320–3840 | 1280 | Browser viewport width. Browser render only. viewport_width accepted as an alias. |
window_height | 240–2160 | 720 | Browser viewport height. Browser render only. viewport_height accepted as an alias. |
country | ISO 3166-1 alpha-2 (2 chars) | null | Route through a residential exit in this country (e.g. "jp"), billed at the residential tier (10, or 40 with a browser render). Codes that aren’t a real country are rejected with 422 validation_error. Which countries have an exit follows the live residential pool and changes over time; it is wider than the six once listed here (jp and br are verified). A successful response reports the exit in exit_country. There is no fallback to another country: a code with no exit right now is refused with 422 country_unavailable (retryable: false, not billed). |
device | "desktop" | "mobile" | "desktop" | mobile sets a mobile User-Agent + 390x844 viewport. Browser render only. |
block_ads | bool | false | Block known ad and tracker domains during the fetch. Speeds up renders on ad-heavy sites. Browser render only. |
simulate_behavior | bool | false | Sprinkle mouse-move + scroll motion before extraction to fool behavioral bot detectors (PerimeterX, DataDome, Akamai BMP). Adds ~200–600 ms. Browser path only. |
session_id | string (1–64 chars, [A-Za-z0-9_.-]) | null | Sticky session id. Repeated requests reuse the same residential exit IP for ~10 min, ideal for multi-step login flows or carts. Forces a higher-tier proxy and disables caching. |
cookies | object <string, string> (up to 50) | null | Cookies injected before navigation. Object only ({name: value}) on this endpoint. |
headers | object <string, string> (up to 30) | null | Extra request headers. Set User-Agent, Referer, etc. Host, Content-Length, Connection, Transfer-Encoding are stripped. |
cache_ttl_seconds | 0–86400 | 900 | 0 bypasses cache. Only successful fetches are cached, and any session_id, cookies or headers turns caching off for the request. |
curl -X POST https://scrapenest.dev/v1/scrape/url \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/article",
"render": "auto",
"extract": true
}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/url",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={
"url": "https://example.com/article",
"render": "auto",
"extract": True,
},
timeout=60.0,
)
r.raise_for_status()
data = r.json()
print(data["title"], "-", data["credits_charged"], "credits")
const r = await fetch("https://scrapenest.dev/v1/scrape/url", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/article",
render: "auto",
extract: true,
}),
});
const data = await r.json();
console.log(data.title, "-", data.credits_charged, "credits");
Response
{
"success": true,
"url": "https://example.com/article",
"final_url": "https://example.com/article",
"status_code": 200,
"method": "fetch",
"proxy_used": "datacenter",
"elapsed_ms": 312,
"title": "Example Article",
"description": "Lead summary...",
"text": "Cleaned article body...",
"structured": { "@type": "Article", "headline": "..." },
"cache_hit": false,
"escalated": false,
"credits_charged": 1
}
success, not the HTTP status. This
endpoint returns 200 even when the target blocks the fetch or serves no
content. success: false means the content fields are absent and
error_code is populated — check it before reading
text/html.
Credit cost depends on the resolved tier: 1 direct, 5
browser-only, 10 residential-only, 40 residential+browser,
1 on a cache hit, plus 5 when ai_query runs.
Blocked and empty fetches are free.
Response fields to know. text is the readability
extraction, except that when it comes back short (under 1,500 chars) and is a small
fraction of the page’s detected main content, the main content is returned instead.
title is absent when the document has no <title>.
structured.product appears when the page declares a product (schema.org
Product JSON-LD, microdata, or og:/product: meta) with
name, price, currency, rating,
review_count, brand, availability as declared
(each only when present) and source ("jsonld",
"microdata" or "meta"). cache_hit: true also covers a
page our fetch service answered from its own page cache: it bills the cache-hit rate
and is never escalated, while method and proxy_used name the tier
that originally fetched it. exit_country is the exit’s country when
country was requested and the fetch succeeded. With
markdown_mode: "main", language-selector link lists and link lists or control
widgets ahead of the first real content block are dropped too.
Long pages. Set max_chars to bound the response. Whenever
text is returned the response carries text_total_chars; when more text follows the
window, next_offset says where, and sending it back as offset
reads on. With focus, the window is the passages that best match it,
in page order, joined by [... N chars skipped ...] markers. The same
applies to markdown, reported as markdown_total_chars and
markdown_next_offset. Windowing is free and works on cached pages.
Auto-escalation. When an auto or browser fetch
is refused by the target’s anti-bot, we automatically retry once in stealth mode
— you don’t need to resend anything. If that retry succeeds,
escalated is true and credits_charged reflects the
stealth rate; if it stays blocked, the request is free. Two things switch it off besides
auto_escalate: false: render: "direct" is a hard tier pin and
never escalates, and escalation needs at least 20 seconds left on the
request budget — so a tight timeout_seconds (say 20)
disables it in practice. The same flag gates two cheaper fallbacks: a page still refused
after that gets one retry as a plain (non-browser) HTTP client unless you set your own
User-Agent, and on render: "auto" a page that comes back as an
empty JavaScript shell gets one browser render (billed at the browser tier only if it
delivers content).
When success is false, error_code is one of:
anti_bot_block (anti-bot refused — already retried in stealth unless
you disabled it), timeout (exceeded the time budget — raise
timeout_seconds), not_found (target returned 404/410),
empty_response (no usable content), rate_limited (the target
answered 429 without a challenge — slow down),
connection_failed (render: "direct" could not connect; not
transient at that tier, so switch to "auto" or "stealth"), or
upstream_failed (transient fetch error, or a bare 503 from the
target — retry). With render: "direct", a fetch that got no page at all
is reported as anti_bot_block or connection_failed, with
method: "fetch". Two failures come back as a real error envelope instead:
422 country_unavailable (no exit for country; not retryable) and
502 upstream_failed when the fetch service itself can’t run the request.
Neither is billed.
/v1/scrape/batch
per URL
Fetches up to 10 URLs in a single call. Each URL is fetched independently, so one URL failing never fails the rest. Fetches run concurrently five at a time, so a full 10-URL batch completes in roughly two waves rather than in the time of a single fetch. The render options apply to every URL in the batch.
Auto-escalation and latency. Batch runs the same auto-escalation as
/v1/scrape/url, controlled by the same auto_escalate flag
(default true); it accepts no timeout_seconds. A URL refused by
anti-bot on auto or browser is retried once in stealth and
billed at the stealth rate only if that retry succeeds; render: "direct"
never escalates. Each URL’s retries get at most 45 s, inside
one ~100 s budget measured from the start of the batch, and a stealth retry only
starts while at least 20 s of that remains, so a URL blocked late in the call comes
back failed instead of escalating. A batch holding a hostile URL can
take tens of seconds up to ~100 s, and longer in the worst case (each URL’s
first fetch has its own ~100 s ceiling and only five run at once), which can cross
the ~100 s edge timeout and fail with 524. Keep hostile URLs to a few
per batch, or fetch them through /v1/scrape/url with a
timeout_seconds you control.
1–40), and credits_charged is the sum.
A delivered 404/410 bills the flat 1-credit
not-found floor; undeliverable fetches (timeouts, blocks, 5xx) bill
0. Batch keeps no response cache of its own and has no
cache_ttl_seconds field; an item the fetch service answered from its page
cache (never with country set) reports cache_hit: true and bills
the cache-hit rate of 1.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
urls | string[] (1–10, http/https) | required | Absolute URLs. Each is fetched independently and concurrently. Private/loopback hosts are blocked per URL. |
render | "auto" | "direct" | "browser" | "stealth" | "auto" | Render mode applied to every URL. Same semantics as /v1/scrape/url. |
auto_escalate | bool | true | Retry an anti-bot refusal once in stealth, per URL, within that URL’s 45 s share of the batch budget. Set false to receive blocks as-is. |
force_proxy | bool | false | Force every fetch through a higher-tier proxy. |
block_assets | bool | true | On browser renders, block images/fonts/media to save bandwidth. |
extract | bool | true | Run readability extraction to return cleaned title/description/text per URL. |
return_markdown | bool | false | Add a markdown field per URL. Implies include_html. No extra credits. |
markdown_mode | "main" | "full" | "main" | Same as on /v1/scrape/url. |
include_html | bool | false | Include the raw html per URL alongside the cleaned text. No extra credits. |
block_ads | bool | false | On browser renders, block known ad/tracker domains. |
country | ISO 3166-1 alpha-2 (2 chars) | null | Route every fetch through a residential exit in this country, billed at the residential tier. Same availability as /v1/scrape/url: a country with no exit fails each item with error_code: "country_unavailable" (0 credits) rather than falling back. Successful items report exit_country. |
include_usage | bool | false | Include a usage block with the total credits and breakdown. |
curl -X POST https://scrapenest.dev/v1/scrape/batch \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"urls": [
"https://example.com/a",
"https://example.com/b",
"https://example.com/c"
],
"render": "auto",
"extract": true
}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/batch",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={
"urls": [
"https://example.com/a",
"https://example.com/b",
"https://example.com/c",
],
"render": "auto",
"extract": True,
},
timeout=120.0,
)
r.raise_for_status()
body = r.json()
print(body["succeeded"], "/", body["requested"], "-", body["credits_charged"], "credits")
for item in body["results"]:
print(item["url"], item["success"], item.get("title"))
const r = await fetch("https://scrapenest.dev/v1/scrape/batch", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
urls: [
"https://example.com/a",
"https://example.com/b",
"https://example.com/c",
],
render: "auto",
extract: true,
}),
});
const body = await r.json();
console.log(`${body.succeeded}/${body.requested} - ${body.credits_charged} credits`);
for (const item of body.results) console.log(item.url, item.success, item.title);
Response (abbreviated)
{
"results": [
{
"url": "https://example.com/a",
"final_url": "https://example.com/a",
"status_code": 200,
"method": "fetch",
"proxy_used": "datacenter",
"elapsed_ms": 284,
"title": "Page A",
"text": "Cleaned body...",
"cache_hit": false,
"escalated": false,
"success": true,
"ok": true,
"credits_charged": 1
},
{
"url": "https://example.com/blocked",
"final_url": "https://example.com/blocked",
"status_code": 403,
"method": "fetch",
"proxy_used": "datacenter",
"elapsed_ms": 640,
"cache_hit": false,
"escalated": false,
"success": false,
"ok": false,
"credits_charged": 0,
"error": "example.com blocked the request (anti-bot protection detected). ...",
"error_code": "anti_bot_block"
}
],
"requested": 3,
"succeeded": 2,
"credits_charged": 3
}
Each item carries its own success, credits_charged, and
error. Branch on success; ok is emitted
alongside it as a deprecated alias with the same value. succeeded counts
the items that returned a usable page; credits_charged at the top level is
the sum across all items. Items also carry escalated and
cache_hit, and exit_country on success with country
set; text, title and structured.product follow the
/v1/scrape/url rules.
error_code is narrower than on /v1/scrape/url.
Batch items set it only for anti_bot_block, empty_response,
rate_limited (target answered a bare 429),
upstream_failed (target answered a bare 503),
connection_failed (render: "direct" could not connect),
country_unavailable and
bandwidth_cap_exceeded (free-tier daily cap hit mid-batch), and omit the field
for anything else. A timeout, a delivered 404, or an infrastructure failure arrives with
no error_code; error is then "http_404",
"fetch_failed", "upstream_failed",
"upstream_not_configured", "redirect_to_unsafe_url",
"unsafe_url: ...", or the fetch service’s own message — so
item.error_code === "timeout" never matches here. Use
status_code and the error string for those cases, or fetch
the URL through /v1/scrape/url, which returns the full vocabulary.
When include_usage=true, the usage.breakdown reports the batch
total under a single fetch key. Before any fetch, a batch whose worst case
(every URL billed at the highest tier its render could reach, stealth
included) would push the account past the 5,000-credit overage floor is refused with
402 insufficient_credits.
/v1/search
5 credits
AI-friendly multi-engine web search. Returns the SERP (titles, URLs, snippets, engines, scores). No page fetching.
Request body
| Field | Type | Default |
|---|---|---|
query | string, 1–400 chars | required |
num_results | 1–50 | 10 |
page | 1–20 | 1 (/v1/search only; deep and answer ignore it) |
depth | "fast" | "balanced" | "thorough" | "stealth" | "balanced" |
language | IETF BCP 47 ("en", "pt-BR", "zh-Hans") | "en" |
time_range | "day" | "week" | "month" | "year" | null |
start_date | YYYY-MM-DD | null |
end_date | YYYY-MM-DD | null |
topic | "general" | "news" | "research" | "code" | null (whole web, news auto-detected) |
country | ISO 3166-1 alpha-2 (e.g. "us", "de") | null |
include_domains | string[] (max 15) | null |
exclude_domains | string[] (max 15) | null |
engines | ("google" | "duckduckgo" | "bing" | "brave" | "bing news" | "duckduckgo news" | "crossref" | "openalex" | "pubmed" | "github" | "stackoverflow" | "hackernews" | "mdn")[] (1–8 entries) | null (default mix follows depth — see below) |
include_images | bool | false |
include_image_descriptions | bool | false |
image_sources | ("bing" | "brave")[] | ["bing"] |
include_usage | bool | false |
categories | comma-separated: general, news, science, it, images | null |
cache_ttl_seconds | 0–86400 | intent-based: 1 h volatile / 24 h evergreen |
timeout_seconds | 0.1–20 | 5 (total retrieval budget, shared by every engine lane and retry) |
The default engine mix follows depth.
fast queries Bing alone (~0.5 s, lowest coverage);
balanced — the default — queries Bing + DuckDuckGo + Brave;
thorough and stealth query Bing + Google + DuckDuckGo + Brave and
wait up to ~5 s for a cold Google render, within timeout_seconds; if Google
isn’t ready by then, gserp_status says so and the other engines’
results come back (repeats are cached and fast). Work that doesn’t fit the budget is
skipped rather than failing the call. If the budget runs out before any engine answers,
the call still returns 200 with empty results,
partial: true, error: "search_deadline_exceeded", a
note and 0 credits (allow at least 1 s); an engine failure well
inside the budget is a 502 upstream_failed, also unbilled. Setting
time_range drops Bing from the default, whose scraper silently discards
the date filter, leaving DuckDuckGo + Brave — or Google + DuckDuckGo + Brave on
thorough/stealth. That substitution is silent; it is
not reported in note.
An explicit engines list is honoured verbatim and overrides all of the
above — no depth-based mix, no time_range swap. It accepts exactly the
thirteen values in the table, "brave" included, on /v1/search,
/v1/search/deep and /v1/search/answer. Brave is in the default
mixes only while the operator’s Brave switch is on; when it is off they drop Brave
silently (an explicit "brave" is still sent). The
"bing news" and "duckduckgo news" values pull straight from
the news vertical — the same lane topic="news" routes to.
topic picks the vertical. Omitted (the default), the search covers the
whole web and blends news in when the query looks like a current event, with news
holding at most half of the results; "general" searches the whole web
with that auto-routing turned off (an explicit engines list or
categories, or depth: "fast", also turns it off);
"news" pins that news vertical for every query;
"research" searches academic literature (Crossref, OpenAlex, PubMed)
and returns papers with DOIs rather than web pages; "code" adds
GitHub, Stack Overflow, Hacker News, and MDN to the general web engines. All four work on
/v1/search, /v1/search/deep, and
/v1/search/answer.
On /v1/search, depth sets the engine mix and how long a cold
query waits (above), and thorough and stealth behave
identically. On /v1/search/deep it additionally governs the per-page fetch
budget and anti-bot effort, which is where the tiers diverge most.
page is 1-based pagination on /v1/search: page=2
returns the next num_results results with no repeats from earlier pages. The
engines give at most 50 results for one query, so a page past the last result comes back
empty with a note and bills 0. Deep and answer ignore
page.
Exclusions. A literal site:/-site: in the query
is enforced like include_domains/exclude_domains. A
-word or -"phrase" drops results whose title, snippet or URL words
(host and path) contain it, including the word’s plural and possessive forms (or the
singular of a negated plural); if every result matches, all are kept.
curl -X POST https://scrapenest.dev/v1/search \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"climate report 2026 ipcc","num_results":10}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/search",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"query": "climate report 2026 ipcc", "num_results": 10},
)
r.raise_for_status()
for item in r.json()["results"][:3]:
print(item["position"], item["title"], "-", item["url"])
const r = await fetch("https://scrapenest.dev/v1/search", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: "climate report 2026 ipcc", num_results: 10 }),
});
const body = await r.json();
for (const it of body.results.slice(0, 3)) {
console.log(it.position, it.title, "-", it.url);
}
Response fields
| Field | Type | Notes |
|---|---|---|
query | string | Echoes the request query. |
results | SearchResult[] | SERP entries with position, url, title, snippet, engine, engines (all engines that surfaced the URL), score, published_at?, thumbnail?. |
images | ImageResult[] | Only present when include_images=true; otherwise omitted entirely. |
suggestions | string[]? | Engine-supplied related queries when available. |
answers | object[]? | Instant-answer/featured-snippet blocks when engines surface them. |
infoboxes | object[]? | Knowledge-panel-style entity blocks when present. |
took_ms | int | End-to-end server time. |
cache_hit | bool | True if served from cache (billed at 1 credit). |
credits_charged | int | Credits actually billed for this call. |
note | string? | Set when the server auto-adjusted the request (e.g. swapped engines to honour a time_range the requested engine doesn’t support) or coverage is degraded. |
partial | bool | Always present. true when an engine lane failed or timed out, when a requested Google render was pending, busy, blocked or failed, or when optional image search ran out of time. Never set merely because Google is unavailable, a standing “no usable exit” condition. |
unresponsive_engines | [engine, reason][] | Always present; [] when every engine answered. |
gserp_status | string? | Google’s state on the render tiers: pending, cold, empty, blocked, failed, deadline_exceeded, busy, or unavailable; each is also explained in note. |
engine_status | object? | Per engine, merged across every lane the search ran: {"status": "ok" | "empty" | "pending" | "unavailable" | "failed", "results": int, "reason"?: string}. Present when the search service reports it. |
engine_counts / engine_failures / number_of_results | object? / object[]? / int? | Results per engine, engines that failed or answered empty (with reason), and the engines’ own total-match estimate. |
error | string? | Machine-readable reason for a degraded response. |
An empty results with partial: false means the engines answered and
found nothing; with partial: true it means some of them did not answer.
Response (typical, abbreviated)
{
"query": "climate report 2026 ipcc",
"results": [
{
"position": 1,
"url": "https://...",
"title": "...",
"snippet": "...",
"engine": "bing",
"engines": ["bing", "brave", "duckduckgo"],
"score": 1.0,
"published_at": "2026-04-12T14:30:00Z"
}
],
"took_ms": 318,
"cache_hit": false,
"credits_charged": 5,
"unresponsive_engines": [],
"engine_status": {
"bing": { "status": "ok", "results": 10 },
"duckduckgo": { "status": "ok", "results": 10 },
"brave": { "status": "ok", "results": 9 }
},
"partial": false
}
Response (when extras are populated; results omitted here for brevity)
{
"query": "weather in tokyo",
"results": [],
"images": [
{
"url": "https://example.com/tokyo.jpg",
"thumbnail": "https://example.com/tokyo_t.jpg",
"title": "Tokyo skyline",
"source_url": "https://example.com/article"
}
],
"suggestions": ["weather in tokyo tomorrow", "weather in tokyo this week"],
"answers": [
{ "text": "Tokyo is currently 22ยฐC, partly cloudy.", "source": "weather.com" }
],
"infoboxes": [
{ "title": "Tokyo", "subtitle": "Capital of Japan", "url": "https://en.wikipedia.org/wiki/Tokyo" }
],
"took_ms": 462,
"cache_hit": false,
"credits_charged": 5
}
/v1/search/deep
5 + 5×fetched credits
Search and fetch the top N pages in one round trip. Same SERP as /v1/search,
plus cleaned text for each fetched page.
Request body
All fields from /v1/search (including depth and
engines; page is accepted but ignored), plus:
| Field | Type | Default |
|---|---|---|
fetch_top | 1–10 | 5 |
render | "auto" | "direct" | "browser" | unset: "direct" on fast/balanced, "auto" on thorough/stealth |
depth | "fast" | "balanced" | "thorough" | "stealth" | "balanced" |
timeout_seconds | 0.1–20 | the tier’s budget (~4.5 s fast, ~10 s balanced, ~11 s thorough, ~18 s stealth); a value can only lower it |
Here depth also controls how hard we fetch each page.
fast (~2.5 s/page) and balanced (default, ~3.5 s/page)
fetch every page in parallel with no anti-bot escalation — lowest latency, and
genuinely bot-walled pages simply fall back to their SERP snippet.
thorough (~6 s/page) and stealth (~12 s/page) escalate
bot-walled pages through a real-browser bypass; stealth has the best coverage
on the hardest sites. thorough and stealth also wait up to ~4 s for
a cold Google render before picking the pages, and only when the budget still leaves the page fetches their full time. An explicit render is honoured at every depth (a
browser render gets at least ~6 s per page); left unset it is
direct on fast/balanced. Easy pages still return in a couple seconds
regardless; the larger budget only applies to pages that actually need the bypass. If the
page-fetch phase fails or runs out of time, the call falls back to search results only
(empty pages, a note says so, billed as a plain search) rather
than erroring. With under ~2 s of budget left there is no page fetch at all: the call
returns search results only with partial: true; if your
timeout_seconds leaves too little for even that, it returns 200
with empty results, partial: true,
error: "search_deadline_exceeded" and 0 credits. The response carries
the same coverage diagnostics as /v1/search, and results is
capped at num_results.
curl -X POST https://scrapenest.dev/v1/search/deep \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "how does retrieval-augmented generation work",
"fetch_top": 3
}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/search/deep",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"query": "how does retrieval-augmented generation work", "fetch_top": 3},
timeout=90.0,
)
body = r.json()
for page in body["pages"]:
print(page["url"], "-", len((page.get("text") or "")), "chars")
const r = await fetch("https://scrapenest.dev/v1/search/deep", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: "rag systems", fetch_top: 3 }),
});
const body = await r.json();
for (const p of body.pages) console.log(p.url, "-", (p.text || "").length);
Response (abbreviated; results carries the same SERP shape as /v1/search)
{
"query": "how does retrieval-augmented generation work",
"results": [],
"pages": [
{
"url": "https://...",
"final_url": "https://...",
"status": 200,
"method": "browser",
"elapsed_ms": 640,
"title": "...",
"text": "..."
}
],
"took_ms": 2110,
"fetched_count": 3,
"cache_hit": false,
"credits_charged": 20
}
Each page reports method — one of "fetch",
"browser", "stealth", "cf_bypass", or
"unknown" — plus status, final_url and
elapsed_ms. A page that couldn’t be fetched carries an
error string instead of text, and isn’t counted in
fetched_count or billed.
Which pages count. A fetched page is counted and billed only when it is on
the query’s subject and usable: at least 200 characters of text, and not a bot wall,
consent gate, login redirect or not-found page. An unusable page comes back with its text
removed and error set to blocked, not_found or
no_content; an off-subject page is dropped from pages and counted
in note. While the budget allows, each discarded page is replaced by fetching
the next on-subject result. When the call delivers nothing at all — no
SERP results, no fetched pages, no images — the base 5 credits are dropped too and
the call is free.
/v1/search/answer
5 + 5×sources + 15 credits
RAG-style: searches the web, fetches the top sources, then asks an LLM to write a cited
answer. Returns the answer plus [1]-style citations mapping to source URLs.
Request body
Accepts the same search fields as /v1/search (including depth
and engines; page is accepted but ignored) plus
render ("auto" | "direct" | "browser"; an explicit value is
honoured at every depth, and left unset it is "direct" on fast and
"auto" otherwise). Instead of fetch_top, the number of sources to
read is set by:
| Field | Type | Default |
|---|---|---|
max_sources | 1–10 | 5 |
Here depth sets the page-fetch effort for the sources: fast
~2.5 s/page with no escalation (a browser render gets at least
~6 s),
balanced ~6 s/page, thorough ~9 s/page,
stealth ~12 s/page, each within an overall fetch ceiling of roughly
6 / 14 / 20 / 28 s, before the answer model runs. thorough and
stealth also wait up to ~4 s for a cold Google render before choosing the
sources. Raise it when answers come back
grounded in snippets. timeout_seconds is accepted (0.1–20) but has no
effect on this endpoint. Responses carry note but not the search coverage
diagnostics, and are cached only when at least one source page was fetched. Errors
(502 no_sources, 502 llm_upstream_failed,
502 upstream_failed, 503 llm_not_configured) aren’t
billed.
Sources that count. A bot wall, consent gate, login redirect, not-found
page or page under 200 characters isn’t used as a fetched source: that source falls
back to its search snippet and is neither counted nor billed, and while the budget allows
the next on-subject result is fetched in its place. When none of the retrieved sources is
relevant to the question, the model isn’t called: answer is the fixed
text “No relevant sources were found for this question: none of the retrieved pages
address it, so no answer was generated.”, citations is empty, and the
call bills 5. An answer whose first sentence says the sources don’t
address the question also bills a flat 5; a real answer that notes a gap later is
billed normally.
include_images,
image_sources, and include_image_descriptions are
not supported on /v1/search/answer. Sending any of
them returns 422. Use /v1/search when you need image results.
curl -X POST https://scrapenest.dev/v1/search/answer \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"query":"is the new gpt-5 multimodal","max_sources":4}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/search/answer",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"query": "is the new gpt-5 multimodal", "max_sources": 4},
timeout=120.0,
)
body = r.json()
print(body["answer"])
for c in body["citations"]:
print(f"[{c['index']}] {c['url']}")
const r = await fetch("https://scrapenest.dev/v1/search/answer", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query: "is gpt-5 multimodal", max_sources: 4 }),
});
const body = await r.json();
console.log(body.answer);
for (const c of body.citations) console.log(`[${c.index}] ${c.url}`);
Response (abbreviated)
{
"query": "is the new gpt-5 multimodal",
"answer": "Yes. GPT-5 supports text, image, audio... [1][2]",
"citations": [
{ "index": 1, "url": "https://openai.com/...", "title": "...", "snippet": "..." }
],
"took_ms": 4200,
"cache_hit": false,
"credits_charged": 30
}
/v1/translate
1 credit
Translate text between languages.
Request body
| Field | Type | Default |
|---|---|---|
text | string, 1–5000 chars | required |
target | IETF BCP 47 ("es", "ja", "pt-BR", "zh-TW", ...); unsupported codes are a 422 | required |
source | IETF BCP 47 or "auto" | "auto" |
include_usage | bool | false |
BCP-47 region aliases are normalised server-side: pt-BR,
zh-CN, en-GB, and underscore forms like
en_US all map to the right target code. Pass whatever your
stack hands you; no need to canonicalise. Supported: ar, cs, da, de, el, en, es, fi, fr,
he, hi, hu, id, it, ja, ko, nb, nl, pl, pt, ro, ru, sv, th, tr, uk, vi, zh-hans,
zh-hant.
Chinese. zh, zh-CN and zh-SG mean
Simplified ("zh-hans"). zh-Hant, zh-TW,
zh-HK and zh-MO mean Traditional and come back as target
"zh-hant"; if the Traditional translator is unavailable the text is
translated to Simplified and target and note both say
"zh-hans", so the metadata always matches the script returned.
Errors. An unsupported target or source is a
422 validation_error whose details[] names the field; the
message lists the supported codes. When source is "auto" and the
detected language isn’t supported, the call is
400 unsupported_detected_source; pass source explicitly.
curl -X POST https://scrapenest.dev/v1/translate \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"text":"Hello world","target":"ja"}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/translate",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"text": "Hello world", "target": "ja"},
)
print(r.json()["translated"])
const r = await fetch("https://scrapenest.dev/v1/translate", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ text: "Hello world", target: "ja" }),
});
console.log((await r.json()).translated);
Response
{
"translated": "ใใใซใกใฏไธ็",
"detected_source": "en",
"target": "ja",
"credits_charged": 1
}
Response fields
translated: the translated text.detected_source: the language we detected the source as (BCP-47).target: echoes the requested target language.note: only present when we adjusted behaviour (source equals target so text was returned unchanged, an alias was applied, or auto-detection fell back to a heuristic). Omitted from normal paid translations.credits_charged: 1 per call, or 0 when source equals (or auto-detects to) target.
/v1/audio/transcript
20 / 2 min
Timestamped transcript of any audio source. Supports podcast episodes (Apple Podcasts or Overcast episode pages, or an episode’s audio file URL from an RSS feed; feed URLs are not accepted), YouTube (captions when available, else audio transcription), X Spaces, SoundCloud, Facebook video, TikTok, and direct MP3/M4A/WAV/OGG links.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
url | string | required | Audio source URL. |
language | ISO 639-1 | auto-detect | Hint for the transcriber, e.g. "en", "es". |
cookies | string (Netscape cookies.txt) | null | Optional. See YouTube cookies below. |
cache_ttl_seconds | 0–2592000 | 2592000 | Default 30 days. Forced to 0 when cookies is set. |
include_usage | bool | false | Include a usage block in the response with credit totals. |
curl -X POST https://scrapenest.dev/v1/audio/transcript \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://podcasts.apple.com/us/podcast/.../id..."}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/audio/transcript",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"url": "https://podcasts.apple.com/us/podcast/.../id..."},
timeout=600.0,
)
body = r.json()
print(body["title"], "/", body["channel"])
print(body["text"][:400])
const r = await fetch("https://scrapenest.dev/v1/audio/transcript", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://podcasts.apple.com/us/podcast/.../id..." }),
});
const body = await r.json();
console.log(body.title, "/", body.channel);
console.log(body.text.slice(0, 400));
Response (abbreviated)
{
"url": "...",
"audio_url": "https://traffic.megaphone.fm/....mp3",
"canonical_url": "https://podcasts.apple.com/us/podcast/.../id...?i=...",
"title": "Episode title",
"channel": "Show name",
"thumbnail": "https://is1-ssl.mzstatic.com/.../artwork.jpg",
"duration_seconds": 3618.2,
"language": "en",
"source": "apple_podcasts",
"chunks": [
{ "start": 0.0, "end": 4.2, "text": "Welcome back to the show..." }
],
"text": "Welcome back to the show... [full transcript]",
"took_ms": 41200,
"cache_hit": false,
"credits_charged": 620
}
chunks is omitted when the upstream source doesn’t provide
timestamps (some podcast feeds and short clips); the text field always
contains the full transcript regardless. canonical_url and
thumbnail are populated when the resolver can pin them; treat absence
as “not available”, not an error.
If part of a long recording still can’t be transcribed after retries, the response
returns the rest with partial: true and missing_ranges
([start, end] spans in seconds). Only the covered time is billed, and a partial
result is never cached, so a retry runs fresh. Neither field is set on a complete
transcript.
YouTube cookies (optional, for the long tail)
~95% of YouTube URLs work out of the box via the captions API. The remaining ~5%
(YouTube Shorts that fall through the captions path, videos with auto-captions disabled,
geo-restricted content, members-only, age-gated) require a logged-in YouTube
session to download the audio. For those, pass a Netscape-format
cookies.txt in the cookies field.
How to obtain cookies:
- Use a burner Google account. Not your personal one, since every transcript request using these cookies adds to that account’s watch history.
- Install a cookies-export browser extension (Chrome: Get cookies.txt LOCALLY; Firefox: cookies.txt).
- Log into
youtube.comin that browser, click the extension on any YouTube tab, choose Export. You’ll get a file starting with# Netscape HTTP Cookie File. - Send the entire file contents as the
cookiesfield. We accept up to 128 KB; only the youtube.com lines actually matter.
cookies are never cached. Audit logs scrub the cookies key
entirely.
Omit cookies for the typical case. Add it only when you’ve previously
seen login_required on a video you specifically need.
/v1/audio/transcript/async
same as sync
Identical request shape to the sync endpoint. Returns immediately with a
run_id (202 Accepted); poll
GET /v1/scrape/runs/{run_id} for the completed transcript.
Recommended for long-form audio (multi-hour podcasts, full conference talks)
so you’re not holding an HTTP connection open for the duration of the transcription.
Credit cost is identical and is charged when the job completes. Failed runs
are not billed.
Initial response (202)
{
"run_id": "f0e0c0a0-1234-5678-9abc-def012345678",
"status": "queued",
"status_url": "/v1/scrape/runs/f0e0c0a0-1234-5678-9abc-def012345678",
"created_at": "2026-05-14T16:00:00Z",
"expires_at": "2026-05-21T16:00:00Z"
}
/v1/scrape/google-maps/reviews
100 / 50 reviews
Pulls Google Maps reviews for any place. Returns up to ~10,000 unique reviews, deduplicated and sorted newest-first.
Pricing. 100 credits per 50 reviews returned (rounded up to the next 50), with a 40-credit floor when 50 or fewer come back — so you only pay for what’s available. 200 reviews bills ~400 credits; the 10,000-review cap bills ~20,000. Before the scrape starts, a request whose worst case (max_reviews all returned) would push the account past the 5,000-credit overage floor is refused with a real 402 insufficient_credits status line; lower max_reviews or top up. When a capture stops short of max_reviews because one of our own paths failed (not because the place has fewer reviews), it bills 2 credits per delivered review whenever that is less than the tier price (e.g. 3 reviews of a requested 50 bill 6, not 40).
200.
The synchronous endpoint streams, and unlike screenshot and PDF it has no fast path, so
the status line is committed before the scrape finishes. Failures come back as
{"error": {...}, "http_status": <N>} — including 4xxs
like place_id_format, place_not_found and
place_id_unresolvable. Always check for an error key before
reading reviews; a 200 alone doesn’t mean success here.
The async variant reports failures through the run record instead.
Why is the cap 10,000 reviews per place when some places have more?
Google itself only exposes a limited slice of any place’s reviews, regardless of how many the place actually has. On places with >20K total reviews the practical ceiling lands around 10,000 unique. Tested against Six Flags Fiesta TX (37,429 Google-reported reviews): 10,000 returned in ~4 minutes. We cap the request at 10,000 because asking for more is misleading, since Google won’t serve it.
How some services claim 100K reviews per place. That’s a hard limit on what Maps itself will surface. Marketing numbers like “up to 100K” aren’t tested against Eiffel-tier places (486K reviews); they’re aspirational ceilings, not measured deliveries. DataForSEO publishes its real cap at 4,490. SerpAPI documented in their own bug tracker that pagination stops where Google’s UI scroll stops.
Realistic delivery by place size
| Place review count (Google) | Typical delivery |
|---|---|
| < 1,000 | ~100% of available |
| 1,000–10,000 | 60–100% (subject to Google’s rate-limit state) |
| 10,000–100,000 | ~10,000 (the per-request cap) |
| 100,000+ (Eiffel Tower, Times Square) | ~10,000 (Google’s indexed pool, not our cap, is the binding constraint) |
The note field in the response is your authoritative count of what came back and why. The review_count field reflects what Google publishes for the place, which is often much higher than what’s actually accessible through any documented endpoint. This reflects Google’s spam-filter and pagination behavior, not a limitation of this API.
/v1/scrape/google-maps/reviews/async, then poll /v1/scrape/runs/{run_id}. Failed runs are not billed.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
place_id | string | required | Full Google Maps URL of the place or a FID hex (0x...:0x...). URLs are parsed server-side, so you can paste the link straight from the Maps address bar. FID hex skips an internal lookup so it’s slightly faster. A value that is neither a Maps URL nor a FID is rejected up front with a real 422 validation_error; place_id_format (in-body) is for an identifier that parses but can’t be resolved to a FID. |
place_url | string | none | Optional canonical Maps URL. Pass when the place’s FID has been rotated and the legacy short URL redirects to a generic location. |
max_reviews | 1–10000 | 200 | Hard cap (request ceiling). Actual delivery is bounded by what Google makes available (see the table above). |
sort | newest | most_relevant | highest_rating | lowest_rating | "newest" | Accepted values: newest, most_relevant, highest_rating, lowest_rating. Reviews are returned newest-first regardless of this value. Kept for backward compatibility. |
language | IETF BCP 47 ("en", "pt-BR", "zh-Hans") | "en" | Affects place name/address language only; review text is whatever the reviewer wrote. |
cache_ttl_seconds | 0–86400 | 3600 | Applies when max_reviews is 2000 or less. Above 2000 the default is 0 (uncached) — pass an explicit value to override. |
include_usage | bool | false | Include a usage block in the response with credit totals. |
Finding a FID
Open the place on Google Maps. The URL contains a segment like
!1s0x47e6…:0x8ddc…!8m2!…. The FID is the
0x…:0x… hex pair immediately after !1s. Copy it whole, separator included.
curl -X POST https://scrapenest.dev/v1/scrape/google-maps/reviews \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"place_id": "0x47e66e2964e34e2d:0x8ddca9ee380ef7e0",
"max_reviews": 5000
}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/google-maps/reviews",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={
"place_id": "0x47e66e2964e34e2d:0x8ddca9ee380ef7e0",
"max_reviews": 5000,
},
timeout=300.0,
)
body = r.json()
print(len(body["reviews"]), "reviews -", body["credits_charged"], "credits")
const r = await fetch("https://scrapenest.dev/v1/scrape/google-maps/reviews", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
place_id: "0x47e66e2964e34e2d:0x8ddca9ee380ef7e0",
max_reviews: 5000,
}),
});
const body = await r.json();
console.log(body.reviews.length, "reviews -", body.credits_charged, "credits");
Response (abbreviated)
{
"place_id": "0x47e66e2964e34e2d:0x8ddca9ee380ef7e0",
"name": "Eiffel Tower",
"address": "Av. Gustave Eiffel, 75007 Paris, France",
"rating": 4.7,
"review_count": 486214,
"google_maps_url": "https://www.google.com/maps/place/...",
"reviews": [
{
"review_id": "ChdDSUhNMG9nS0VJQ0FnSURBNS0z...",
"review_url": "https://www.google.com/maps/contrib/.../reviews?hl=en",
"author_name": "Jane D.",
"author_id": "1234567890",
"author_profile_url": "https://www.google.com/maps/contrib/1234567890/reviews?hl=en",
"author_avatar_url": "https://lh3.googleusercontent.com/...",
"rating": 5,
"text": "Amazing view, worth the wait.",
"language": "en",
"relative_time": "2 weeks ago",
"posted_at": 1778630400,
"posted_at_iso": "2026-05-13T00:00:00Z",
"helpful_count": 4
}
],
"star_histogram": [8214, 5190, 17920, 69450, 385440],
"note": "Returned 5000 unique reviews after deduplication.",
"took_ms": 197000,
"cache_hit": false,
"credits_charged": 10000
}
Heads up: name, address, rating, review_count, and star_histogram can be null when the FID resolves but the metadata block isn’t served. The reviews array is always the source of truth for what you get.
star_histogram is a flat array of 5 ints ordered 1★ → 5★
(so [8214, 5190, 17920, 69450, 385440] means 8,214 one-star reviews and
385,440 five-star reviews). When the whole review list was read (the place has fewer
reviews than max_reviews), review_count and
star_histogram are computed from that full set and are exact.
photos is omitted from a review entry when the reviewer didn’t
attach any; treat its absence as the empty case. Reviews recovered through the
page-rendering fallback carry a posted_at/posted_at_iso
approximated from Google’s relative label (“2 weeks ago”), not an exact
timestamp.
/v1/scrape/google-maps/reviews/async
same as sync
Identical request body to the sync endpoint. Returns immediately with a
run_id (202 Accepted); poll
GET /v1/scrape/runs/{run_id} for the final result. Recommended for
any large pull (it doesn’t hold a long-lived HTTP connection open, and you
can fire-and-forget across many places). Credit cost is identical and is charged when the
job completes. Failed runs are not billed. Async runs share the sync endpoint’s
cache, so a repeat inside the TTL completes at once and bills the cache-hit rate.
Initial response (202)
{
"run_id": "f0e0c0a0-1234-5678-9abc-def012345678",
"status": "queued",
"status_url": "/v1/scrape/runs/f0e0c0a0-1234-5678-9abc-def012345678",
"created_at": "2026-05-14T15:00:00Z",
"expires_at": "2026-05-21T15:00:00Z"
}
/v1/scrape/gas-prices
10 credits
Returns nearby retail gas-station prices for a US location. Accepts a 5-digit ZIP,
City, ST, or City, State. Backed by public station-finder
listings.
price values
may be null even when the station itself is listed. The
region_stats block (regional lowest / average) stays populated for
regular. When
prices are missing the response sets note so you can surface the
caveat to your caller.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
location | string, 2–200 chars | required | 5-digit US ZIP ("37122", "37122-1234"), "City, ST" ("Mount Juliet, TN"), or "City, State" ("Nashville, Tennessee"). Ambiguous queries may auto-resolve, so check location in the response. |
grade | "regular" | "midgrade" | "premium" | "diesel" | "e85" | "regular" | Fuel grade. |
limit | 1–50 | 20 | Hard cap on returned stations. Listings show ~13 by default. |
cache_ttl_seconds | 0–3600 | 600 | 10 minutes. Max 1 hour (prices change slowly). |
include_usage | bool | false | Include a usage block in the response. |
curl -X POST https://scrapenest.dev/v1/scrape/gas-prices \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"location": "37122",
"grade": "regular",
"limit": 20
}'
import os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/gas-prices",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"location": "Mount Juliet, TN", "grade": "regular", "limit": 20},
timeout=60.0,
)
body = r.json()
for s in body["stations"][:5]:
print(f"{s['brand']:>18} {s.get('price')} {s['address']}")
const r = await fetch("https://scrapenest.dev/v1/scrape/gas-prices", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ location: "37122", grade: "regular", limit: 20 }),
});
const body = await r.json();
for (const s of body.stations.slice(0, 5)) {
console.log(`${s.brand} ${s.price} ${s.address}`);
}
Response (abbreviated)
{
"location": {
"query": "37122",
"zip": "37122",
"city": "Mount Juliet",
"state": "TN"
},
"grade": "regular",
"stations": [
{
"brand": "Kroger",
"sub_brand": "Kroger Fuel Center",
"station_id": "21847",
"address": "401 S Mt Juliet Rd, Mount Juliet, TN",
"city": "Mount Juliet",
"state": "TN",
"price": 2.789,
"currency": "USD",
"unit": "USD/gallon",
"reporter": "anon_18234",
"updated_relative": "12 minutes ago"
}
],
"region_stats": {
"state": "TN",
"region": "Tennessee",
"region_type": "state",
"lowest_price": 2.699,
"average_price": 2.952
},
"source_url": "https://scrapenest.dev",
"fetched_at": "2026-05-19T17:42:00Z",
"took_ms": 1820,
"cache_hit": false,
"credits_charged": 10
}
Field rules:
location.zip,location.city,location.stateare populated when the resolver can identify them;location.queryalways echoes the verbatim input.region_statsis omitted when the source doesn’t render the regional summary block (typically rural ZIPs).region_stats.region_typeis"state"whenregionis a US state name andstateholds the USPS code; it’s"city"when the source returned a metro-level summary (stateis thennull).- The source’s regional figures cover
regularonly. For any othergrade,region_statsis computed from the returned stations of that grade (lowest and average, labelled with the searched city) andnotesays so; when no prices of that grade came back,region_statsis omitted with anote. noteis set when stations were returned without per-station prices, when an ambiguous query was auto-resolved, or when other caveats apply.
/v1/scrape/runs/{run_id}
Polling free
Poll the status of a run created by one of the two async endpoints,
/v1/scrape/google-maps/reviews/async and
/v1/audio/transcript/async; no other endpoint
has an async variant. Returns one of queued, running,
completed, failed, cancelled. When
completed, result holds the same body shape as the synchronous
endpoint.
Polling does not consume credits, but it does count against your rate limit. Runs expire after 7 days.
The response also includes an optional progress object on long-running scrapes
(currently large Maps reviews pulls). Shape:
{"current": <int>, "target": <int>, "detail": "<string>"}.
It advances as reviews are collected, through each fetch path the run tries. Use it to
display a progress bar to your users; absent means the worker hasn't emitted a milestone
yet.
To stop an in-flight run, hit
POST /v1/scrape/runs/{run_id}/cancel. Runs bill only when they complete, so a
queued or running run cancelled before completing is not billed
(credits_charged: 0). A run that already completed, failed or was cancelled
is returned unchanged, and a completed run stays billed. The response has the same shape
as the GET:
curl -X POST https://scrapenest.dev/v1/scrape/runs/f0e0c0a0-1234-5678-9abc-def012345678/cancel \
-H "Authorization: Bearer $SCRAPENEST_API_KEY"
{
"run_id": "f0e0c0a0-1234-5678-9abc-def012345678",
"endpoint": "POST /v1/scrape/google-maps/reviews",
"status": "cancelled",
"created_at": "2026-05-14T15:00:00Z",
"started_at": "2026-05-14T15:00:02Z",
"completed_at": "2026-05-14T15:01:10Z",
"cancelled_at": "2026-05-14T15:01:10Z",
"expires_at": "2026-05-21T15:00:00Z",
"credits_charged": 0,
"progress": { "current": 1200, "target": 5000, "detail": "..." }
}
RUN=$(curl -s -X POST https://scrapenest.dev/v1/scrape/google-maps/reviews/async \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{"place_id":"0x47e6...:0x8ddc...","max_reviews":5000}' \
| jq -r .run_id)
while true; do
STATUS=$(curl -s https://scrapenest.dev/v1/scrape/runs/$RUN \
-H "Authorization: Bearer $SCRAPENEST_API_KEY")
STATE=$(echo "$STATUS" | jq -r .status)
case "$STATE" in completed|failed|cancelled) echo "$STATUS" | jq; break;; esac
sleep 10
done
import os, time, httpx
API_KEY = os.environ["SCRAPENEST_API_KEY"]
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
BASE = "https://scrapenest.dev"
with httpx.Client(base_url=BASE, headers=HEADERS, timeout=30.0) as client:
submit = client.post(
"/v1/scrape/google-maps/reviews/async",
json={"place_id": "0x47e6...:0x8ddc...", "max_reviews": 5000},
)
submit.raise_for_status()
run_id = submit.json()["run_id"]
while True:
time.sleep(10)
body = client.get(f"/v1/scrape/runs/{run_id}").json()
if body["status"] in ("completed", "failed", "cancelled"):
break
if body["status"] == "completed":
print(len(body["result"]["reviews"]), "reviews")
else:
print(body["status"], body.get("error_code"), body.get("error_message"))
const API_KEY = process.env.SCRAPENEST_API_KEY;
const BASE = "https://scrapenest.dev";
const headers = { "Authorization": `Bearer ${API_KEY}`, "Content-Type": "application/json" };
const submit = await fetch(`${BASE}/v1/scrape/google-maps/reviews/async`, {
method: "POST",
headers,
body: JSON.stringify({
place_id: "0x47e6...:0x8ddc...",
max_reviews: 5000,
}),
});
const { run_id } = await submit.json();
while (true) {
await new Promise(r => setTimeout(r, 10000));
const body = await (await fetch(`${BASE}/v1/scrape/runs/${run_id}`, { headers })).json();
if (["completed", "failed", "cancelled"].includes(body.status)) {
console.log(body.status, body.result?.reviews?.length ?? body.error_message);
break;
}
}
Response (result holds the same body as the synchronous endpoint)
{
"run_id": "f0e0c0a0-1234-5678-9abc-def012345678",
"endpoint": "POST /v1/scrape/google-maps/reviews",
"status": "completed",
"created_at": "2026-05-14T15:00:00Z",
"started_at": "2026-05-14T15:00:02Z",
"completed_at": "2026-05-14T15:03:15Z",
"expires_at": "2026-05-21T15:00:00Z",
"credits_charged": 10000,
"result": {}
}
On a successful run, cancelled_at, error_code,
error_message and progress are absent rather than
null. A failed run carries error_code and
error_message; an in-flight Maps run carries
progress as {"current": int, "target": int, "detail": str}
once the worker has emitted its first milestone.
/v1/scrape/screenshot
15–35 credits
Captures a PNG or JPEG screenshot of any public URL. Returns base64-encoded image
bytes inline. Auto-falls-back to a stealth backend when the target site blocks the
normal headless browser — same endpoint, same response shape. If you already know
the target is hostile, skip the failed first attempt with render: "stealth".
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
url | string (http/https) | required | Absolute URL. Private/loopback hosts are blocked. |
viewport_width | 320–3840 | 1280 | Browser viewport width in CSS pixels. |
viewport_height | 240–2160 | 720 | Browser viewport height in CSS pixels. |
full_page | bool | false | When true, captures the entire scrollable height. Auto-detected up to the rendered page height. |
selector | string (CSS, up to 300 chars) | null | Captures the first element matching selector. If it matches nothing you get a full-viewport capture at the requested tier and price (one extra render, never a stealth charge) with selector_applied: false. A stealth capture never crops to the selector. Raises selector_not_found (422, or in-body past the 20s fast path — see below) only when nothing can be rendered. Overrides full_page (e.g. "#hero", ".product-card:first-child"). |
render | "auto" | "browser" | "stealth" | "auto" | auto lets us pick the right backend per target (including stealth fallback for hostile sites); browser forces the standard headless browser; stealth forces the anti-bot capture backend up front (35 credits). |
format | "png" | "jpeg" | "png" | JPEG is typically ~half the byte size at quality 75–85. |
jpeg_quality | 1–100 | 85 | Ignored when format is png. |
wait_extra_ms | 0–10000 | 0 | Extra wait after page load for late-loading content (SPAs, lazy images, charts). |
cookies | object <string, string> or [{name, value}, …] (up to 50) | null | Cookies injected before navigation. Accepts either a {name: value} map or a list of {name, value} objects; the list form is normalised server-side. |
headers | object <string, string> (up to 30) | null | Extra HTTP headers (User-Agent, Referer, etc.). Host/Content-Length are stripped. |
session_id | string (1–64 chars, [A-Za-z0-9_.-]) | null | Sticky exit IP (10-minute window). Pair with the matching /v1/scrape/url session to capture an authenticated page. |
include_usage | bool | false | Include a usage block in the response. |
cache_ttl_seconds | 0–86400 | 0 (off) | Set non-zero to dedupe repeat captures made with identical settings. Requests with cookies, headers or a session_id are never cached. |
curl -X POST https://scrapenest.dev/v1/scrape/screenshot \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://news.ycombinator.com",
"full_page": true,
"format": "png"
}' \
| jq -r .image_base64 | base64 -d > screenshot.png
import base64, os, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/screenshot",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={
"url": "https://news.ycombinator.com",
"full_page": True,
"format": "png",
},
timeout=120.0,
)
r.raise_for_status()
data = r.json()
with open("screenshot.png", "wb") as f:
f.write(base64.b64decode(data["image_base64"]))
print(f"{data['width']}x{data['height']}, {data['bytes']} bytes via {data['method']}")
import { writeFileSync } from "node:fs";
const r = await fetch("https://scrapenest.dev/v1/scrape/screenshot", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://news.ycombinator.com",
full_page: true,
format: "jpeg",
jpeg_quality: 80,
}),
});
const data = await r.json();
writeFileSync("screenshot.jpg", Buffer.from(data.image_base64, "base64"));
console.log(`${data.width}x${data.height}, ${data.bytes} bytes via ${data.method}`);
Response
{
"url": "https://news.ycombinator.com",
"final_url": "https://news.ycombinator.com/",
"status_code": 200,
"method": "browser",
"escalated": false,
"elapsed_ms": 2470,
"width": 1280,
"height": 1179,
"bytes": 253264,
"format": "png",
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA...",
"cache_hit": false,
"credits_charged": 25
}
The method field tells you which engine handled the capture:
"browser" is the normal headless path (~2–3 s, 15–25 credits),
"stealth" is the anti-bot path for hostile sites (~8–12 s, 35 credits).
You don't have to choose — we route per target — but render: "stealth"
forces it when you already know the site blocks normal browsers. escalated is
true when the requested tier was refused by anti-bot and the capture was taken
in stealth (billed at the stealth rate); a selector miss never escalates.
selector_applied appears only when selector was set:
true when the image is cropped to the matched element, false when
it is the full viewport.
Error delivery. A capture that resolves within ~20 seconds returns a
real HTTP status line (422 selector_not_found,
422 target_error, 502 screenshot_failed…) with the
plain {"error": {...}} envelope. Past that window the response has already
begun streaming, so the status line is locked at 200 and the same error
arrives in-body as {"error": {...}, "http_status": 502}. Branch on the
transport status first, then check for an error key when it is
200. Failures and timeouts don’t bill either way.
/v1/scrape/pdf
25–35 credits
Renders any URL to PDF. Returns base64-encoded bytes. Useful for archiving articles, generating invoice/receipt copies, or capturing legal evidence.
Request body
| Field | Type | Default | Notes |
|---|---|---|---|
url | string (http/https) | required | Absolute URL. |
format | "Letter" | "Legal" | "Tabloid" | "Ledger" | "A0"–"A6" | "Letter" | Paper size. |
landscape | bool | false | Orientation. |
viewport_width | 320–3840 | 1280 | Render viewport width. |
viewport_height | 240–2160 | 720 | Render viewport height. |
wait_extra_ms | 0–10000 | 0 | Extra wait before capture for late-loading content. |
cookies | object <string, string> or [{name, value}, …] | null | Cookies injected before navigation (for paywalled/logged-in pages). Accepts either a {name: value} map or a list of {name, value} objects. |
headers | object <string, string> | null | Extra request headers. |
session_id | string | null | Reuse a sticky exit IP (see /v1/scrape/url). |
include_usage | bool | false | Include a usage block in the response. |
cache_ttl_seconds | 0–86400 | 0 (off) | Set non-zero to dedupe repeat captures made with identical settings. Requests with cookies, headers or a session_id are never cached. |
curl -X POST https://scrapenest.dev/v1/scrape/pdf \
-H "Authorization: Bearer $SCRAPENEST_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/article",
"format": "A4"
}' | jq -r .pdf_base64 | base64 -d > article.pdf
import os, base64, httpx
r = httpx.post(
"https://scrapenest.dev/v1/scrape/pdf",
headers={"Authorization": f"Bearer {os.environ['SCRAPENEST_API_KEY']}"},
json={"url": "https://example.com/article", "format": "A4"},
timeout=120,
)
data = r.json()
with open("article.pdf", "wb") as f:
f.write(base64.b64decode(data["pdf_base64"]))
print(f"{data['bytes']} bytes -> article.pdf")
import fs from "node:fs/promises";
const r = await fetch("https://scrapenest.dev/v1/scrape/pdf", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.SCRAPENEST_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ url: "https://example.com/article", format: "A4" }),
});
const data = await r.json();
await fs.writeFile("article.pdf", Buffer.from(data.pdf_base64, "base64"));
console.log(`${data.bytes} bytes -> article.pdf`);
Response
{
"url": "https://example.com/article",
"final_url": "https://example.com/article",
"status_code": 200,
"elapsed_ms": 2120,
"format": "A4",
"landscape": false,
"bytes": 20925,
"pdf_base64": "JVBERi0xLjQKJdPr6eEKMSAwIG9iago...",
"cache_hit": false,
"credits_charged": 25
}
Error delivery works exactly as on
/v1/scrape/screenshot: a render that resolves
within ~20 seconds returns a real status line (422 target_error,
502 pdf_failed…), while anything slower has already started
streaming and arrives as {"error": {...}, "http_status": 502} inside a
200. Failures don’t bill.
Common workflows
Reviews → sentiment summary for a place
POST /v1/scrape/google-maps/reviews/asyncwithmax_reviews=5000.- Poll
/v1/scrape/runs/{run_id}every 10s untilstatus=completed. - Batch
result.reviews[*].textin chunks of 50, feed each chunk to your LLM with "Summarize themes and rate sentiment 1–5 per review." Map back viareview_id.
Topic research with citations
POST /v1/search/answerwith a clear question andmax_sources=6.- The response gives you a written answer plus
citations[]. - For deeper backing, take each
citations[*].url, fan out to/v1/scrape/urlwithextract=true, and feed the cleanedtextback into your own LLM prompt.
Podcast → show notes
POST /v1/audio/transcriptwith the episode URL.- Take
chunks[](start/end/text) and ask your LLM: "Group these into 5–10 topic chapters, each with timestamps from the chunks." Works because chunks are already time-anchored.
Common mistakes
- Running large Maps scrapes on the sync endpoint. For pulls of more than a few hundred reviews,
use
/v1/scrape/google-maps/reviews/asyncand poll/v1/scrape/runs/{run_id}. The sync endpoint is best for small places and quick interactive checks. If you already have the FID (0x...:0x...) extracted from the Maps URL, it’s slightly faster than pasting the full URL. - Assuming
max_reviews=5000always returns 5K. Google only makes a limited slice available, roughly 2–5K reviews on most places and ~200 on small spots. No scraper can exceed what Google itself indexes. Always checklen(reviews). - Ignoring
cache_hit: true. A cached response is identical to the live response. For price tracking or change detection, setcache_ttl_seconds: 0. - Storing API keys in client-side code. Bearer keys give full account access. Keep them in your backend.
- Retrying on 4xx. Those are caused by your request, so fix it instead
of retrying. Only retry 5xx and 429 (with backoff and
Retry-After). - Hammering
/v1/scrape/runs/{id}every 100ms. Polling is free but counts against your rate limit. 2–10 seconds is plenty; 10–15s is fine for big Maps runs.
MCP server — connect Claude, Cursor & ChatGPT
ScrapeNest runs a hosted Model Context Protocol server so any MCP-capable AI assistant can use live web search, scraping, deep research and translation as built-in tools. It is the same API documented above — billing, rate limits and failure handling are identical — exposed over MCP’s streamable-HTTP transport.
https://scrapenest.dev/mcp. Claude Code, Cursor,
VS Code and LM Studio can authenticate with an API key in the
Authorization: Bearer sn_... header. ChatGPT Work connects through OAuth:
sign in to ScrapeNest and approve its access to your account. Do not put an API key in a URL.
Manage API keys at /dashboard/keys.
Add it to your client:
claude mcp add scrapenest --transport http \ --header "Authorization: Bearer $SCRAPENEST_API_KEY" \ https://scrapenest.dev/mcp
{
"mcpServers": {
"scrapenest": {
"url": "https://scrapenest.dev/mcp",
"headers": { "Authorization": "Bearer sn_your_key_here" }
}
}
}
{
"servers": {
"scrapenest": {
"type": "http",
"url": "https://scrapenest.dev/mcp",
"headers": { "Authorization": "Bearer sn_your_key_here" }
}
}
}
{
"mcpServers": {
"scrapenest": {
"url": "https://scrapenest.dev/mcp",
"headers": { "Authorization": "Bearer sn_your_key_here" }
}
}
}
In ChatGPT Work, enable Developer mode and add a personal plugin using this MCP URL: https://scrapenest.dev/mcp Connect your ScrapeNest account when ChatGPT prompts you to sign in and approve access. The default URL exposes all nine tools. Use ?profile=chatgpt for the smaller search + fetch profile.
The default profile exposes nine tools; each is a thin wrapper over the endpoint shown, billed
at the same rate (every tool’s output, translate_text included, ends with the
credits charged). A failed call comes back as an MCP result with isError: true
and the error text, so clients can tell a refusal from a result:
| Tool | Does | Endpoint | Credits |
|---|---|---|---|
web_search | Multi-engine web search, merged & ranked | /v1/search | 5 |
ask_web | Cited answer synthesized from live pages | /v1/search/answer | 5 + 5×sources + 15 |
deep_search | Search + full text of the top results | /v1/search/deep | 5 + 5×pages |
scrape_url | Fetch one URL (handles JS + anti-bot) | /v1/scrape/url | 1–40 |
translate_text | Translate text, auto-detect source | /v1/translate | 1 |
maps_reviews | Google Maps reviews for one place | .../google-maps/reviews/async | 100 per 50 reviews (40 when 50 or fewer; max 2,000 per call over MCP) |
audio_transcript | Transcribe a podcast, video or audio URL | /v1/audio/transcript/async | 20 per 2 min |
poll_run | Collect a finished background job | /v1/scrape/runs/{run_id} | free |
scrapenest_account | Key status, credit balance, limits | /v1/me | free |
maps_reviews and audio_transcript
routinely outlast a single tool call, so they submit to the async endpoints and wait roughly a
minute inline. Short jobs come back in that first call; longer ones return a
run_id that the assistant passes to poll_run until the result is
ready. Polling is free — the job is billed once, on completion, and failed runs are not
billed at all.
/v1 API directly with a
higher timeout_seconds.
SDKs & libraries
Official clients are published for Python and TypeScript/JavaScript (both
version 0.5.1, MIT licensed). They wrap every endpoint, return typed
responses, and raise typed exceptions for 401, 402,
403, 404, and 429. The API is also a thin HTTP
layer, so it works cleanly with any client (curl, httpx,
requests, fetch, axios, got,
Go’s net/http, Rust’s reqwest) if you prefer raw
REST.
Python
Install from PyPI (requires Python 3.9+):
pip install scrapenest
from scrapenest import Client
client = Client("sn_your_key_here")
result = client.search("python web scraping", num_results=5)
for r in result.results:
print(r.title, r.url)
deep = client.search_deep("what is rust lang", fetch_top=3)
for page in deep.pages:
print(page.url, len(page.text or ""))
answer = client.search_answer("who wrote the odyssey", max_sources=3)
print(answer.answer)
for c in answer.citations:
print(c.index, c.url)
page = client.scrape_url("https://example.com", render="auto", return_markdown=True)
print(page.title)
data = client.scrape_url("https://example.com/product", ai_query="Return the product name and price")
print(data.ai_extract)
batch = client.scrape_batch(
["https://example.com/a", "https://example.com/b", "https://example.com/c"],
render="auto",
)
print(batch.succeeded, "/", batch.requested, "-", batch.credits_charged, "credits")
gas = client.gas_prices("Los Angeles, CA", grade="regular", limit=10)
for s in gas.stations:
print(s.brand, s.address, s.price)
tr = client.translate("Hello, how are you?", "es")
print(tr.translated)
run = client.maps_reviews_async("0x6b12ae665e892fdd:0x3133f8d75a1ac251", max_reviews=2000)
final = client.wait_for_run(run.run_id, timeout=1800)
print(len(final.result["reviews"]), "reviews")
wait_for_run defaults to a 300-second timeout and raises
TimeoutError when it lapses — the run keeps going server-side either way.
Large Maps pulls routinely run longer than that, so pass a timeout sized to the
job (up to 1800s for the biggest pulls) and re-poll with get_run(run_id) if you
do time out.
Every method returns a typed dataclass. Pass include_usage=True (where
supported) to get a usage block with the credits charged. An
AsyncClient with the same methods is available for asyncio:
import asyncio
from scrapenest import AsyncClient
async def main():
async with AsyncClient("sn_your_key_here") as client:
result = await client.search("python web scraping")
print(result.results[0].title)
asyncio.run(main())
Errors map to typed exceptions you can catch by class:
from scrapenest import RateLimitError, AuthenticationError, PaymentRequiredError
try:
result = client.search("test")
except RateLimitError as e:
print(f"Rate limited. Retry after {e.retry_after}s")
except PaymentRequiredError:
print("Out of credits")
except AuthenticationError:
print("Invalid API key")
TypeScript / JavaScript
Install from npm (requires Node.js 18+; ships ESM + CJS builds and full type definitions):
npm install scrapenest
import { ScrapeNest } from "scrapenest";
const client = new ScrapeNest({ apiKey: "sn_your_key_here" });
const result = await client.search({ query: "python web scraping", num_results: 5 });
for (const r of result.results) {
console.log(r.title, r.url);
}
const deep = await client.searchDeep({ query: "what is rust lang", fetch_top: 3 });
for (const page of deep.pages) console.log(page.url, (page.text ?? "").length);
const answer = await client.searchAnswer({ query: "who wrote the odyssey", max_sources: 3 });
console.log(answer.answer);
const page = await client.scrapeUrl({ url: "https://example.com", render: "auto", return_markdown: true });
console.log(page.title);
const batch = await client.scrapeBatch({
urls: ["https://example.com/a", "https://example.com/b", "https://example.com/c"],
render: "auto",
});
console.log(`${batch.succeeded}/${batch.requested} - ${batch.credits_charged} credits`);
const gas = await client.gasPrices({ location: "Los Angeles, CA", grade: "regular", limit: 10 });
for (const s of gas.stations) console.log(s.brand, s.address, s.price);
const tr = await client.translate({ text: "Hello, how are you?", target: "es" });
console.log(tr.translated);
const run = await client.mapsReviewsAsync({ place_id: "0x6b12ae665e892fdd:0x3133f8d75a1ac251", max_reviews: 2000 });
const final = await client.waitForRun(run.run_id, { timeout: 1_800_000 });
console.log(final.status, final.credits_charged);
waitForRun defaults to a 300,000 ms timeout and throws
when it lapses, while the run continues server-side. Size the timeout to the
job for large Maps pulls, exactly as in the Python client.
Every method takes a single options object and returns a typed response. Errors map to
typed classes you can catch with instanceof:
import { ScrapeNest, RateLimitError, AuthenticationError, PaymentRequiredError } from "scrapenest";
try {
await client.search({ query: "test" });
} catch (e) {
if (e instanceof RateLimitError) console.log(`Rate limited. Retry after ${e.retryAfter}s`);
else if (e instanceof PaymentRequiredError) console.log("Out of credits");
else if (e instanceof AuthenticationError) console.log("Invalid API key");
}
Machine-readable: /openapi.json · interactive: /reference · for AI agents: /llms.txt. Stuck? Email [email protected].