# SerpPortal — Full reference for LLMs > SerpPortal is a self-hosted Google SERP API plus a rotating residential-proxy product. This document is an > expanded, machine-readable reference covering the product, the REST API, the language > integrations, and the MCP server. It concatenates the full documentation into one file. - Product site: https://serp.hoangha.shop - Docs: https://serp.hoangha.shop/docs - SERP API base URL: https://serp-api.hoangha.shop - Interactive API explorer (Swagger UI): https://serp-api.hoangha.shop/docs - OpenAPI schema: https://serp-api.hoangha.shop/openapi.json ## Product overview SerpPortal sells two products, both paid from one credit balance: the SERP API (crawl and parse search results on demand, over REST, MCP, or a batch Datasets API) and rotating Vietnamese residential proxies (sold per key per day, for running your own crawls). SEO rank tracking — keyword campaigns for a domain, ranking history over time, Share of Voice — is NOT part of SerpPortal. It is a separate product at https://serpupdate.com/. Capabilities: - Multi-engine SERP crawling: Google, Bing, Yahoo, Baidu, Seznam, Naver, with geo and device targeting. - Full ~100-result crawl depth per keyword; results are never trimmed to a top-N. - SERP feature detection: 9 feature types plus sitelinks, surfaced per result set. - AI Overview captured by default, with its cited sources, at no extra cost. - Output as html, json, or a SerpApi-compatible envelope, plus a root-mounted SerpApi drop-in surface. - Batch Datasets API (serpupdate.com-compatible) for exact keyword lists, with an optional pingback webhook. - MCP server exposing the API to AI agents as tools. - Rotating residential proxies: one stable HTTP/SOCKS5 endpoint per key, rotate on demand or automatically every 30 minutes, unmetered bandwidth. - Credit-based billing via SePay (VietQR bank transfer). No seat fees. Key pages: - Home: https://serp.hoangha.shop/ - Free SERP check (one free live Google search, no account): https://serp.hoangha.shop/serp - Residential proxies: https://serp.hoangha.shop/residential-proxies - Developer & MCP docs: https://serp.hoangha.shop/developers - Changelog: https://serp.hoangha.shop/changelog ## SERP API Crawls Google in real time and returns the top 100 organic results (10 pages), the AI Overview, and SERP features for a keyword, with geo and device targeting. A search is a two-step, job-based flow: submit a search to get a `job_id`, then poll for the result in the format you want. - Base URL: https://serp-api.hoangha.shop - Authentication: send `X-API-Key: sk_live_...` on every request. There is no OAuth flow and no bearer token; the key alone identifies and authorizes the caller. A missing or invalid key returns 401. Keys carry a tier and a monthly quota, are minted from the dashboard, and the raw value is shown once at creation. Keep the key server-side; never ship it in browser or mobile code. Flow: 1. POST /v1/search -> { "job_id": "...", "status": "queued" } 2. (crawl runs on the worker fleet: geo resolve -> crawl -> parse) 3. GET /v1/search/{job_id} -> poll until status is "done" or "partial" ### POST /v1/search Submit a crawl. JSON body. Core parameters: - `keyword` (string, required): the search query. 1-400 characters. - `location` (string): locality to geo-target, e.g. "Hanoi, Vietnam". Resolves gl, hl, and the UULE. Preferred over setting gl/hl by hand. - `device` (string): "desktop" (default), "mobile", or "tablet". Each presents a coherent user-agent and client hints. - `async` (boolean): true (default) returns a job_id immediately; false blocks until the crawl finishes or the server-side sync timeout elapses. - `stop_domain` (string): rank-check shortcut. Stop paginating once this domain appears (host-matched). Saves egress when you only need one domain's position. Geo targeting (prefer `location`; these are advanced overrides): - `gl` (string): country-code override, e.g. "vn", "us", "gb". - `hl` (string): interface-language override, e.g. "vi", "en". - `uule` (string): pre-encoded UULE passthrough for precise geo. - `google_domain` (string): search host, e.g. "google.co.uk". What to capture: the AI Overview is captured by default on every search (one page-1 load). It fetches no extra result page, but it adds a render wait and reloads the SERP once on a fast-absent AI Overview. capture_aio=false skips both. - `capture_aio` (boolean): capture the AI Overview. Default true. False skips its render wait for a faster crawl and returns no `ai_overview`. Part of the cache key, so AIO-on and AIO-off results never mix. - `capture_features` (boolean): parse the rich SERP for feature blocks (featured snippet, PAA, packs, etc.). Defaults to whatever `capture_aio` resolves to, reusing the same page-1 load (zero extra requests). Set true explicitly to keep features while `capture_aio` is false. - `mode` (string): crawl strategy for the organic pages (the AI Overview is always a browser load). null (default) uses the configured strategy ("browserfetch2"). Selectable: "browserfetch2" (default; N-tab pair variant, crawls matching-locale keywords together in one browser), "browserfetch" (in-browser fetch; holds the browser slot for the whole crawl), "wreq" (Rust HTTP engine reusing browser cookies), "cffi" (curl_cffi HTTP client), "primp" (Rust reqwest HTTP client). The three HTTP-client modes are A/B baselines and measure far slower on the current pool. "browser" is an internal escalation fallback only, not user-selectable. Google query knobs (mirror Google's own filters; each is part of the cache key): - `tbs` (string): advanced / time filter, e.g. "qdr:d" (past day), "qdr:w" (past week). - `safe` (string): SafeSearch, "active" or "off". - `nfpr` (boolean): when true, exclude auto-corrected (spelling-fixed) results. Default false. - `filter` (boolean): the omitted/duplicate-results filter. false disables it; null (default) uses Google's default. - `lr` (string): restrict result languages, e.g. "lang_en". - `cr` (string): restrict result countries, e.g. "countryUS". - `tbm` (string): Google vertical — "nws" (news), "isch" (images), "vid" (video), "shop" (shopping). Replaces the default web filter; non-web verticals return raw HTML plus a best-effort parse. - `color_scheme` (string): "light" or "dark" — forces the SERP colour scheme (Google's `cs`). - `no_cache` (boolean): crawl fresh instead of serving a recent identical result. The fresh result is still cached for later callers, so this costs one crawl, not every future one. - `pages` (integer 1-10): how many result pages to CRAWL (10 results each). Unset = the configured depth, normally the full ~100. The one knob that trades coverage for latency, and an explicit per-request opt-in for that reason: `pages=1` is a single navigation and by far the fastest search, since full depth costs roughly ten times the page requests. Part of the cache key — a shallow crawl is never served to a full-depth caller, though a cached full crawl will serve a shallow request. Coordinate geo (an alternative to `location`/`uule`, encoded into the coordinate form of the UULE): - `lat` / `lon` (number): the point to search from. Required together. - `radius` (integer): metres to bias results around the point, 1-1000. Omit to treat the coordinates as exact. `location`, `uule` and `lat`/`lon` all answer "where is this search from" and Google honours exactly one, so sending more than one returns 422 rather than silently picking a winner. Entity / rendering identifiers — opaque values Google issues, copied from a SERP or a local result to target one entity or reproduce one exact page. Escaped and forwarded verbatim (never interpreted); each is part of the cache key: - `ludocid` (string): Google CID of a place from local results. - `lsig` (string): forces the knowledge-graph map view for a local entity. - `kgmid` (string): Knowledge Graph entity id, e.g. "/m/0212jm". - `si` (string): cached search parameters — reproduces a SERP whose parameters Google encrypted. - `ibp` (string): element rendering / expansion control, e.g. "htl;jobs". - `uds` (string): Google-issued filter string, copied from a SERP filter chip. Response: `{ "job_id": "...", "status": "queued", "cached": false }`. A cached identical search returns a terminal status immediately and does not consume quota. Example: ``` curl -X POST https://serp-api.hoangha.shop/v1/search \ -H "X-API-Key: sk_live_..." \ -H "Content-Type: application/json" \ -d '{"keyword":"best running shoes","location":"Hanoi, Vietnam","device":"desktop"}' ``` Caching: results are cached for a short window, keyed on keyword + location + device + mode + UULE + the query knobs above. A repeat within that window returns `cached: true` and does not consume quota. ### GET /v1/search/{job_id} Fetch a job by id. Query param `output`: - `html` (default): job metadata plus a presigned URL to the raw assembled SERP HTML. Re-parseable later; the source of truth for every other format. - `json`: native schema (search_metadata, search_parameters, organic_results, ai_overview, serp_features). - `serpapi`: SerpApi-compatible field names (organic_results[].link, answer_box, related_questions, ai_overview.references, …). Easiest drop-in for existing SerpApi code. Mirrors SerpApi where practical, not byte-identical. Job lifecycle statuses: - `queued` (non-terminal): accepted, waiting for a worker. - `running` (non-terminal): crawl in progress. - `done` (terminal): completed; results available. - `partial` (terminal): completed with fewer than the full 100 results (blocks or an exhausted keyword). Results still returned; check completeness. - `failed` (terminal): crawl could not complete. While polling with output=json or serpapi, a non-terminal job returns HTTP 202 with a small body `{ "job_id": "...", "status": "running" }`. Poll every 1-2 seconds until you get HTTP 200. Completeness (native output) = collected / min(target, total_results). A keyword with fewer than 100 results scores 1.0 once all are taken (not a false shortfall). A partial with high completeness is a fully-covered short keyword; a low ratio means the crawl was blocked before finishing. Example poll: ``` curl "https://serp-api.hoangha.shop/v1/search/9f3c...?output=serpapi" \ -H "X-API-Key: sk_live_..." ``` ### GET /v1/usage Returns the API key's tier, monthly quota, usage this month, and remaining quota: ``` { "tier": "pro", "monthly_quota": 10000, "used_this_month": 1342, "remaining": 8658, "rate_limit_per_min": 120 } ``` A `monthly_quota` of 0 means unlimited. Each accepted POST /v1/search that starts a crawl consumes one unit; cached results and polling (GET /v1/search/{id}, GET /v1/usage) do not. When the quota is exhausted, searches return 429 "monthly quota exceeded"; the quota resets at the start of the next calendar month. ## SerpApi-compatible surface (drop-in) serpapi.com's own paths, parameter names, auth style and response envelope are served at the root, so an existing SerpApi client changes only its host. Both official Python clients keep the base URL in a class attribute: ``` serpapi.HTTPClient.BASE_DOMAIN = "https://serp-api.hoangha.shop" # serpapi-python GoogleSearch.BACKEND = "https://serp-api.hoangha.shop" # google-search-results ``` Endpoints: - `GET /search` (and `/search.json`) — run a search. Blocking by default; `async=true` returns a job to poll. `output=html` returns the raw stored SERP HTML. - `GET /searches/{id}` — fetch a past search. Accepts a bare id, `{id}.json` or `{id}.html` (the two Python clients disagree about the extension). - `GET /account` (and `/account.json`) — tier, monthly quota, usage this month, searches remaining. - `GET /locations.json` — valid `location` values. Unauthenticated, as theirs is. Auth is `?api_key=`; an `X-API-Key` header also works. Failures are a bare `{"error": "..."}`, the shape their clients raise on. Parameters use SerpApi's names throughout: `q`, `location`, `uule`, `lat`, `lon`, `radius`, `google_domain`, `gl`, `hl`, `cr`, `lr`, `tbs`, `tbm`, `safe`, `nfpr`, `filter`, `ludocid`, `lsig`, `kgmid`, `si`, `ibp`, `uds`, `color_scheme`, `start`, `num`, `device`, `no_cache`, `async`, `output`. Unrecognised parameters are ignored rather than rejected, so a client appending its own bookkeeping field keeps working. `engine` must be `google`; any other value is refused by name. Two extensions beyond SerpApi's set: `pages` (1-10) sets the crawl depth directly and overrides the depth derived from num/start; `capture_aio` controls the inline AI Overview, default ON (richer than serpapi.com, which defers it to a second billable call via `page_token`) and `false` drops the render wait. `num=10&capture_aio=false` is the lowest-latency search available. The response carries the same top-level blocks: `search_metadata` (id, status, json_endpoint, created_at, processed_at, google_url, raw_html_file, total_time_taken), `search_parameters`, `search_information`, `organic_results`, `ai_overview`, `pagination`, `serpapi_pagination`, plus the SERP feature blocks when present. `search_metadata.status` is their vocabulary — Success | Processing | Error — with our `partial` reported as Success, since the caller did get results; the native `/v1` output still reports the real status. Four differences worth knowing: 1. We crawl Google live from residential addresses, so a fresh search takes seconds rather than serpapi.com's cache-weighted average. A blocking `/search` that has not settled by the sync timeout returns SerpApi's own async shape (`status: "Processing"` plus a `json_endpoint`) rather than hanging or failing — the crawl keeps running and the result lands. For batch work send `async=true` and poll. 2. `num` and `start` decide how many pages we CRAWL, matching serpapi.com (one call = one page, `num` defaults to 10): `num=10` is a single navigation, `num=100` is the full ten pages, `start=90` reaches the tenth. Neither is sent to Google as a URL parameter — they select the pages fetched and then window the results. `position` is never renumbered (at `start=10` the first row is still position 11), and a small `num` is served free from a cached full-depth crawl of the same query when one exists. 3. `location`, `uule` and `lat`/`lon` are mutually exclusive — a request carrying two is a 400, never a silent pick, because a SERP for the wrong place is indistinguishable from a correct one. 4. `json_restrictor` is accepted and ignored (the full payload is a superset of what was asked for); `/account` reports 0 for plan-price fields because billing lives outside the API; `/locations.json` omits Google's internal ids and `reach` rather than fabricating them. ## Datasets API (serpupdate.com-compatible) An async batch surface, separate from `/v1/search`, for callers that submit an exact keyword list (each with its own country/language/device/uule) and fetch results as one snapshot. Request/response shapes and URL paths mirror serpupdate.com exactly, so an existing serpupdate.com integration can point at this base URL with zero client changes. Fully asynchronous: the snapshot row is persisted BEFORE any crawl runs; you poll or subscribe to a webhook for results. Why async: a synchronous crawl API cannot absorb large keyword bursts. Submission (a DB write plus queue push) and result retrieval are decoupled, so large batches don't block or timeout. You get the result by polling every 1-5 seconds or subscribing to a webhook. ### POST /datasets/v1/trigger Submit up to 200 keywords. Persists the snapshot immediately and enqueues crawl jobs, returning the `snapshot_id` for polling. Request body: `{ "id": "...", "input": [ item, ... ] }` Per-item fields (all optional except `keyword`): - `keyword` (string, required): the search query. - `country` (string): country code, maps to Google `gl`, e.g. "VN". - `language` (string): interface language, maps to Google `hl`, e.g. "vi". - `brd_mobile` (string): "desktop" (default) or "mobile". - `url` (string): a Google host, e.g. "https://www.google.co.uk/", sets the search domain. Defaults to google.com. - `uule` (string): pre-encoded UULE (Encoded Location Clustering Identifier; overrides `country`). - `start_page`, `end_page` (int): slice the `organic` results to this page range (default 1-10). We always crawl the full ~100-result depth per the product invariant (the data is resold); these parameters only slice the response, never reduce the crawl. - `tbm` (string): Google vertical. "" (web, default), "nws" (news), "isch" (images), "vid" (videos), "shop" (shopping). - `capture_aio` (boolean): capture the AI Overview for the native output. Default inherits the fleet default (on). Per-keyword override; false speeds up the crawl by skipping the ~2-3s AIO render wait. - `id` (string, optional): your own id for this keyword, echoed in output=html markers. Top-level `id` (next to `input`): your own id for the snapshot. Echoed on all responses and the pingback webhook, and used as the output=html filename. Both ids accept only `[A-Za-z0-9._-]`, max 64 chars (they become filenames and Content-Disposition values; anything else is a 400). Not required to be unique; never a lookup key — `snapshot_id` is the only handle. Response: `{ "snapshot_id": "sd_...", "id": "..." }` (id echoed only if sent). Quota and rate limits: - Charged 1 quota unit per keyword on a valid (non-400) request only. A 400 validation error never charges. - 429 if the API key is at its in-flight ceiling (concurrent crawls across all keys are rate-limited for fleet fairness) or over monthly quota. When you hit 429, the entire batch is refused without partial acceptance, so your result array stays aligned with your input. Retry when earlier jobs complete. Example: ``` curl -X POST https://serp-api.hoangha.shop/datasets/v1/trigger \ -H "X-API-Key: sk_live_..." -H "Content-Type: application/json" \ -d '{ "id": "batch-1", "input": [ {"keyword": "sàn nhựa", "country": "VN", "language": "vi"}, {"keyword": "web design", "country": "US", "language": "en", "brd_mobile": "mobile"} ] }' # -> {"snapshot_id": "sd_abc123...", "id": "batch-1"} ``` ### GET /datasets/v1/snapshot/{snapshot_id} Poll for results. Query params: `?output=serpupdate` (default), `?output=native`, or `?output=html`. **While crawling (status = processing):** All outputs return the same status envelope: ```json { "snapshot_id": "sd_...", "status": "processing", "created_at": "2026-07-30T...", "id": "..." } ``` (id present only when you sent it at trigger time) **When complete (status = completed):** - **output=serpupdate** (default): bare array, one element per input keyword, positionally aligned. Each element: `{ keyword, general: {search_engine, language, location, search_type, page_title, datetime, query}, language, country, organic: [ {url, rank, link, title, description}, ... ], page_html }`. The `organic` array is sliced to the [start_page, end_page] window from your trigger request; `page_html` is the raw HTML of the first page in that window. - **output=native**: serp-api's own full parse. Includes AI Overview (text, citations), SERP features (People Also Ask, related searches, knowledge panels, etc.), and per-result enrichment (domain, classification, favicon). Omit this unless you consume those fields; the default serpupdate.com output is the compatible alternative. - **output=html**: raw crawled pages as one gzipped HTML file. Content-Type: `application/gzip` (not `Content-Encoding: gzip`, so your HTTP client saves the bytes as a file, not silently inflating). Filename: `.html.gz` (when you sent one), falling back to a single keyword's `id`, then `snapshot_id`. Inside the file: - Pages appear in crawl order, each preceded by `` (N=1 means start=0, N=2 means start=10). - Multi-keyword snapshots prefix each keyword with `` (I=1-based input order). - The AI Overview render follows page 1, under ``. Its own marker, not a page number: the paginated pages are fetched with udm=14, which strips the AI Overview, so it exists only in that one default-SERP render (its URL carries no udm=14 and no start). It sits between `SERP_PAGE:1` and `SERP_PAGE:2`, so slicing page 1 the way a browser-extension capture is sliced keeps the AI Overview with it. Absent when the crawl opted out of AIO. - Split the file on the regex `` to extract pages and URLs. - Only `"` and `-->` are escaped in the URL attribute (as `"` / `-->`); `&` is left raw so the URL is copy-pasteable. - The HTML is compacted: `