api reference · v1

scantir api

two endpoints: fetch a url, or search google. json in, json out. you are billed only when it works.

productionsynchronousbilled on success only
BASEhttps://api.scantir.com

authentication

every request carries your api key as a bearer token: Authorization: Bearer <key>. create and revoke keys in the console under keys. the key is shown once, at creation.

  • no token, or a token scantir does not know → 401 with {"error": "missing bearer token"} or {"error": "invalid api key"}.
  • the key service is unreachable → 502 auth backend unavailable. nothing was fetched or billed; retry.
  • verified keys are cached for about 30 seconds, so a key you revoke can answer for up to 30 more seconds.
curl
# every call, both endpoints
curl https://api.scantir.com/v1/scrape \
  -H "Authorization: Bearer $SCANTIR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com" }'
error · 401
{ "error": "invalid api key" }

scrape

POSThttps://api.scantir.com/v1/scrape

one url in, its content out. every scrape starts on the http tier: a tls-fingerprinted client (a real chrome or firefox profile with matching headers) through the managed residential pool, retrying on a fresh exit ip and fingerprint when the site walls it — up to six attempts, with a warmed-up retry (homepage first, then the target on the same cookie jar) when a cold request trips a soft gate. if every attempt is still blocked and you sent auto_escalate, the same url is rendered in a real browser on the best-ranked proxy; render: true skips the http tier entirely.

WHAT'S CHANGINGtoday a fetch that fails inside scantir (dns, proxy_tunnel, timeout…) still returns http 200, with error and error_type set. check error_type, not just the status code. phase 1 maps these to 502 / 503 / 504 with the same error_type. this page will say so when it lands.

request body

fieldtypedescription
urlstringrequiredabsolute http(s) url. anything else is a 400. urls that point at private, internal or unresolvable addresses are refused with 400 egress denied.
renderbooleanskip the http tier and render in a real browser. default false. needed for screenshot and wait_selector.
auto_escalatebooleanif every http attempt is walled, retry the url in the browser tier. default false. the response then has escalated: true.
screenshotbooleanbrowser tier only. viewport screenshot, returned as screenshot_b64. default false.
wait_selectorstringbrowser tier only. css selector to wait for before capturing, up to the timeout. an unmatched selector does not fail the request.
extractobjectmap of name → css selector. the response carries data (trimmed text of the first match per name, "" when nothing matches) instead of html. plain css only: no ::text / ::attr.
countrystringtwo-letter iso code, lower-case (de). pins the proxy exit country for this request. without it scantir uses the country it has learned for the domain, or the whole pool. echoed back as country.
timeout_msintegerper-attempt timeout: each http attempt and the browser render. 1,000–60,000 ms, default 30,000. a browser render is also capped by your plan: 15,000 ms on tinker, 30,000 on indie, 60,000 on team.

response

always a ScrapeResult. optional fields are omitted when empty.

urlstringthe url fetched. the browser tier reports the final url after redirects.
statusintegerupstream http status. 0 when nothing was fetched.
methodstringtier that answered: http or browser.
fingerprintstringidentity family the http tier used: chrome or firefox. absent on the browser tier.
countrystringexit country pinned for this request. absent when the whole pool was used.
wafstringanti-bot detected in front of the response: cloudflare, datadome, perimeterx, akamai, imperva or none.
blockedbooleanthe response is a challenge or block page, not content. also true for a 401, 403, 407, 429 or 503 from the target, and for every browser-tier failure.
escalatedbooleanthe http tier was walled and the browser tier answered.
elapsed_msintegerwall time of the whole request, every attempt included.
error_typestringfailure class when the fetch itself failed: bad_url, client, proxy_tunnel, pool_unavailable, dns, timeout, cancelled, tls, network, http_error (http tier); browser_error, queue, timeout, cancelled (browser tier). absent on success.
truncatedbooleantrue when the body was cut at the size cap: 10 MB on the http tier, 20 MB on the browser tier. absent otherwise.
errorstringhuman-readable failure. absent on success.
headersobjectupstream response headers, name → [values].
content_typestringupstream content-type.
body_typestringhtml, json or text, from the content-type (json is also sniffed).
htmlstringthe page, when body_type is html and no extract was sent.
bodystringthe body when it is not html (json, text).
dataobjectyour extract names → text. replaces html.
screenshot_b64stringbase64 image, when screenshot was set and the browser tier ran.

what is billed

a scrape counts toward your monthly quota only when it succeeded: no error, not blocked, and a 2xx or 3xx status. a 404 from the target, a wall, or a transport failure costs nothing. at the quota every call is 402 until the month resets (the 1st, utc).

# http first, browser if walled; two fields instead of html
curl https://api.scantir.com/v1/scrape \
  -H "Authorization: Bearer $SCANTIR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com",
    "auto_escalate": true,
    "extract": { "title": "h1", "price": ".price" }
  }'
response · 200
{
  "url": "https://example.com/",
  "status": 200,
  "method": "http",
  "fingerprint": "chrome",
  "waf": "none",
  "blocked": false,
  "escalated": false,
  "elapsed_ms": 812,
  "headers": {
    "Content-Type": [
      "text/html; charset=UTF-8"
    ]
  },
  "content_type": "text/html; charset=UTF-8",
  "body_type": "html",
  "data": { "title": "Example Domain", "price": "" }
}
fetch failed · 200 today
{
  "url": "https://example.com/",
  "status": 0,
  "method": "browser",
  "waf": "none",
  "blocked": true,
  "escalated": true,
  "elapsed_ms": 40213,
  "error_type": "timeout",
  "error": "browser timeout waiting for worker"
}

google serp

POSThttps://api.scantir.com/v1/serp/google

a query in, the results page as json out: organic results, ads, featured snippet, people also ask, related searches. results are localised with google's own gl / hl for the country and language you ask for, and every organic link is the real destination, resolved from google's redirect links before the page is returned.

1 credit per fresh pagecached 1 hour · cache hits freeshared capacity

request body

fieldtypedescription
qstringrequiredthe search. max 512 characters. alias query.
countrystringtwo-letter code, default us. sets google's gl, the market results are ranked for. uk is accepted as gb. alias gl.
languagestringgoogle's hl, default en. regional forms like pt-BR. alias hl.
locationstringgoogle canonical location name, e.g. Austin,Texas,United States. max 200 characters; sent as uule.
pageinteger1–10, default 1. ten results a page.
safebooleansafesearch on. default false.
time_periodstringhour, day, week, month or year.
no_cachebooleanskip the cache and fetch live. the fresh page still refreshes it.
include_htmlbooleanalso return the raw page as html. these responses are never cached.

response

lists are always arrays, never null. featured_snippet and html are omitted when absent.

search_parametersobjectthe normalised query: q, country, language, location, page, safe, time_period, google_url.
search_informationobjecttotal_results, time_taken_seconds, query_displayed, spelling_fix ("showing results for…"), no_results.
featured_snippetobjecttitle, link, displayed_link, snippet, list.
adsarrayposition, block (top | bottom), title, link, displayed_link, description.
organic_resultsarrayposition (1-based on this page), title, link, displayed_link, source, snippet, date.
people_also_askarrayquestion.
related_searchesarrayquery, link.
htmlstringthe raw page, only with include_html.
metaobjectcached, method (http, browser, or google-serp — the dedicated search fleet), escalated, exit_country, elapsed_ms, credits (what this call cost: 1 for a fresh page, 0 from cache).

caching and billing

  • identical queries (same q, country, language, location, page, safe, time_period) share one results page for one hour. a cache hit costs 0 credits and does not touch your hourly cap.
  • a fresh results page costs 1 credit. a page with no results is still a results page and is billed.
  • every failure is free. a failed live fetch still uses one of the hour's slots, except a 503 (no capacity), which gives it back.
  • serp has its own allowance, separate from your scrape quota: credits per month and live searches per hour, both set by your plan. see rate limits & quotas.

migrating from serpapi

the body accepts query, gl and hl as aliases for q, country and language, so a serpapi-shaped request works unchanged: {"query": "web scraping api", "gl": "us", "hl": "en"}. what differs: requests are POST with a json body, the key goes in the Authorization header instead of api_key, there is no num (ten results a page, use page), and location takes the same canonical names. organic_results keep the familiar position / title / link / snippet keys.

# page one, us results in english
curl https://api.scantir.com/v1/serp/google \
  -H "Authorization: Bearer $SCANTIR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "q": "web scraping api",
    "country": "us",
    "language": "en"
  }'
response · 200
{
  "search_parameters": {
    "q": "web scraping api",
    "country": "us",
    "language": "en",
    "page": 1,
    "safe": false,
    "google_url": "https://www.google.com/search?q=web+scraping+api&hl=en&gl=us&ie=UTF-8&pws=0"
  },
  "search_information": {
    "total_results": 128000000,
    "query_displayed": "web scraping api"
  },
  "ads": [],
  "organic_results": [
    {
      "position": 1,
      "title": "Web Scraping API — docs",
      "link": "https://vendor.example/docs/api",
      "displayed_link": "vendor.example › docs › api",
      "source": "vendor.example",
      "snippet": "Fetch any page as JSON with one POST request …"
    }
  ],
  "people_also_ask": [
    { "question": "Is web scraping legal?" }
  ],
  "related_searches": [
    {
      "query": "web scraping api free",
      "link": "https://www.google.com/search?q=web+scraping+api+free"
    }
  ],
  "meta": {
    "cached": false,
    "method": "google-serp",
    "elapsed_ms": 6420,
    "credits": 1
  }
}
error · 429
# Retry-After: 1312
{
  "error": "hourly SERP limit reached (5/hour on your plan)",
  "error_type": "rate_limited"
}

rate limits & quotas

scrapes count toward a monthly request quota, and only when they succeed, and your plan sets how many can run at once. google serp runs on shared capacity, so it has its own allowance: credits per month and live searches per hour. both are set by your plan and reset on the 1st (quota) and on the hour (hourly cap), utc.

planrequests / monthconcurrent requestsserp credits / monthlive serp searches / hour
tinker1,0002502
indie100,000201,0005
team1,000,0001005,00010
fleetcustomcustomcustomcustom
  • over the monthly request quota: every /v1/scrape call is 402 monthly quota exceeded.
  • over your concurrent requests: /v1/scrape is 429 rate_limited with Retry-After: 2. a slot frees as soon as one of your scrapes finishes; refused calls are not billed.
  • browser renders (render, or an auto_escalate escalation) share a small pool, so each team runs at most 1 at a time on tinker and 2 on paid plans. over that, the same 429.
  • over the monthly serp credits: 402 quota_exceeded.
  • over the hourly serp cap: 429 rate_limited with a Retry-After header, seconds until the next utc hour. cache hits are not limited.
  • there is no per-second limit on /v1/scrape. one url per call, synchronous; a call never runs longer than timeout_ms plus 15 seconds, so a client timeout of 90 seconds is enough.
{ "error": "monthly quota exceeded" }
serp · 402
{
  "error": "monthly SERP quota exceeded (50/month on your plan)",
  "error_type": "quota_exceeded"
}

errors

scrape errors are {"error": "message"}. serp errors, and the scrape 429, add error_type: {"error": "message", "error_type": "…"}. nothing that errors is billed.

statuserror_typeendpointmeaning
400—scrapeinvalid json body, url must be an absolute http(s) url, or egress denied: … (private, internal or unresolvable address).
400invalid_requestserpbad or missing parameter; error names it.
401—bothmissing bearer token or invalid api key.
402—scrapemonthly quota exceeded. resets on the 1st, utc.
402quota_exceededserpthis month's serp credits are spent.
429rate_limitedscrapeconcurrency limit reached (N at a time on your plan), or render limit reached (…) for browser renders. Retry-After: 2.
429rate_limitedserphourly live-search cap reached. Retry-After = seconds to the next utc hour.
502—bothauth backend unavailable. retry.
502blockedserpgoogle walled every attempt (captcha, js wall, consent). retry shortly.
502upstreamserptransport failure, or result links that could not be resolved. retry shortly.
502parse_failedserppage fetched but not parseable — most likely a google markup change. we watch this one.
503unavailableserpno search capacity right now. Retry-After says when to try again.
200variousscrapethe fetch itself failed: error_type is one of bad_url, client, proxy_tunnel, pool_unavailable, dns, timeout, cancelled, tls, network, http_error, browser_error, queue. today this is an http 200 ScrapeResult with error set — see what's changing.
serp · 502
{
  "error": "google served a captcha wall on every attempt; not billed, retry shortly",
  "error_type": "blocked"
}