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.

Base URL. 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.
Reading this with an AI agent? Skip the HTML and point it at 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}'

That’s the full ergonomics: one Bearer header, one JSON body, one JSON response. The same shape works for every endpoint below.

Null fields are omitted, not returned as 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.

HTTPCodeMeaningWhat to do
400unsafe_urlURL points at a private/internal/loopback host.Send a public http(s) URL.
400redirect_to_unsafe_urlTarget redirected the fetch to a private/internal address.Use a different URL; we won't follow the redirect.
400cookies_invalidOn /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.
400invalid_run_idrun_id is not a UUID.Use the value from the 202 response.
400place_id_formatOn Google Maps endpoints: the place URL / ID isn’t a recognised format.Pass a valid Maps place URL or CID.
401unauthorizedMissing or invalid bearer token.Check the key in /dashboard/keys.
401login_requiredOn /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).
402insufficient_creditsCredit balance is exhausted.Top up or upgrade at /dashboard/billing.
402overage_cap_reachedAccount hit the negative-balance floor (5,000 credits).Top up at /dashboard/billing or wait for the monthly credit reset.
402bandwidth_cap_exceededFree-tier daily bandwidth budget exhausted.Upgrade to any paid plan to remove the cap.
403no_ownerAPI key has no owning account (key revoked or detached).Generate a new key in /dashboard/keys.
403email_not_verifiedAccount’s email isn’t verified.Click the verification link or hit /verify/resend.
403account_suspendedAccount suspended.Contact [email protected].
402team_seat_blockedThe team is over its plan’s seat cap.Upgrade the plan or remove members.
404run_not_foundAsync run doesn’t belong to this customer, or has expired.Confirm the key + 7-day expiry.
404not_foundUnknown route, or the requested place/resource doesn’t exist.Verify the path, method, and inputs.
404place_not_foundOn 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.
405method_not_allowedWrong 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.
422validation_errorRequest body has missing or invalid fields.The response’s details array names each offending field. Fix and retry.
422country_unavailableOn /v1/scrape/url: no residential exit is available for the requested country right now. Not billed; retryable: false.Omit country or pick another country.
422selector_not_foundOn /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.
422target_errorOn screenshot / PDF: the target itself refused the render.Retry once, or try a different URL. Same in-body caveat as above.
429rate_limitedPer-minute or per-day rate limit exceeded.Honour Retry-After and back off.
429too_many_active_runsToo many async runs already queued or running for your plan.Wait for one to finish (Retry-After: 30) or upgrade. See Rate limits.
429abuse_throttledAccount temporarily throttled by abuse-detection (e.g. excessive refund rate).Wait the indicated retry_after; contact support if persistent.
500server_errorUnexpected server error.Retry with backoff; contact [email protected] if it persists.
502upstream_failedTarget site returned an error or could not be fetched.Retry once with backoff. Credits are not consumed on errors.
200 (in-body)pdf_failedTarget 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_failedTarget 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.
502no_sources/v1/search/answer couldn’t find usable sources.Rephrase the query.
502rpc_parse_failedGoogle Maps returned a malformed response.Retry once; if persistent, file a bug.
502rpc_unexpected_bodyGoogle Maps returned an unexpected (non-JSON) response.Retry once; often a transient challenge.
503upstream_not_configuredAn upstream we depend on isn’t configured server-side.Contact [email protected].
504gateway_timeoutRequest 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_codeMeaningWhat to do
anti_bot_blockThe target’s anti-bot refused the request.Already retried once in stealth (unless auto_escalate: false). Retry later, or leave auto-escalation on.
timeoutThe fetch exceeded the time budget.Raise timeout_seconds for slow targets, or retry.
not_foundThe target returned 404/410.Verify the URL. Delivered not-founds bill the flat 1-credit floor.
empty_responseThe page returned no usable content.Try render: "browser" for JS-rendered pages, or a different URL.
rate_limitedThe target answered 429 without an anti-bot challenge.Slow down for that site, then retry.
upstream_failedA 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.

Strict bool fields. On /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:

PlanReq / minuteReq / day
Free30500
Starter6010,000
Growth12050,000
Pro300200,000
Scale600500,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.

PlanConcurrent async runs
Free2
Starter5
Growth15
Pro40
Scale100

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:

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):

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

OperationCreditsPAYG $Used by
Plain HTTP fetch (datacenter, no JS)1$0.0002Static pages, JSON APIs, sitemaps.
Datacenter + JS render5$0.0010SPAs on soft targets.
Residential proxy, no JS10$0.0020Geo-blocked / soft anti-bot.
Residential + JS40$0.0080Hostile 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, viewport15$0.0030/v1/scrape/screenshot on cooperative sites.
Screenshot, full page25$0.0050/v1/scrape/screenshot with full_page=true.
Screenshot, selector-targeted15$0.0030/v1/scrape/screenshot with selector.
Screenshot, stealth fallback35$0.0070/v1/scrape/screenshot auto-escalated when the target blocks the standard browser engine.
PDF, standard paper25$0.0050/v1/scrape/pdf, Letter / Legal / A3-A6.
PDF, landscape or large paper35$0.0070/v1/scrape/pdf with landscape=true or paper format Tabloid / Ledger / A0-A2.
Cache hit (any tier)1$0.0002Repeat 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.0010Added 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.0002A 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.

SourceAdd-on costLatencyNotes
"bing" (default)+5~1 sBing Image Search. Returns direct image URLs when available.
"brave"+40~8 sBrave 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

EndpointCredit formula
/v1/search5 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/deep5 + 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/answer5 + 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/translate1 per call; 0 when the source equals (or auto-detects to) the target.

Pre-built scrapers

EndpointCredits
/v1/audio/transcript20 per 2 minutes of audio (ceiling-rounded; minimum 20).
/v1/scrape/google-maps/reviews100 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-prices10 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

GET /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"

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"
}
POST /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

FieldTypeDefaultNotes
urlstring (http/https)requiredAbsolute 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_proxyboolfalseForce a higher-tier proxy even when a direct fetch would work.
auto_escalatebooltrueWhen 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_seconds1–120~100Hard 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_assetsbooltrueWhen rendering with a browser, block images/fonts/media to save bandwidth.
extractbooltrueRun readability + JSON-LD extraction. Set false to get raw HTML in text.
ai_querystringnullNatural-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_schemaJSON Schema objectnullConstrains ai_extract to a typed object. Used together with ai_query.
include_usageboolfalseInclude a usage block in the response with credit totals.
include_htmlboolfalseInclude the raw page html in the response alongside the cleaned text. No extra credits.
return_markdownboolfalseConvert 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_apiboolfalseFor 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_chars200–2000000nullReturn at most this many characters of text (and markdown). Longer content comes back as a window; see Long pages below.
offsetint ≥ 00Where the window starts. Pass back the next_offset of the previous response to read on.
focusstring (up to 500 chars)nullTerms, 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_rulesobject <string, string | object> (up to 50)nullCSS-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_ms0–10000nullExtra wait in ms after load before reading the DOM. Browser render only.
wait_for_selectorstringnullCSS selector to wait for before returning. Browser render only.
wait_for_timeout_ms100–300005000Max ms to wait for wait_for_selector.
window_width320–38401280Browser viewport width. Browser render only. viewport_width accepted as an alias.
window_height240–2160720Browser viewport height. Browser render only. viewport_height accepted as an alias.
countryISO 3166-1 alpha-2 (2 chars)nullRoute 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_adsboolfalseBlock known ad and tracker domains during the fetch. Speeds up renders on ad-heavy sites. Browser render only.
simulate_behaviorboolfalseSprinkle mouse-move + scroll motion before extraction to fool behavioral bot detectors (PerimeterX, DataDome, Akamai BMP). Adds ~200–600 ms. Browser path only.
session_idstring (1–64 chars, [A-Za-z0-9_.-])nullSticky 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.
cookiesobject <string, string> (up to 50)nullCookies injected before navigation. Object only ({name: value}) on this endpoint.
headersobject <string, string> (up to 30)nullExtra request headers. Set User-Agent, Referer, etc. Host, Content-Length, Connection, Transfer-Encoding are stripped.
cache_ttl_seconds0–864009000 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
  }'

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
}
Always branch on 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.

POST /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.

Billing is per URL. Each URL bills at its own resolved tier (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

FieldTypeDefaultNotes
urlsstring[] (1–10, http/https)requiredAbsolute 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_escalatebooltrueRetry 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_proxyboolfalseForce every fetch through a higher-tier proxy.
block_assetsbooltrueOn browser renders, block images/fonts/media to save bandwidth.
extractbooltrueRun readability extraction to return cleaned title/description/text per URL.
return_markdownboolfalseAdd a markdown field per URL. Implies include_html. No extra credits.
markdown_mode"main" | "full""main"Same as on /v1/scrape/url.
include_htmlboolfalseInclude the raw html per URL alongside the cleaned text. No extra credits.
block_adsboolfalseOn browser renders, block known ad/tracker domains.
countryISO 3166-1 alpha-2 (2 chars)nullRoute 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_usageboolfalseInclude 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
  }'

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.

Batch 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.

POST /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:

FieldTypeDefault
fetch_top1–105
render"auto" | "direct" | "browser"unset: "direct" on fast/balanced, "auto" on thorough/stealth
depth"fast" | "balanced" | "thorough" | "stealth""balanced"
timeout_seconds0.1–20the 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
  }'

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.

POST /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:

FieldTypeDefault
max_sources1–105

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.

Image flags are rejected here. 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}'

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
}
POST /v1/translate 1 credit

Translate text between languages.

Request body

FieldTypeDefault
textstring, 1–5000 charsrequired
targetIETF BCP 47 ("es", "ja", "pt-BR", "zh-TW", ...); unsupported codes are a 422required
sourceIETF BCP 47 or "auto""auto"
include_usageboolfalse

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"}'

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.
POST /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

FieldTypeDefaultNotes
urlstringrequiredAudio source URL.
languageISO 639-1auto-detectHint for the transcriber, e.g. "en", "es".
cookiesstring (Netscape cookies.txt)nullOptional. See YouTube cookies below.
cache_ttl_seconds0–25920002592000Default 30 days. Forced to 0 when cookies is set.
include_usageboolfalseInclude 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..."}'

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:

  1. Use a burner Google account. Not your personal one, since every transcript request using these cookies adds to that account’s watch history.
  2. Install a cookies-export browser extension (Chrome: Get cookies.txt LOCALLY; Firefox: cookies.txt).
  3. Log into youtube.com in that browser, click the extension on any YouTube tab, choose Export. You’ll get a file starting with # Netscape HTTP Cookie File.
  4. Send the entire file contents as the cookies field. We accept up to 128 KB; only the youtube.com lines actually matter.
Security model. Cookies are written to a per-request tempfile (0600 perms), used by the audio resolver, and deleted before the response returns. Requests carrying 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.

POST /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"
}
POST /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).

Every error on this endpoint arrives in-body inside a 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,00060–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.

For pulls above ~700 reviews, use the async variant. The synchronous endpoint is bound by a request time limit. Submit to /v1/scrape/google-maps/reviews/async, then poll /v1/scrape/runs/{run_id}. Failed runs are not billed.

Request body

FieldTypeDefaultNotes
place_idstringrequiredFull 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_urlstringnoneOptional canonical Maps URL. Pass when the place’s FID has been rotated and the legacy short URL redirects to a generic location.
max_reviews1–10000200Hard cap (request ceiling). Actual delivery is bounded by what Google makes available (see the table above).
sortnewest | 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.
languageIETF BCP 47 ("en", "pt-BR", "zh-Hans")"en"Affects place name/address language only; review text is whatever the reviewer wrote.
cache_ttl_seconds0–864003600Applies when max_reviews is 2000 or less. Above 2000 the default is 0 (uncached) — pass an explicit value to override.
include_usageboolfalseInclude 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
  }'

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.

POST /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"
}
POST /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.

Upstream limitation. Some sources have progressively hidden per-station prices behind a login wall. On any given pull, individual 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

FieldTypeDefaultNotes
locationstring, 2–200 charsrequired5-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.
limit1–5020Hard cap on returned stations. Listings show ~13 by default.
cache_ttl_seconds0–360060010 minutes. Max 1 hour (prices change slowly).
include_usageboolfalseInclude 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
  }'

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.state are populated when the resolver can identify them; location.query always echoes the verbatim input.
  • region_stats is omitted when the source doesn’t render the regional summary block (typically rural ZIPs).
  • region_stats.region_type is "state" when region is a US state name and state holds the USPS code; it’s "city" when the source returned a metro-level summary (state is then null).
  • The source’s regional figures cover regular only. For any other grade, region_stats is computed from the returned stations of that grade (lowest and average, labelled with the searched city) and note says so; when no prices of that grade came back, region_stats is omitted with a note.
  • note is set when stations were returned without per-station prices, when an ambiguous query was auto-resolved, or when other caveats apply.
GET /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": "..." }
}
Suggested cadence. 2–5 seconds is fine for most scrapes; 10–15 seconds is more polite for Maps runs targeting 5K reviews.
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

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.

POST /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

FieldTypeDefaultNotes
urlstring (http/https)requiredAbsolute URL. Private/loopback hosts are blocked.
viewport_width320–38401280Browser viewport width in CSS pixels.
viewport_height240–2160720Browser viewport height in CSS pixels.
full_pageboolfalseWhen true, captures the entire scrollable height. Auto-detected up to the rendered page height.
selectorstring (CSS, up to 300 chars)nullCaptures 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_quality1–10085Ignored when format is png.
wait_extra_ms0–100000Extra wait after page load for late-loading content (SPAs, lazy images, charts).
cookiesobject <string, string> or [{name, value}, …] (up to 50)nullCookies injected before navigation. Accepts either a {name: value} map or a list of {name, value} objects; the list form is normalised server-side.
headersobject <string, string> (up to 30)nullExtra HTTP headers (User-Agent, Referer, etc.). Host/Content-Length are stripped.
session_idstring (1–64 chars, [A-Za-z0-9_.-])nullSticky exit IP (10-minute window). Pair with the matching /v1/scrape/url session to capture an authenticated page.
include_usageboolfalseInclude a usage block in the response.
cache_ttl_seconds0–864000 (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

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.

POST /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

FieldTypeDefaultNotes
urlstring (http/https)requiredAbsolute URL.
format"Letter" | "Legal" | "Tabloid" | "Ledger" | "A0"–"A6""Letter"Paper size.
landscapeboolfalseOrientation.
viewport_width320–38401280Render viewport width.
viewport_height240–2160720Render viewport height.
wait_extra_ms0–100000Extra wait before capture for late-loading content.
cookiesobject <string, string> or [{name, value}, …]nullCookies injected before navigation (for paywalled/logged-in pages). Accepts either a {name: value} map or a list of {name, value} objects.
headersobject <string, string>nullExtra request headers.
session_idstringnullReuse a sticky exit IP (see /v1/scrape/url).
include_usageboolfalseInclude a usage block in the response.
cache_ttl_seconds0–864000 (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

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

  1. POST /v1/scrape/google-maps/reviews/async with max_reviews=5000.
  2. Poll /v1/scrape/runs/{run_id} every 10s until status=completed.
  3. Batch result.reviews[*].text in chunks of 50, feed each chunk to your LLM with "Summarize themes and rate sentiment 1–5 per review." Map back via review_id.

Topic research with citations

  1. POST /v1/search/answer with a clear question and max_sources=6.
  2. The response gives you a written answer plus citations[].
  3. For deeper backing, take each citations[*].url, fan out to /v1/scrape/url with extract=true, and feed the cleaned text back into your own LLM prompt.

Podcast → show notes

  1. POST /v1/audio/transcript with the episode URL.
  2. 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

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.

Server URL. 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

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:

ToolDoesEndpointCredits
web_searchMulti-engine web search, merged & ranked/v1/search5
ask_webCited answer synthesized from live pages/v1/search/answer5 + 5×sources + 15
deep_searchSearch + full text of the top results/v1/search/deep5 + 5×pages
scrape_urlFetch one URL (handles JS + anti-bot)/v1/scrape/url1–40
translate_textTranslate text, auto-detect source/v1/translate1
maps_reviewsGoogle Maps reviews for one place.../google-maps/reviews/async100 per 50 reviews (40 when 50 or fewer; max 2,000 per call over MCP)
audio_transcriptTranscribe a podcast, video or audio URL/v1/audio/transcript/async20 per 2 min
poll_runCollect a finished background job/v1/scrape/runs/{run_id}free
scrapenest_accountKey status, credit balance, limits/v1/mefree
Background jobs. 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.
Long calls. Individual tool calls are capped at ~90s so they return cleanly within the edge timeout; a call that would exceed it is cancelled and not billed. For heavy stealth scrapes with a larger time budget, call the /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].