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.
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. If you get 402, your credit balance is exhausted, so 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. Because the connection is held open
with keep-alive whitespace before the body is known, a handler error on these three
arrives inside an HTTP 200 body as
{"error": {...}, "http_status": <N>} (after leading whitespace),
not as a real 5xx status line. Parse the body and branch on
http_status. Auth, validation, and rate-limit errors still fire before
streaming begins, so those keep their real 4xx status line.
| 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. |
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. |
429 | rate_limited | Per-minute or per-day rate limit exceeded. | Honour Retry-After and back off. |
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 | – | 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. |
upstream_failed | A transient fetch-infrastructure error. | 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), Maps place resolution (place_id_unresolvable,
parse_failed), translate language validation
(unsupported_target_language, translation_rejected), 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. Always send a JSON boolean.
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.
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 30 days for audio).- omitted: use the per-endpoint default (15 min for scrape/url, 5 min for search, 1 hour for Maps reviews, 30 days for audio).
Requests carrying YouTube cookies are never cached, so cookie-protected
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. |
| 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, 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. Wider sources beyond Bing's index. Higher latency. |
Totals include the search base. 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; 0 when nothing is delivered (zero results and zero images). |
/v1/search/deep | 5 + 5 × pages_fetched (pages that returned text). |
/v1/search/answer | 5 + 5 × sources_fetched + 5 (answer overhead). The +5 answer step is waived when the model can’t ground an answer in the sources. |
/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 | 5–120 | ~100 | Hard time budget for the whole request, including any auto-escalation retry. Lower it (e.g. 20) 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 premium credits. |
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. |
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. "us", "de", "fr"). Forces a higher-tier proxy. Residential exits are available for us, be, de, fr, it, sg; other codes have no exit and fail upstream. |
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. |
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,
"error": null,
"error_code": null
}
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 null
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. Blocked and empty fetches are free.
Auto-escalation. When a non-stealth 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. Disable with auto_escalate: false.
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), or upstream_failed
(transient fetch error — retry).
/v1/scrape/batch
per URL
Fetches up to 10 URLs in a single call. Each URL is fetched independently and concurrently, so one URL failing never fails the rest. The render options apply to every URL in the batch.
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 responses are never cached, so there is no
cache_ttl_seconds field and items never report cache_hit.
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. |
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. |
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 proxy in this country (e.g. "us"). Forces a higher-tier proxy. |
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["ok"], 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.ok, 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...",
"structured": null,
"ok": true,
"credits_charged": 1,
"error": null,
"error_code": null
},
{
"url": "https://example.com/blocked",
"status_code": 403,
"ok": false,
"credits_charged": 0,
"error": "anti-bot protection refused the request",
"error_code": "anti_bot_block"
}
],
"requested": 3,
"succeeded": 2,
"credits_charged": 3
}
Each item carries its own ok, credits_charged,
error, and error_code. error_code is a
machine-readable failure category; anti_bot_block means the target’s
anti-bot protection refused the request — retry that URL with
render: "stealth". succeeded counts the items that returned a
usable page; credits_charged at the top level is the sum across all items.
When include_usage=true, the usage.breakdown reports the batch
total under a single fetch key.
/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 |
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 | "news" | null |
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" | "bing news" | "duckduckgo news")[] (1–5 entries) | null (defaults to bing,google,duckduckgo) |
include_images | bool | false |
include_image_descriptions | bool | false |
image_sources | ("bing" | "brave")[] | ["bing"] |
include_usage | bool | false |
categories | comma-separated (e.g. "news") | null |
cache_ttl_seconds | 0–86400 | 300 |
Leave engines unset to query the default mix — Google, Bing, and
DuckDuckGo merged and deduped (when time_range is set the mix drops Bing,
whose date filter can't be honoured; the response note reports the swap).
An explicit engines list overrides the mix entirely. The
"bing news" and "duckduckgo news" values pull straight from
the news vertical — the same lane topic="news" routes to.
depth matters mostly on /v1/search/deep, where it governs the
per-page fetch budget and anti-bot effort; on plain /v1/search every depth
queries the same engine mix, with thorough/stealth allowing
extra time for the freshest engine data.
page is 1-based offset pagination, so page=2 returns the next
batch of results after the first.
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). |
Response (typical, abbreviated)
{
"query": "climate report 2026 ipcc",
"results": [
{
"position": 1,
"url": "https://...",
"title": "...",
"snippet": "...",
"engine": "google",
"engines": ["bing", "duckduckgo", "google"],
"score": 1.0,
"published_at": "2026-04-12T14:30:00Z"
}
],
"took_ms": 318,
"cache_hit": false,
"credits_charged": 5
}
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), plus:
| Field | Type | Default |
|---|---|---|
fetch_top | 1–10 | 5 |
render | "auto" | "direct" | "browser" | "auto" |
depth | "fast" | "balanced" | "thorough" | "stealth" | "balanced" |
Here depth also controls how hard we fetch each page.
fast and balanced (default) fetch every page directly in
parallel — lowest latency, and genuinely bot-walled pages simply fall back to
their SERP snippet. thorough and stealth escalate
bot-walled pages through a real-browser bypass with progressively larger
per-page budgets; stealth has the best coverage on the hardest
sites. Easy pages still return in a couple seconds regardless; the larger budget
only applies to pages that actually need the bypass.
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
}
/v1/search/answer
5 + 5×sources + 5 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)
plus render ("auto" | "direct" | "browser", default
"auto"). Instead of fetch_top, the number of sources to read is
set by:
| Field | Type | Default |
|---|---|---|
max_sources | 1–10 | 5 |
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-Hant", ...) | required |
source | IETF BCP 47 or "auto" | "auto" |
include_usage | bool | false |
BCP-47 region aliases are normalised server-side: pt-BR,
zh-Hant, 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.
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, Overcast, RSS), 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.
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
from 40
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.
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. |
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 | |
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": "London Eye",
"address": "Riverside Building, County Hall, London SE1 7PB, UK",
"rating": 4.5,
"review_count": 89234,
"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,
"owner_reply": null
}
],
"star_histogram": [1820, 1140, 980, 1500, 4200],
"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 [1820, 1140, 980, 1500, 4200] means 1820 one-star reviews, 4200 five-star
reviews). photos is omitted from a review entry when the reviewer didn’t
attach any; treat its absence as the empty case.
/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.
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. 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 | per-endpoint default | 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,
"note": null
}
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).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 any async run. 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>"}.
Use it to display a progress bar to your users; absent or null means the worker
hasn't emitted a milestone yet.
To stop an in-flight run, hit
POST /v1/scrape/runs/{run_id}/cancel. The worker stops on its next checkpoint
(cooperative cancellation). No refund: cancellation is best-effort and any work already
performed is billed.
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)
[ "$STATE" = "completed" ] || [ "$STATE" = "failed" ] && { echo "$STATUS" | jq; break; }
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",
"cancelled_at": null,
"expires_at": "2026-05-21T15:00:00Z",
"credits_charged": 10000,
"error_code": null,
"error_message": null,
"progress": null,
"result": {}
}
/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 the selector doesn't match, the capture falls back to the full viewport. Returns 422 selector_not_found 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 of the same URL+viewport+format. |
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",
"proxy_used": null,
"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.
/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 repeats. |
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,
"proxy_used": null,
"elapsed_ms": 2120,
"format": "A4",
"landscape": false,
"bytes": 20925,
"pdf_base64": "JVBERi0xLjQKJdPr6eEKMSAwIG9iago...",
"cache_hit": false,
"credits_charged": 25
}
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. Authenticate with your
API key as Authorization: Bearer sn_..., or append
?apiKey=sn_... to the URL for clients that have no custom-header field (Claude.ai
web, ChatGPT). No separate key — any key from
/dashboard/keys works.
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 (Developer Mode / connectors) add a connector with this URL: https://scrapenest.dev/mcp?profile=chatgpt&apiKey=sn_your_key_here The chatgpt profile exposes the search + fetch tools that ChatGPT deep research requires.
The default profile exposes six tools; each is a thin wrapper over the endpoint shown, billed at the same rate (tool output ends with the credits charged):
| 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 |
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 |
scrapenest_account | Key status, credit balance, limits | /v1/me | free |
/v1 API directly with a
higher timeout_seconds.
SDKs & libraries
Official clients are published for Python and TypeScript/JavaScript (both
version 0.5.0, 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)
print(len(final.result["reviews"]), "reviews")
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);
console.log(final.status, final.credits_charged);
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].