SERP & rankings
SERP HTML (raw & rendered)
Google's results page as HTML, for callers that parse the page themselves. Two ways, one endpoint:
- Without JavaScript (default): the full results page exactly as Google's server sent it. Nothing is extracted, rewritten or re-serialised: result links are Google's own
/gotoredirects, as on the page. Typically 2–3 seconds, about 1 MB of HTML. - With JavaScript (
js=true): the same page rendered, with the AI Overview opened out: finished streaming, with its Show more and Show all sections expanded in the DOM that comes back. Typically 8–15 seconds. Images, fonts and media are not downloaded; the HTML references them.
Both work for desktop and for iOS (device=ios): the page Google serves to Safari on an iPhone.
Endpoint#
https://api.searlo.tech/api/v1/serp/htmlCredits: 2 per page; 8 per page with js=true. Failed calls are refunded. Never cached: each call fetches the page at request time.
Parameters#
| Parameter | Type | Required | Description |
|---|---|---|---|
q | string | Yes | Search query (1–400 chars) |
gl | string | No | Country, 2 letters (default: us) |
hl | string | No | Language, 2–5 chars (default: en) |
page | number | No | Results page (1–10, default: 1) |
tbs | string | No | Google time or sort filter, max 40 chars (e.g. qdr:w) |
safe | string | No | active turns SafeSearch on; off |
device | string | No | desktop (default) or ios |
js | boolean | No | true returns the rendered page with the AI Overview opened out (default: false) |
expandAio | boolean | No | With js=true: return the AI Overview with its Show more and Show all sections expanded (default: true). false returns the rendered page as first shown |
format | string | No | json (default) wraps the page in a JSON envelope; raw answers with the page itself as text/html |
location | string | No | A country, by name or two-letter code (e.g. Germany, DE), applied as gl. A city or state returns 400 CITY_LOCATION_UNAVAILABLE and is not charged (see Location). Cannot be combined with uule |
uule | string | No | A country's uule is applied as its gl; any other token returns 400 CITY_LOCATION_UNAVAILABLE |
One call is one results page: page=2 is results 11–20. Google serves about ten organic results a page and has not honoured a larger num since 2025, so there is no num here. Two identical calls are two fetches, and two pages.
Raw or rendered?#
| Raw (default) | Rendered (js=true) | |
|---|---|---|
| Credits | 2 | 8 |
| Typical time | 2–3 s (p95 about 7 s) | 8–15 s |
| HTML | about 1 MB | 1–2 MB |
| What it is | The bytes Google's server sent, before any JavaScript ran | The DOM after Google's JavaScript ran, with the AI Overview opened out |
| Organic results, ads, People Also Ask questions, related searches, knowledge panel | Yes | Yes |
| AI Overview | Its first part only: Google streams the rest in after the page loads (in our measurements a median of 39% of the full text, and 1–4 of its sources) | In full, every source included (Show more and Show all pressed) |
| Links | Google's /goto redirects, as served | As on the rendered page |
iOS (device=ios) | Yes | Yes |
Raw is the choice for rank and SERP-feature data at volume. Rendered is for the AI Overview's full text and sources, or the page as a person sees it. For the overview as JSON rather than HTML, Full SERP reads it in full for 3 credits (from 2026-11-01, 8 when it has to render the page to complete the overview).
Response (format=json)#
{ "success": true, "searchParameters": { "q": "how does compound interest work", "type": "rendered", "gl": "us", "hl": "en", "page": 1, "device": "desktop" }, "html": "<!doctype html><html itemscope=\"\" itemtype=\"http://schema.org/SearchResultsPage\" lang=\"en\">…", "url": "https://www.google.com/search?q=how+does+compound+interest+work&hl=en&gl=us", "empty": false, "rendered": true, "aio": { "present": true, "expanded": true, "sourcesOpened": true, "showAll": "pressed", "sourcesBefore": 7, "sources": 7, "sourcesVisible": 11 }, "aiOverview": { "text": "Compound interest is interest earned on both the principal and the interest already added to it...", "markdown": "Compound interest is interest earned on both the principal and the interest already added to it...", "sources": [{ "position": 1, "label": "Investopedia", "url": "https://www.google.com/goto?url=…", "resolved": false }] }, "bytes": 2089123, "fetchedAt": "2026-09-29T18:39:40.000Z", "source": "searlo.tech(google_serp_rendered)", "credits": { "used": 8 }}| Field | Description |
|---|---|
html | The page. Without js: the bytes Google's server sent. With js: the DOM after the page's JavaScript ran and the AI Overview was opened out |
url | The results page's URL |
empty | true when Google answered with a results page that has no results (it is still the page, and still returned) |
rendered | Whether JavaScript ran (js=true) |
aio | With js=true, what happened to the AI Overview: present (Google showed one), expanded (Show more took), sourcesOpened (every source is on show: Show all took, or there was nothing left to open), showAll (what the Show all step found: pressed; absent, no Show all to press; complete, every source already on screen; failed, the press took nowhere with sources still hidden), sources (unique source links on the page), sourcesVisible (source links on screen) |
aiOverview | With js=true on desktop: the overview parsed from the captured page (text, markdown, sources). null on iOS: the mobile layout is in the html only |
bytes | Size of html in bytes (UTF-8) |
Response (format=raw)#
The body is the page itself, Content-Type: text/html; charset=utf-8. The metadata moves to headers:
| Header | Value |
|---|---|
X-Searlo-Url | The results page's URL |
X-Searlo-Fetched-At | When it was fetched (ISO 8601) |
X-Searlo-Empty | true for a results page with no results |
X-Searlo-Rendered | true when JavaScript ran |
X-Searlo-Aio | With js=true: present=…; expanded=…; sources-opened=…; sources=…; show-all=… |
X-Credits-Used | Credits this call took |
AI Overview notes#
- Google decides whether an AI Overview appears, per query and per request.
aio.presenttells you; the page is billed either way. - Desktop: the overview renders in two parts once opened; all of its sources are in the returned HTML, and Show all makes them visible. A short sources list is shown in full without one (
showAllabsentorcomplete). - iOS: the mobile overview has no Show all control (
showAll: "absent",sourcesOpened: true); its sources are cards inside the overview, all in the HTML. Its Show more lifts a height limit rather than adding content, so on iOSaio.expandedmeans the tap went through. The overview's full text is in the HTML once it has finished streaming, which is what the render waits for. - An AI Overview is non-deterministic: the same query can return a different overview, with different sources, from one request to the next.
iOS#
device=ios returns the mobile layout Google serves to Safari on an iPhone, from an emulated iPhone rather than a physical device.
Latency, capacity and timeouts#
Measured through the public API (Cloudflare, our edge, the SERP hosts) in September 2026:
| p50 | p95 | Success | |
|---|---|---|---|
| Raw, 6 concurrent | 3.1 s | 7.6 s | 99.3% of 302 calls |
| Rendered, 3 concurrent | 9.2 s | 11.9 s | 100% of 60 calls |
Pages are fetched on dedicated SERP hosts. One host sustains at least 5 raw pages a second (97% or better at 16 concurrent requests, the most we have driven it) and about 0.7 rendered pages a second (rendering is CPU-bound), and capacity grows by adding hosts. For sustained volume, tell us your daily volume and peak rate per mode, and capacity is reserved for your key before you ramp.
Set client timeouts of 30 s for raw pages and 90 s for rendered ones. The server gives up at about 92 s and answers either way: a call that runs out of time is a 503 and is not charged.
Retries#
429BUSY: the endpoint is at capacity (withjs=true, rendering capacity). WaitRetry-Afterseconds (1–10) and retry.503withretryable: true(UPSTREAM_UNAVAILABLE): no usable page was available within the call. Retry with backoff, for example after 2, 5 and 10 seconds.400and422: retrying the same request gives the same answer.- A failed call is never charged, so a retry cannot bill twice: you pay once, for the call that returns the page.
Errors#
| Status | code | Meaning | Retry |
|---|---|---|---|
400 | VALIDATION_ERROR | Invalid parameters, or a device other than desktop / ios | No |
400 | CITY_LOCATION_UNAVAILABLE | location or uule names a city or a state | No |
400 | UNKNOWN_LOCATION | location is not a country we recognise (suggestions lists close matches) | No |
402 | INSUFFICIENT_CREDITS | Not enough credits for the call | After a top-up |
422 | QUERY_NOT_SERVED | A query shape this endpoint does not serve: search operators (site:, inurl:, intitle:, filetype:, before: / after:), e-mail lookups and long OR chains | No |
422 | COUNTRY_UNAVAILABLE | This gl cannot be served right now | Later |
429 | BUSY | At capacity (with js=true, rendering capacity) | After Retry-After |
429 | 4201 / 4202 | The account or API-key rate limit | After Retry-After |
403 | FEATURE_NOT_AVAILABLE | Switched off on this deployment (retryable: false) | No |
503 | UPSTREAM_UNAVAILABLE | No usable page was available within the call (retryable: true) | Yes, with backoff |
None of them is charged. A failure answers JSON even with format=raw:
{ "success": false, "code": "QUERY_NOT_SERVED", "retryable": false, "message": "This query shape is not served as raw HTML: search operators (site:, inurl:, intitle:, filetype:, before:/after:), e-mail lookups and long OR chains."}Going to production#
- Use
format=rawwhen you store or parse the page: it arrives astext/htmlwith no JSON escaping, and the metadata is in theX-Searlo-*headers. - Send
Accept-Encoding: gzip. A raw page is about 1 MB of HTML and compresses to a fraction of that. - Honour
Retry-Afteron every429, and retry503s markedretryable: truewith backoff. - Use the timeouts above: 30 s raw, 90 s rendered.
- Track spend from
X-Credits-Used(credits.usedin JSON) andX-Credits-Remaining. - Read `GET /search/capabilities` for the endpoint's live state and its success rate over the last two hours.
- A key's default caps are 20,000 requests a minute and 400,000 a day (Rate Limiting). Ask us for custom limits and reserved capacity before a large ramp.
Examples
# The page as Google sent it (2 credits)curl "https://api.searlo.tech/api/v1/serp/html?q=best+running+shoes&gl=us&hl=en" \ -H "x-api-key: sk_your_api_key" # The same, as the HTML file itselfcurl "https://api.searlo.tech/api/v1/serp/html?q=best+running+shoes&format=raw" \ -H "x-api-key: sk_your_api_key" -o serp.html # Rendered, AI Overview opened out (8 credits)curl "https://api.searlo.tech/api/v1/serp/html?q=how+does+compound+interest+work&js=true" \ -H "x-api-key: sk_your_api_key" # What an iPhone gets, renderedcurl "https://api.searlo.tech/api/v1/serp/html?q=how+does+compound+interest+work&device=ios&js=true&format=raw" \ -H "x-api-key: sk_your_api_key" -o serp-ios.htmlconst API = "https://api.searlo.tech/api/v1";const headers = { "x-api-key": process.env.SEARLO_API_KEY }; // Rendered, AI Overview opened outconst params = new URLSearchParams({ q: "how does compound interest work", js: "true" });const res = await fetch(API + "/serp/html?" + params, { headers });const page = await res.json();if (!page.success) throw new Error(page.message); console.log(page.aio); // { present, expanded, sourcesOpened, showAll, sources, ... }await import("node:fs").then(fs => fs.writeFileSync("serp.html", page.html));import osimport requests API = "https://api.searlo.tech/api/v1"HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]} # The page an iPhone gets, rendered, as the HTML file itselfr = requests.get( f"{API}/serp/html", params={"q": "how does compound interest work", "device": "ios", "js": "true", "format": "raw"}, headers=HEADERS, timeout=90,)r.raise_for_status()print(r.headers.get("X-Searlo-Aio"))with open("serp-ios.html", "w", encoding="utf-8") as f: f.write(r.text)Stuck? Write to [email protected], or check live status.