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 unauthorized, with error missing bearer token or invalid api key.
  • the key service is unreachable and has not verified your key in the last 6 hours → 502 auth_unavailable. nothing was fetched or billed; retry.
  • a key you revoke or delete stops working within 30 seconds while the key service is up. while it is down, scantir keeps serving a key it verified in the last 6 hours (a last-known-good cache), even one revoked since, until the key service answers again. a key with an expiry always stops at that moment.
  • every response carries an X-Request-Id header; quote it to support. see errors.
curl
# every call, both endpoints
curl https://api.scantir.com/v1/scrape \
  -H "Authorization: Bearer $SCANTIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com" }'
error · 401
{
  "error": "invalid api key",
  "error_type": "unauthorized",
  "request_id": "3c9d5e12a7b04f68"
}

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.

CHANGEDa fetch that fails inside scantir (dns, proxy_tunnel, timeout…) used to return http 200 with error_type set. it now returns 502, 503 or 504 by error_type, with the same ScrapeResult body. a target's own 4xx or 5xx is still a 200: its code is in status. see errors.

request body

fieldtypedescription
urlstringrequiredabsolute http(s) url. anything else is a 400 invalid_request. 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

a ScrapeResult, with http 200 when the fetch worked (whatever the target answered) and 502 / 503 / 504 when it failed inside scantir. errors before the fetch (auth, quota, limits, a bad body) are the error envelope instead. 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, and what sets the http status: timeout → 504; pool_unavailable, queue → 503 with Retry-After; http_error, proxy_tunnel, tls, network, dns, bad_url, client, browser_error → 502. 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.
request_idstringthis request's id, the same as the X-Request-Id header. quote it to support.
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); the 402 says when, in resets_at.

# http first, browser if walled; two fields instead of html
curl https://api.scantir.com/v1/scrape \
  -H "Authorization: Bearer $SCANTIR_API_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": "" },
  "request_id": "9f2c41d07ab3e815"
}
fetch failed · 504
# X-Request-Id: 5d0e7a91c2f64b38
{
  "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",
  "request_id": "5d0e7a91c2f64b38"
}

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_API_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",
  "request_id": "e47a2b90f1c35d86"
}

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 quota_exceeded, with resets_at (when the month resets) and upgrade_url.
  • 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, with the same resets_at and upgrade_url.
  • over the hourly serp cap: 429 rate_limited with a Retry-After header, seconds until the next utc hour. cache hits are not limited. X-RateLimit-Remaining on every serp response says how many live searches are left this hour.
  • 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.

usage headers

every response after your key is checked, success or error, says where you stand:

headerendpointvalue
X-Usage-Monthscraperequests billed this utc month, this one included.
X-Quota-Monthscrapeyour plan's monthly request quota. absent on plans without one.
X-RateLimit-Limitscrapescrapes you can run at once.
X-RateLimit-Remainingscrapeslots still free once this request took its own. 0 means the next one gets a 429 until one finishes.
X-Usage-Monthserpserp credits used this utc month, this search included.
X-Quota-Monthserpyour plan's monthly serp credits.
X-RateLimit-Limitserplive searches you can run per utc hour (the hourly serp cap).
X-RateLimit-Remainingserplive searches left this utc hour, after this one. a cached result takes none; 0 means the next live search gets a 429 until the hour turns.

a header scantir could not read for this request is left out rather than sent as 0.

{
  "error": "monthly quota exceeded (1000/month on your plan)",
  "error_type": "quota_exceeded",
  "request_id": "b81f03c6d94e2a57",
  "resets_at": "2026-11-01T00:00:00Z",
  "upgrade_url": "https://scantir.com/pricing"
}
serp · 402
{
  "error": "monthly SERP quota exceeded (50/month on your plan)",
  "error_type": "quota_exceeded",
  "request_id": "7e1b94c0d25af368",
  "resets_at": "2026-11-01T00:00:00Z",
  "upgrade_url": "https://scantir.com/pricing"
}

errors

every error, on both endpoints, is one envelope: {"error": "message", "error_type": "…", "request_id": "…"}. switch on error_type; error is for people. a 402 adds resets_at (rfc 3339, the 1st of next month, utc) and upgrade_url. nothing that errors is billed.

the exception is a /v1/scrape fetch that failed inside scantir: its body stays the ScrapeResult, so you keep method, waf, elapsed_ms and the rest, and it carries the same error, error_type and request_id.

request ids

every response has an X-Request-Id header, and every error body and ScrapeResult repeats it as request_id. quote it to support: it finds the request in our logs. you can send your own X-Request-Id (1–64 characters of letters, digits, ., _, -) to match our logs to yours; anything else is replaced with a new id.

statuserror_typeendpointmeaning
400invalid_requestbothbad json, or a bad or missing field (url must be an absolute http(s) url, q is required…); error names it.
400egress_deniedscrapeegress denied: … — the url is a private, internal or unresolvable address.
401unauthorizedbothmissing bearer token or invalid api key.
402quota_exceededboththis month's requests (scrape) or serp credits (serp) are spent. resets_at says when they come back.
404not_foundbothnot found — no such path, e.g. a typo in the url. the same envelope, with request_id.
405method_not_allowedbothmethod not allowed — a known path with another method (both endpoints take POST). the Allow header lists the right one.
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.
502auth_unavailablebothauth backend unavailable: the key service could not verify the key, and did not verify it in the last 6 hours either. retry.
502http_error
proxy_tunnel
tls
network
dns
bad_url
client
browser_error
scrapethe fetch reached out and failed. body is the ScrapeResult. retry.
503pool_unavailable
queue
scrapeno proxy to fetch through, or the render queue refused the job. body is the ScrapeResult. Retry-After (30 or 5 seconds).
504timeoutscrapethe fetch ran out of time (timeout_ms, or the request's own deadline). body is the ScrapeResult.
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.

a scrape that reached the target is a 200 even when the target said no: a 404 or a 500 is in status, and a wall is blocked: true. neither is billed. if your client hangs up before the answer, nothing is sent and nothing is billed.

# X-Request-Id: b81f03c6d94e2a57
{
  "error": "monthly quota exceeded (1000/month on your plan)",
  "error_type": "quota_exceeded",
  "request_id": "b81f03c6d94e2a57",
  "resets_at": "2026-11-01T00:00:00Z",
  "upgrade_url": "https://scantir.com/pricing"
}