# Searlo API Documentation

> Complete API reference for Searlo Search API — web, image, news search and more.
> Source: https://searlo.tech/docs

---

## Table of Contents

- [Introduction](#introduction)
- [Quick Start](#quick-start)
- [Authentication](#authentication)
- [Web Search](#search-web)
- [Custom Search JSON API (drop-in)](#custom-search-json-api)
- [Image Search](#search-images)
- [News Search](#search-news)
- [Video Search](#search-videos)
- [Site Search](#search-site)
- [Advanced Search](#search-advanced)
- [Shopping Search](#search-shopping)
- [Maps, Places & Reviews](#local-search)
- [Scholar & Patents](#scholar-patents)
- [Simple Search](#search-simple)
- [Autocomplete (Suggestions)](#search-suggestions)
- [Batch Search](#search-batch)
- [Full SERP](#full-serp)
- [SERP HTML (raw & rendered)](#serp-html)
- [Rank Checks](#rankings)
- [AI Answers](#ai-answers)
- [Web Data (Scrape, Map, Crawl, Extract)](#web-data)
- [Async Tasks](#async-tasks)
- [Search Analytics](#search-analytics)
- [Email Verifier](#email-verify)
- [Account & Utilities](#account)
- [Rate Limiting](#rate-limiting)
- [Error Handling](#errors)
- [MCP Server](#mcp-server)
- [Changelog](#changelog)

---

<a id="introduction"></a>

# Introduction

Searlo is a developer-friendly search API. Web, image, news, video, shopping, local, scholarly and full-page Google search, AI answer engines, rank checks, web data and email verification — all with a single API key.

### Base URL

```
https://api.searlo.tech/api/v1
```

### Features

- **Web Search** — Ranked web results with snippets
- **Image / Video / News Search** — Media and real-time news results
- **Site & Advanced Search** — Site-restricted and filtered queries (date, file type, exact/exclude terms)
- **Shopping Search** — Product results
- **Maps, Places & Reviews** — Local businesses, their Google reviews, attributes and menus
- **Scholar & Patents** — Papers with citation counts; patents with filing and grant dates
- **Batch Search** — Up to 10 queries in one request
- **Autocomplete** — Google Autocomplete suggestions
- **Full SERP** — Ads, People Also Ask, related searches, knowledge graph and the AI Overview
- **Rank Checks** — Where a domain ranks for a keyword, with stored history
- **AI Answers** — ChatGPT, Copilot and Gemini answers with their citations (Perplexity is paused)
- **Web Data** — Scrape, map, crawl and extract structured data from pages
- **Async Tasks** — Queue long jobs, collect results by polling or webhook
- **Email Verifier** — Validate email deliverability
- **Search Analytics** — Your own usage analytics
- **MCP Server** — Connect AI agents (Claude, Cursor, VS Code, Windsurf)
- **Simple Auth** — One API key, no OAuth complexity

---

<a id="quick-start"></a>

# Quick Start

Get search results in under 2 minutes.

### 1. Get Your API Key

Sign up at [dashboard.searlo.tech](https://dashboard.searlo.tech) → Dashboard → API Keys → Create Key

You'll get a key like `sk_abc123...`

### 2. Make Your First Request

Include your API key in the `x-api-key` header:

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/web?q=hello+world&limit=5" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const res = await fetch(
  "https://api.searlo.tech/api/v1/search/web?q=hello+world&limit=5",
  { headers: { "x-api-key": "sk_your_api_key" } }
);
const data = await res.json();
for (const r of data.organic) console.log(r.position, r.title, r.link);
```

### Python

```python
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/web",
    params={"q": "hello world", "limit": 5},
    headers={"x-api-key": "sk_your_api_key"}
)
for r in res.json()["organic"]:
    print(r["position"], r["title"], r["link"])
```

### Go

```go
req, _ := http.NewRequest("GET",
    "https://api.searlo.tech/api/v1/search/web?q=hello+world&limit=5", nil)
req.Header.Set("x-api-key", "sk_your_api_key")

client := &http.Client{}
resp, _ := client.Do(req)
defer resp.Body.Close()

var result struct {
    Organic []struct {
        Position int    `json:"position"`
        Title    string `json:"title"`
        Link     string `json:"link"`
    } `json:"organic"`
}
json.NewDecoder(resp.Body).Decode(&result)
for _, r := range result.Organic {
    fmt.Println(r.Position, r.Title, r.Link)
}
```

### Ruby

```ruby
require "net/http"
require "json"

uri = URI("https://api.searlo.tech/api/v1/search/web?q=hello+world&limit=5")
req = Net::HTTP::Get.new(uri)
req["x-api-key"] = "sk_your_api_key"

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http|
  http.request(req)
}
JSON.parse(res.body)["organic"].each { |r| puts "#{r["position"]} #{r["title"]} #{r["link"]}" }
```

### PHP

```php
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL,
    "https://api.searlo.tech/api/v1/search/web?q=hello+world&limit=5");
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    "x-api-key: sk_your_api_key"
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

$response = curl_exec($ch);
$data = json_decode($response, true);
foreach ($data["organic"] as $r) {
    echo $r["position"], " ", $r["title"], " ", $r["link"], "
";
}
```


---

<a id="authentication"></a>

# Authentication

All API requests require an API key, sent in the `x-api-key` header or as `Authorization: Bearer sk_...`. The same key works for the REST API and the MCP server.

### Getting Your API Key

1. Sign in at [dashboard.searlo.tech](https://dashboard.searlo.tech)
2. Go to **Dashboard → API Keys**
3. Click **Create New Key**
4. Copy and save the key immediately (shown only once)

### Using Your API Key

Keys start with `sk_`. Send it as the `x-api-key` request header on every call, or as `Authorization: Bearer sk_...`.

A key is shown once, when you create it: we keep only a hash of it, and lists show a masked preview (`sk_1a2b3c4d••••9f0e`), never the key itself.

An API key is for the data API (search, SERP, AI answers, web data, MCP) and for read-only views of its own account: the balance ([`GET /credits/balance`](#account)), usage, limits, history and `GET /user/profile`. Managing the account needs a dashboard login: creating or deleting API keys, exporting or deleting your data, payments and billing. An API key used on those routes gets `403` `API_KEY_SCOPE_FORBIDDEN`, and is not charged. A missing, invalid or expired key gets `401` `UNAUTHORIZED`.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/web?q=test" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
// Store key in environment variable
const API_KEY = process.env.SEARLO_API_KEY;

const response = await fetch(url, {
  headers: { "x-api-key": API_KEY }
});
```

### Python

```python
import os
import requests

API_KEY = os.environ["SEARLO_API_KEY"]

response = requests.get(url, headers={"x-api-key": API_KEY})
```


---

<a id="search-web"></a>

# Web Search

Search the web and get ranked results with titles, URLs, and snippets.

### Endpoint

```
GET /search/web
```

> `GET /search` is an alias for `/search/web` (it accepts `q`, `limit`, `page`, `gl`, `hl` only: no `safe`, `lr`, `dateRestrict` or `cache`).

**Credits:** 1 per request. A search that finds nothing, or fails, is not charged.

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results per page (1–10, default: 10) |
| `page` | number | No | Page number (default: 1). Results are served down to about result 101: see Pages below |
| `safe` | string | No | Safe search: `active` or `off` |
| `lr` | string | No | Language restriction, max 50 chars (e.g. `lang_en`) |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`, `gb`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |
| `dateRestrict` | string | No | Only results from the last n days, weeks, months or years: `d7`, `w2`, `m1`, `y1`. Works with `gl` and `hl`. See Date filter below |
| `cache` | boolean | No | `false` skips the cached copy and searches now. Same credits (default: `true`) |

### Response

```json
{
  "searchParameters": {
    "q": "machine learning",
    "type": "search",
    "gl": "us",
    "hl": "en",
    "num": 10,
    "page": 1
  },
  "page": 1,
  "nextPage": 2,
  "previousPage": null,
  "totalResults": 1250000,
  "searchTime": 0.42,
  "organic": [
    {
      "position": 1,
      "title": "Introduction to Machine Learning",
      "link": "https://example.com/ml-intro",
      "snippet": "Machine learning is a subset of AI...",
      "description": "An introduction to machine learning concepts and methods.",
      "displayedLink": "example.com",
      "domain": "example.com",
      "favicon": "https://www.google.com/s2/favicons?domain=example.com&sz=32"
    }
  ],
  "credits": 1
}
```

Responses also set `X-Cache` (`HIT`/`MISS`) and credit headers (`X-Credits-Deducted`, `X-Credits-Remaining`).

### Pages

Web search serves results down to about result 101. A page whose first result would come after that returns `400` `PAGE_OUT_OF_RANGE` with the last page it can serve, and is not charged. At `limit` 10 the last page is 11, at `limit` 5 it is 21.

```json
{
  "success": false,
  "errorCode": "PAGE_OUT_OF_RANGE",
  "message": "Search pages this query up to page 11 at this limit. Ask for page 11 or earlier.",
  "retryable": false,
  "maxPage": 11
}
```

### Date filter

`dateRestrict` keeps the results from a recent window: `d7` is the last 7 days, `w2` the last 2 weeks, `m1` the last month and `y1` the last year. It works with `gl` and `hl`. [News Search](#search-news) takes the same filter, and [Advanced Search](#search-advanced) takes it as `dateRestrict` or `dateRange`.

A date filter cannot be combined with search operators in `q`: `site:`, `inurl:`, `intitle:`, `filetype:`, `before:` or `after:`, e-mail addresses and long `OR` chains. That request returns `400` `DATE_FILTER_UNAVAILABLE` with `retryable: false`, and is not charged: remove the filter or the operators. A value that is not one of `d`, `w`, `m` or `y` followed by a number returns `400` `VALIDATION_ERROR`.

```json
{
  "success": false,
  "errorCode": "DATE_FILTER_UNAVAILABLE",
  "message": "This query cannot take a date filter: the lane that applies dates does not serve operator queries (site:, filetype:, OR chains, e-mail addresses). Remove the date filter or the operators.",
  "retryable": false
}
```

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/web?q=machine+learning&limit=10&gl=us" \
  -H "x-api-key: sk_your_api_key"

# Results from the last two weeks, for Germany, in German
curl "https://api.searlo.tech/api/v1/search/web?q=kuenstliche+intelligenz&dateRestrict=w2&gl=de&hl=de" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
async function webSearch(query, options = {}) {
  const params = new URLSearchParams({
    q: query,
    limit: options.limit || 10,
    ...options
  });

  const res = await fetch(
    `https://api.searlo.tech/api/v1/search/web?${params}`,
    { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
  );

  return res.json();
}

const result = await webSearch("machine learning", { limit: 10, gl: "us" });
result.organic.forEach(r => console.log(r.position, r.title, r.link));
```

### Python

```python
import os
import requests

def web_search(query, limit=10, gl=None):
    params = {"q": query, "limit": limit}
    if gl:
        params["gl"] = gl

    response = requests.get(
        "https://api.searlo.tech/api/v1/search/web",
        params=params,
        headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
    )
    return response.json()

results = web_search("machine learning", limit=10, gl="us")
for r in results["organic"]:
    print(r["position"], r["title"], r["link"])
```


---

<a id="custom-search-json-api"></a>

# Custom Search JSON API (drop-in)

Google's Custom Search JSON API is closed to new customers, and existing customers lose it on January 1, 2027. Searlo answers the same request in the same JSON shape, so you move by changing the host and the key:

```
- https://www.googleapis.com/customsearch/v1?key=GOOGLE_API_KEY&cx=YOUR_CX&q=lectures
+ https://api.searlo.tech/customsearch/v1?key=SEARLO_API_KEY&cx=YOUR_CX&q=lectures
```

Step-by-step guides, with code we ran before publishing: [Python](https://searlo.tech/google-custom-search-api-migration/python), [Node.js](https://searlo.tech/google-custom-search-api-migration/node), [PHP](https://searlo.tech/google-custom-search-api-migration/php), [LangChain](https://searlo.tech/google-custom-search-api-migration/langchain), [Open WebUI](https://searlo.tech/google-custom-search-api-migration/open-webui), [n8n](https://searlo.tech/google-custom-search-api-migration/n8n) and [WordPress](https://searlo.tech/google-custom-search-api-migration/wordpress). Google's dates and the full parameter table are on the [migration page](https://searlo.tech/google-custom-search-api-migration).

### Endpoint

```
GET https://api.searlo.tech/customsearch/v1
GET https://api.searlo.tech/customsearch/v1/siterestrict
GET https://api.searlo.tech/api/v1/customsearch/v1
```

All three paths run the same search. `/customsearch/v1` is Google's own path, so a client that lets you set the API host needs no other change.

**Authentication:** pass your Searlo API key (`sk_…`) in the `key` parameter, where Google takes its key. The `x-api-key` header and `Authorization: Bearer sk_…` also work, and keep the key out of URLs. Searlo strips `key` from its own logs.

**Credits:** a call is billed exactly as [Web Search](#search-web) bills one: 1 credit per call, whatever `num` (1 to 10) and `start` are. A call with `searchType=image` is billed as [Image Search](#search-images): 1 credit. A call that returns an error is not charged.

### Parameters

| Parameter | Status | Notes |
|-----------|--------|-------|
| `q` | Supported | Required. Up to 500 characters |
| `key` | Supported | Your Searlo API key |
| `cx` | Accepted, no effect | An engine's site list lives in your Google console, which Searlo cannot read, so Searlo searches the whole web. To restrict results, use `siteSearch` or `site:` in `q` |
| `num` | Supported | 1 to 10 (default 10) |
| `start` | Supported | 1-based. As on Google, the last result can be at most the 100th: `start=91&num=10` is the last full page |
| `gl` | Supported | Country, two letters |
| `cr` | Supported | One country, as `countryXX`. Read as `gl` |
| `hl` | Supported | Interface language |
| `lr` | Supported | One language, as `lang_xx`. Read as the request language |
| `safe` | Supported | `active` or `off` |
| `dateRestrict` | Supported | `d[n]`, `w[n]`, `m[n]` or `y[n]`. Combining it with operator parameters (`siteSearch`, `fileType`, `orTerms`…) or with image search returns `400` |
| `siteSearch`, `siteSearchFilter` | Supported | `site:` (`i`, the default) or `-site:` (`e`) |
| `exactTerms`, `excludeTerms`, `orTerms`, `fileType`, `hq`, `lowRange`, `highRange` | Supported | Applied as the search operators Google applies |
| `searchType` | Supported | `image` for image search |
| `imgSize`, `imgType`, `imgColorType`, `imgDominantColor` | Supported | Image filters, with Google's values |
| `fields`, `prettyPrint`, `alt=json` | Supported | Partial responses and pretty-printed JSON |
| `googlehost`, `quotaUser`, `userIp` | Accepted, no effect | Listed in the `X-Searlo-Ignored-Params` header, with `cx` |
| `filter`, `c2coff`, `sort` | Default only | `filter=1`, `c2coff=0` and an empty `sort` are accepted. Any other value returns `400` |
| `linkSite`, `relatedSite`, `rights`, `callback` | Not supported | Returns `400` with the reason |
| `location`, `uule` and other city or point locations | Not supported | Country level only (`gl`, `cr`). Returns `400` |
| Any other parameter | Not supported | Returns `400`, as Google does for an unknown parameter |

### Response

The response has Google's `customsearch#search` shape:

```json
{
  "kind": "customsearch#search",
  "url": {
    "type": "application/json",
    "template": "https://api.searlo.tech/customsearch/v1?q={searchTerms}&num={count?}&start={startIndex?}&…&alt=json"
  },
  "queries": {
    "request": [
      {
        "title": "Searlo Custom Search - lectures",
        "totalResults": "2410000",
        "searchTerms": "lectures",
        "count": 10,
        "startIndex": 1,
        "inputEncoding": "utf8",
        "outputEncoding": "utf8",
        "safe": "off",
        "cx": "YOUR_CX"
      }
    ],
    "nextPage": [{ "startIndex": 11, "count": 10, "…": "the same fields" }]
  },
  "context": { "title": "Searlo" },
  "searchInformation": {
    "searchTime": 0.21,
    "formattedSearchTime": "0.21",
    "totalResults": "2410000",
    "formattedTotalResults": "2,410,000"
  },
  "items": [
    {
      "kind": "customsearch#result",
      "title": "Lectures | MIT OpenCourseWare",
      "htmlTitle": "<b>Lectures</b> | MIT OpenCourseWare",
      "link": "https://ocw.mit.edu/…",
      "displayLink": "ocw.mit.edu",
      "snippet": "Video lectures from …",
      "htmlSnippet": "Video <b>lectures</b> from …",
      "formattedUrl": "https://ocw.mit.edu/…",
      "htmlFormattedUrl": "https://ocw.mit.edu/…"
    }
  ]
}
```

- When nothing matches, `items` is left out, as Google does. `queries.nextPage` appears only when more results exist.
- Image results add `mime`, `fileFormat` and `image`. The `image` object holds `contextLink`, plus `width`, `height`, `thumbnailLink`, `thumbnailWidth` and `thumbnailHeight` when the image source reports them. `byteSize` is not returned.
- `pagemap` appears only when a result has a thumbnail (`pagemap.cse_thumbnail`). There is no `cacheId`, `labels`, `promotions` or `spelling`.
- Every response carries `X-Request-ID`. Quote it when you contact support.

### Errors

Errors use Google's envelope, so code that reads `error.code`, `error.errors[0].reason` or `error.status` keeps working:

```json
{
  "error": {
    "code": 400,
    "message": "Request contains an invalid argument.",
    "errors": [
      { "message": "Request contains an invalid argument.", "domain": "global", "reason": "badRequest", "location": "num", "locationType": "parameter" }
    ],
    "status": "INVALID_ARGUMENT",
    "details": [{ "@type": "type.googleapis.com/google.rpc.RequestInfo", "requestId": "…" }]
  }
}
```

| Status | `reason` | When |
|--------|----------|------|
| `400` | `badRequest`, `invalid`, `required` | A parameter is missing or invalid |
| `400` | `keyInvalid` | The key is not a valid Searlo API key (this includes a Google `AIza…` key) |
| `400` | `unsupportedParameter`, `cityLocationUnavailable` | A parameter Searlo does not take (see above) |
| `403` | `forbidden` | No key, or the account is suspended or has not confirmed its email |
| `403` | `dailyLimitExceeded` | The account is out of credits. The message links to the top-up |
| `429` | `rateLimitExceeded` | The [rate limit](#rate-limiting), or too many searches in flight. Wait for `Retry-After` |
| `503` | `backendError` | Search is temporarily unavailable. Wait for `Retry-After` |
| `500` | `backendError` | Internal error |

None of these is charged.

### What differs from Google

- `cx` has no effect: results come from the whole web, not from an engine's site list. `/siterestrict` works the same way.
- Results come from Searlo's web search, so ranking, snippets and the `totalResults` estimate can differ from your Google engine.
- Location is country level only, through `gl` or `cr`. Boolean `cr` and `lr` expressions are not supported.
- Running out of credits is a `403 dailyLimitExceeded` that clears when you top up, not at midnight.
- Call the API from a server. Browsers on other sites are blocked by CORS, and JSONP (`callback`) is not supported.

## Code Examples

### cURL

```bash
# Before: Google
curl "https://www.googleapis.com/customsearch/v1?key=$GOOGLE_API_KEY&cx=$CX&q=lectures&num=10"

# After: Searlo. Only the host and the key change
curl "https://api.searlo.tech/customsearch/v1?key=$SEARLO_API_KEY&cx=$CX&q=lectures&num=10"

# The same call with the key in a header
curl "https://api.searlo.tech/customsearch/v1?q=lectures&num=10" \
  -H "x-api-key: $SEARLO_API_KEY"

# Image search
curl "https://api.searlo.tech/customsearch/v1?key=$SEARLO_API_KEY&q=red+panda&searchType=image&imgSize=large"
```

### Python

```python
import os
import requests

API_URL = "https://api.searlo.tech/customsearch/v1"  # was https://www.googleapis.com/customsearch/v1
API_KEY = os.environ["SEARLO_API_KEY"]                 # was your Google API key

params = {"key": API_KEY, "cx": "YOUR_CX", "q": "lectures", "num": 10, "start": 1}
while params:
    data = requests.get(API_URL, params=params, timeout=30).json()
    if "error" in data:
        raise RuntimeError(data["error"]["message"])
    for item in data.get("items", []):
        print(item["title"], item["link"])
    next_page = data["queries"].get("nextPage")
    params = {**params, "start": next_page[0]["startIndex"]} if next_page else None
```

### JavaScript

```javascript
const API_URL = "https://api.searlo.tech/customsearch/v1"; // was https://www.googleapis.com/customsearch/v1
const API_KEY = process.env.SEARLO_API_KEY;                 // was your Google API key

const params = new URLSearchParams({ key: API_KEY, cx: "YOUR_CX", q: "lectures", num: "10" });
const res = await fetch(`${API_URL}?${params}`);
const data = await res.json();
if (!res.ok) throw new Error(data.error.message);

for (const item of data.items ?? []) console.log(item.title, item.link);
const next = data.queries.nextPage?.[0]; // { startIndex: 11, count: 10, ... } when there is more
```


---

<a id="search-images"></a>

# Image Search

Search for images with thumbnails and source URLs.

### Endpoint

```
GET /search/images
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results per page (1–10, default: 10) |
| `page` | number | No | Page number (default: 1). Google serves about 200 images per query: at `limit` 10 the last page is 21 |
| `size` | string | No | Minimum size: `icon`, `small`, `medium`, `large`, `xlarge`, `xxlarge` or `huge` |
| `imgSize` | string | No | Another name for `size` |
| `imgType` | string | No | Kind of image: `photo`, `face`, `clipart`, `lineart`, `stock` or `animated` |
| `imgColorType` | string | No | Colour treatment: `color`, `gray`, `mono` or `trans` (transparent background) |
| `imgDominantColor` | string | No | Dominant colour: `black`, `blue`, `brown`, `gray`, `green`, `orange`, `pink`, `purple`, `red`, `teal`, `white` or `yellow` |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |
| `cache` | boolean | No | `false` skips the cached copy and searches now. Same credits (default: `true`) |

The four filters take effect and combine: `size` (or `imgSize`), `imgType`, `imgColorType` and `imgDominantColor` each narrow the results, and the ones applied are echoed in `searchParameters`. A value outside the lists above returns `400` `VALIDATION_ERROR`. A page past the last one Google serves (`(page - 1) * limit` above 200) returns `400` `VALIDATION_ERROR` that names the last page; it is answered at once and is not charged.

### Response

```json
{
  "searchParameters": { "q": "sunset", "type": "images", "gl": "us", "num": 10, "page": 1, "imgSize": "large", "imgType": "photo" },
  "page": 1,
  "nextPage": 2,
  "previousPage": null,
  "totalResults": 50000,
  "searchTime": 0.35,
  "images": [
    {
      "position": 1,
      "title": "Beautiful Sunset",
      "imageUrl": "https://example.com/sunset.jpg",
      "width": 1920,
      "height": 1080,
      "thumbnailUrl": "https://example.com/thumb.jpg",
      "thumbnailWidth": 300,
      "thumbnailHeight": 168,
      "link": "https://example.com/gallery",
      "sourceUrl": "https://example.com/gallery",
      "source": "example.com",
      "domain": "example.com",
      "format": "image/jpeg",
      "imageId": "ANd9GcQexample"
    }
  ],
  "credits": 1
}
```

- `imageUrl` is the image itself, with its size in pixels in `width` and `height`. `thumbnailUrl` is Google's thumbnail, with its own `thumbnailWidth` and `thumbnailHeight`.
- `link` and `sourceUrl` are the page the image is on (`sourceUrl` falls back to the image URL when Google names no page). `source` and `domain` name that page's site.
- `format` is the image's MIME type, and `imageId` Google's id for it. A field Google does not give comes back `null`.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/images?q=sunset&limit=10&size=large" \
  -H "x-api-key: sk_your_api_key"

# Filters combine: orange-dominant photos
curl "https://api.searlo.tech/api/v1/search/images?q=sunset&imgType=photo&imgDominantColor=orange" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
async function imageSearch(query, options = {}) {
  const params = new URLSearchParams({
    q: query,
    limit: options.limit || 10,
    ...options
  });

  const res = await fetch(
    `https://api.searlo.tech/api/v1/search/images?${params}`,
    { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
  );

  return res.json();
}

const result = await imageSearch("sunset", { size: "large", limit: 10 });
result.images.forEach(img => console.log(img.title, img.imageUrl, `${img.width}x${img.height}`, img.sourceUrl));
```

### Python

```python
import os
import requests

def image_search(query, limit=10, size=None):
    params = {"q": query, "limit": limit}
    if size:
        params["size"] = size

    response = requests.get(
        "https://api.searlo.tech/api/v1/search/images",
        params=params,
        headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
    )
    return response.json()

results = image_search("sunset", size="large")
for img in results["images"]:
    print(img["title"], img["imageUrl"])
```


---

<a id="search-news"></a>

# News Search

Search news articles from thousands of sources worldwide.

### Endpoint

```
GET /search/news
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results per page (1–10, default: 10) |
| `page` | number | No | Page number (default: 1) |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |
| `dateRestrict` | string | No | Only articles from the last n days, weeks, months or years: `d7`, `w2`, `m1`, `y1`. Articles dated before the window are dropped, so a dated page can hold fewer than `limit` |
| `cache` | boolean | No | `false` skips the cached copy and searches now. Same credits (default: `true`) |

### Response

```json
{
  "searchParameters": { "q": "technology", "type": "news", "gl": "us", "num": 10, "page": 1 },
  "page": 1,
  "nextPage": 2,
  "previousPage": null,
  "totalResults": 12500,
  "searchTime": 0.28,
  "news": [
    {
      "position": 1,
      "title": "Tech News Article",
      "link": "https://news.example.com/article",
      "snippet": "Article summary...",
      "description": "A longer summary from the article's own metadata.",
      "source": "Example News",
      "date": "2026-09-28T10:30:00Z",
      "thumbnail": "https://news.example.com/article.jpg"
    }
  ],
  "credits": 1
}
```

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/news?q=technology&limit=10" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const res = await fetch(
  "https://api.searlo.tech/api/v1/search/news?q=technology&limit=10",
  { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
);
const data = await res.json();
data.news.forEach(a => console.log(`[${a.source}] ${a.title}`));
```

### Python

```python
import os
import requests

response = requests.get(
    "https://api.searlo.tech/api/v1/search/news",
    params={"q": "technology", "limit": 10},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
for a in response.json()["news"]:
    print(f"[{a.get('source')}] {a['title']}")
```


---

<a id="search-videos"></a>

# Video Search

Search for videos across the web.

### Endpoint

```
GET /search/videos
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results per page (1–10, default: 10) |
| `page` | number | No | Page number (default: 1) |
| `safe` | string | No | Safe search: `active` or `off` |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |
### Response

The same envelope as Web Search, with the results in `videos[]`. Fields a video does not have come back as `null`.

```json
{
  "searchParameters": { "q": "react tutorial", "type": "videos", "gl": "us", "num": 10, "page": 1 },
  "page": 1,
  "nextPage": null,
  "previousPage": null,
  "totalResults": 20,
  "searchTime": 1.1,
  "videos": [
    {
      "position": 1,
      "title": "React Tutorial for Beginners",
      "link": "https://video.example.com/watch?v=abc",
      "thumbnailUrl": "https://video.example.com/abc.jpg",
      "duration": "1:02:15",
      "durationSeconds": 3735,
      "source": "Example Channel",
      "sourceUrl": "https://video.example.com/@example",
      "publishedDate": "1 year ago",
      "views": "2.1M views",
      "viewCount": 2100000,
      "snippet": "Learn React in 1 hour...",
      "videoId": "abc"
    }
  ],
  "credits": 1
}
```

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/videos?q=react+tutorial&limit=10" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const res = await fetch(
  "https://api.searlo.tech/api/v1/search/videos?q=react+tutorial&limit=10",
  { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
);
const data = await res.json();
console.log(data.videos);
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/videos",
    params={"q": "react tutorial", "limit": 10},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
print(res.json()["videos"])
```


---

<a id="search-site"></a>

# Site Search

Restrict a web search to a single site or domain.

### Endpoint

```
GET /search/site
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `site` | string | Yes | Domain or URL (e.g. `example.com` or `https://example.com`) |
| `limit` | number | No | Results per page (1–10, default: 10) |
| `page` | number | No | Page number (default: 1) |
### Response

The same response as Web Search (results in `organic[]`), scoped to the given site.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/site?q=hooks&site=react.dev&limit=10" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const params = new URLSearchParams({ q: "hooks", site: "react.dev", limit: 10 });
const res = await fetch(
  `https://api.searlo.tech/api/v1/search/site?${params}`,
  { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
);
console.log((await res.json()).organic);
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/site",
    params={"q": "hooks", "site": "react.dev", "limit": 10},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
print(res.json()["organic"])
```


---

<a id="search-advanced"></a>

# Advanced Search

Run complex queries with filters: date range, file type, exact phrases, excluded terms and site restriction. Available as **GET** (query params) or **POST** (JSON body).

### Endpoint

```
GET  /search/advanced
POST /search/advanced
```

**Credits:** 1 per request

### Parameters

Provide the query as either `query` or `q` (one is required).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `query` / `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results (1–10, default: 10) |
| `dateRange` | string | No | One of `day`, `week`, `month`, `year` |
| `timeRange` | string | No | Alias of `dateRange` (`day`/`week`/`month`/`year`) |
| `dateRestrict` | string | No | A window as `d7`, `w2`, `m1`, `y1` (see Date filter under [Web Search](#search-web)). Takes precedence over `dateRange` |
| `fileType` | string | No | File type filter, max 20 chars (e.g. `pdf`) |
| `exactTerms` | string | No | Exact phrase to include, max 200 chars |
| `excludeTerms` | string | No | Terms to exclude, max 200 chars |
| `site` | string | No | Restrict to a domain/URL (e.g. `example.com`) |
| `siteSearch` | string | No | Another name for `site` |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |

A date filter (`dateRange`, `timeRange` or `dateRestrict`) cannot be combined with `site`, `fileType` or search operators in `q`: that request returns `400` `DATE_FILTER_UNAVAILABLE` and is not charged. `exactTerms` and `excludeTerms` work with a date.

### Response

The same response as Web Search (results in `organic[]`).

## Code Examples

### cURL (GET)

```bash
# A date and excluded terms
curl "https://api.searlo.tech/api/v1/search/advanced?q=climate+report&dateRange=year&excludeTerms=opinion" \
  -H "x-api-key: sk_your_api_key"

# A file type on one site (no date)
curl "https://api.searlo.tech/api/v1/search/advanced?q=climate+report&fileType=pdf&site=nasa.gov" \
  -H "x-api-key: sk_your_api_key"
```

### cURL (POST)

```bash
curl -X POST "https://api.searlo.tech/api/v1/search/advanced" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "climate report",
    "fileType": "pdf",
    "exactTerms": "sea level rise",
    "excludeTerms": "opinion",
    "site": "nasa.gov",
    "limit": 10
  }'
```

### Python (POST)

```python
import os
import requests

res = requests.post(
    "https://api.searlo.tech/api/v1/search/advanced",
    json={
        "query": "climate report",
        "dateRestrict": "m6",
        "excludeTerms": "opinion",
        "gl": "us",
        "limit": 10,
    },
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
)
print(res.json()["organic"])
```


---

<a id="search-shopping"></a>

# Shopping Search

Search product/shopping results.

### Endpoint

```
GET /search/shopping
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results (1–55, **default: 55**) |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`) |
| `hl` | string | No | Interface language code, max 5 chars (e.g. `en`) |

### Response

```json
{
  "success": true,
  "searchParameters": { "q": "wireless earbuds", "type": "shopping", "gl": "us", "hl": "en" },
  "shopping": [
    { "title": "Wireless Earbuds Pro", "price": "$79.99", "source": "example-store.com", "link": "https://..." }
  ],
  "source": "searlo.tech(google)"
}
```

If no products are found you get a `200` with `"shopping": []`. If results are temporarily unavailable you get a `503` with `"retryable": true`.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/shopping?q=wireless+earbuds&gl=us" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const res = await fetch(
  "https://api.searlo.tech/api/v1/search/shopping?q=wireless+earbuds&gl=us",
  { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
);
console.log((await res.json()).shopping);
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/shopping",
    params={"q": "wireless earbuds", "gl": "us"},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
print(res.json()["shopping"])
```


---

<a id="local-search"></a>

# Maps, Places & Reviews

Local business data from Google Maps: search the map, list the businesses that match a query in a named place, read one place's reviews, and get one place's full details: its attributes (payments, service options, children…) and menu.

### Endpoints

```
GET /search/maps
GET /search/places
GET /search/reviews
GET /search/place-details
```

**Credits:** 1 per request. These four endpoints also count against a tighter `local` rate-limit group, on top of the `search` group: a request must pass both (see [Rate Limiting](#rate-limiting)).

### Maps Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars). A place name in the query geocodes on its own, e.g. `coffee shops in austin` |
| `limit` | number | No | Results (1–20, default: 20) |
| `page` | number | No | Page number (1–100, default: 1) |
| `gl` | string | No | Country code, max 5 chars (e.g. `us`). With no `ll`, `lat` or `lng` it also sets where the search looks (see below) |
| `hl` | string | No | Language code, max 5 chars (e.g. `en`) |
| `ll` | string | No | Viewport as a Google `ll` token, max 64 chars, e.g. `@30.2672,-97.7431,13z` |
| `lat` | number | No | Latitude to steer results toward (-90 to 90) |
| `lng` | number | No | Longitude to steer results toward (-180 to 180) |
| `zoom` | number | No | Map zoom (1–21) |
| `cache` | boolean | No | `false` skips the cached copy and looks the places up now. Same credits (default: `true`) |

### Maps Response

```json
{
  "searchParameters": { "q": "coffee shops in austin", "type": "maps", "gl": "us", "hl": "en", "num": 20, "page": 1, "ll": "@39.5,-98.35,5z" },
  "lite": false,
  "page": 1,
  "totalResults": 20,
  "places": [
    {
      "position": 1,
      "title": "Example Coffee Roasters",
      "placeId": "ChIJ7cv00Dm1RIYRx5mHcIYlGmE",
      "cid": "0x8644b5a4e3d8f52b:0x5e1c2c3a4b5d6e7f",
      "featureId": "/g/11c5k8tq1n",
      "category": "Coffee shop",
      "categories": ["Coffee shop", "Cafe"],
      "rating": 4.7,
      "reviews": 1834,
      "price": "$$",
      "address": "123 Congress Ave, Austin, TX 78701",
      "latitude": 30.2672,
      "longitude": -97.7431,
      "website": "https://coffee.example.com/",
      "domain": "coffee.example.com",
      "phone": "(512) 555-0100",
      "timezone": "America/Chicago",
      "description": "Small-batch roaster with a patio.",
      "openState": "Open · Closes 6 PM",
      "openNow": true,
      "businessStatus": "OPERATIONAL",
      "hours": null,
      "openingDate": null,
      "openingDateText": null,
      "attributes": {
        "service_options": { "serves_dine_in": true, "has_takeout": true, "has_delivery": false },
        "payments": { "requires_cash_only": false, "pay_credit_card": true, "pay_mobile_nfc": true }
      },
      "thumbnailUrl": "https://lh5.googleusercontent.com/...",
      "reviewsLink": "https://www.google.com/maps/...",
      "bookingLink": null,
      "bookingLinks": null
    }
  ],
  "source": "searlo.tech(engine)",
  "credits": 1
}
```

- `lite: true` means Google served its reduced record. Since October 2026 that is what Google Maps shows every signed-out visitor, so expect `lite: true` on maps and places: no price level, and accessibility as the only attribute group (the sample above shows the full record). A lite answer is never cached.
- `businessStatus` says whether the place still trades: `OPERATIONAL`, `CLOSED_TEMPORARILY` or `CLOSED_PERMANENTLY`, as the Places API names them, or `null` when Google's record does not say. It is never guessed from missing data.
- `openNow` is `true` or `false` for whether the place is open right now, or `null` when the record does not say. `openState` is Google's own state line, in the language of `hl` (`"Abierto · Cierre: 21:30"` with `hl=es`). A place that has closed (`businessStatus` `CLOSED_TEMPORARILY` or `CLOSED_PERMANENTLY`) has both set to `null`.
- With no `ll`, `lat` or `lng`, `gl` also sets where the search looks: the main city of that country, for 51 countries (`gl=es` with `farmacia` answers from Spain, not the US). A place named in `q` (`hoteles en tokio`) decides for itself. `gl=us`, no `gl`, and a country without a default area keep the US-wide default. `searchParameters.ll` echoes the area used.
- In a maps result, `cid` is the `0x…:0x…` place id, `featureId` is a Knowledge Graph id (`/g/…`) and `placeId` is the Google Place ID (`ChIJ…`).
- `bookingLink` is the single link to send a user to when the place takes bookings or orders; `bookingLinks` holds the full set.
- `attributes` is Google's "About" list for the place, grouped as Google groups it (`service_options`, `payments`, `children`, `parking`, `offerings`…) and keyed by Google's own ids, the same in every language. A yes/no attribute is `true` or `false` (`attributes.payments.requires_cash_only` is `true` on a cash-only place, `false` where Google says it is not), a value attribute is a string (`price_level`), a list one an array (`pay_credit_card_types_accepted`). It is `null` when Google lists none, and a `lite` answer carries accessibility only.
- A place listed before it opens has `openingDateText`, Google's text in your `hl` (`"Opens 1 Nov"`), and `openingDate`, the date read from it (`"2026-11-01"`). Both are `null` on a place that is open, and so is `openState` on one that is not open yet. `/search/places` carries the same two fields.
- A maps query that finds nothing answers `200` with `places: []`, and is not charged.

### Places Parameters

`GET /search/places` lists the businesses that match a query in a place you name, instead of a map viewport.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | What to look for (1–500 chars), e.g. `dentist` |
| `location` | string | No | Where to search, max 200 chars, e.g. `Austin, Texas` |
| `limit` | number | No | Results (1–20, default: 20) |
| `page` | number | No | Page number (1–100, default: 1) |
| `gl` | string | No | Country code, max 5 chars |
| `hl` | string | No | Language code, max 5 chars |
| `ll` | string | No | Viewport token, as on maps |
| `lat` | number | No | Latitude (-90 to 90) |
| `lng` | number | No | Longitude (-180 to 180) |
| `cache` | boolean | No | `false` skips the cached copy and looks the places up now. Same credits (default: `true`) |

### Places Response

```json
{
  "searchParameters": { "q": "dentist", "type": "places", "location": "Austin, Texas", "gl": "us", "hl": "en", "num": 20, "page": 1 },
  "lite": false,
  "page": 1,
  "totalResults": 20,
  "places": [
    {
      "position": 1,
      "title": "Example Family Dental",
      "address": "456 Lamar Blvd, Austin, TX 78703",
      "latitude": 30.2849,
      "longitude": -97.7545,
      "rating": 4.9,
      "ratingCount": 412,
      "category": "Dentist",
      "categories": ["Dentist", "Cosmetic dentist"],
      "priceLevel": null,
      "phoneNumber": "(512) 555-0199",
      "website": "https://dental.example.com/",
      "domain": "dental.example.com",
      "openState": "Closed · Opens 8 AM",
      "openNow": false,
      "businessStatus": "OPERATIONAL",
      "hours": null,
      "openingDate": null,
      "openingDateText": null,
      "attributes": { "payments": { "pay_credit_card": true, "requires_cash_only": false } },
      "thumbnailUrl": "https://lh5.googleusercontent.com/...",
      "cid": "0x8644b5a4e3d8f52b:0x2f1e3d4c5b6a7980",
      "featureId": "0x8644b5a4e3d8f52b:0x2f1e3d4c5b6a7980",
      "knowledgeGraphId": "/g/1tf6yx2q",
      "placeId": "ChIJN1t_tDeuEmsRUsoyG83frY4"
    }
  ],
  "source": "searlo.tech(google_places)",
  "credits": 1
}
```

A places query that finds nothing returns `200` with `places: []`, and is not charged. `openState`, `openNow`, `businessStatus` and `lite` mean the same as on maps.

### Reviews Parameters

`GET /search/reviews` returns the reviews for one place. Identify it one of four ways. `placeId`, `featureId` and `cid` are faster than `q`, which costs a place lookup first.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `placeId` | string | One of four | A Google Place ID (`ChIJ…`), as on a maps result or from the Places API |
| `featureId` | string | One of four | The `0x…:0x…` place id: `featureId` on a places result, or `cid` on a maps result |
| `cid` | string | One of four | The decimal place id from a Google Maps share link (digits only, max 32). Not the `0x…` value |
| `q` | string | One of four | A place name to look up (1–500 chars); the top match is used |
| `limit` | number | No | Reviews to return (1–50, default: 10). A call returns the first page Google serves, about eight reviews: `limit` can only lower that |
| `gl` | string | No | Country code, max 5 chars |
| `hl` | string | No | Language code, max 5 chars. Sets the language of `relativeDate` |

### Reviews Response

```json
{
  "searchParameters": { "type": "reviews", "featureId": "0x8644b5a4e3d8f52b:0x2f1e3d4c5b6a7980", "hl": "en", "gl": "us", "num": 10 },
  "place": {
    "title": "Example Family Dental",
    "address": "456 Lamar Blvd, Austin, TX 78703",
    "featureId": "0x8644b5a4e3d8f52b:0x2f1e3d4c5b6a7980",
    "rating": 4.9,
    "reviewCount": 412
  },
  "totalResults": 8,
  "hasMore": true,
  "hasNextPage": true,
  "nextPageToken": "CAESBkVnSUlDQQ",
  "reviews": [
    {
      "position": 1,
      "reviewId": "ChZDSUhNMG9nS0VJQ0FnSUNf",
      "rating": 5,
      "text": "Friendly staff and no wait.",
      "date": "2026-09-14T16:20:00.000Z",
      "timestamp": 1789402800,
      "relativeDate": "2 weeks ago",
      "language": "en",
      "author": "Jordan P.",
      "authorProfile": "https://www.google.com/maps/contrib/...",
      "authorPhoto": "https://lh3.googleusercontent.com/...",
      "authorId": "104512345678901234567",
      "authorReviewCount": 37,
      "isLocalGuide": true,
      "response": { "text": "Thank you, Jordan!", "relativeTime": "a week ago", "date": "2026-09-21T09:00:00.000Z", "language": "en" }
    }
  ],
  "source": "searlo.tech(google_reviews)",
  "credits": 1
}
```

- Each call returns the first page of reviews Google serves with the place, about eight, in any language: `hl` sets the language of `relativeDate`, and `language` is the one each review was written in.
- `hasMore` (also `hasNextPage`) is `true` when the place has more reviews than that page. Later pages cannot be fetched yet: `nextPageToken`, when present, is Google's token for them, and no parameter takes it. `place.reviewCount` is the lifetime count.
- `date` is the ISO time, `timestamp` the same in Unix seconds and `relativeDate` Google's own text.
- `response` is present only when the owner replied: `text`, `relativeTime`, `date` and `language`.
- A place with no reviews answers `200` with `reviews: []`, and is not charged. A place Google reports reviews for but sent without them answers `503` `REVIEWS_WITHHELD` (`retryable: true`), also not charged: retry the request.

### Place Details Parameters

`GET /search/place-details` returns everything Google's place panel holds for one place: every field a maps result has, plus the full attribute list and the menu. Identify the place as on reviews; a `placeId` costs no lookup.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `placeId` | string | One of four | A Google Place ID (`ChIJ…`) |
| `featureId` | string | One of four | The `0x…:0x…` place id |
| `cid` | string | One of four | The decimal place id from a Google Maps share link |
| `q` | string | One of four | A place name to look up (1–500 chars); the top match is used |
| `gl` | string | No | Country code, max 5 chars |
| `hl` | string | No | Language code, max 5 chars; sets the language of labels, descriptions and the menu |
| `menu` | boolean | No | `false` skips the menu lookup for a place that serves food (a faster answer when you do not need the menu) |

### Place Details Response

```json
{
  "searchParameters": { "type": "place-details", "placeId": "ChIJV8rJWPR1nkcRfAVGOrZIeVs", "featureId": "0x479e75f458c9ca57:0x5b7948b63a46057c", "hl": "en", "gl": "de" },
  "lite": true,
  "place": {
    "title": "Augustiner Klosterwirt",
    "placeId": "ChIJV8rJWPR1nkcRfAVGOrZIeVs",
    "cid": "0x479e75f458c9ca57:0x5b7948b63a46057c",
    "featureId": "/g/1ptx9q8dt",
    "category": "Bavarian restaurant",
    "rating": 4.5,
    "reviews": 14740,
    "address": "Augustinerstraße 1, 80331 München-Altstadt-Lehel, Germany",
    "website": "http://augustiner-klosterwirt.de/",
    "phone": "+498955054466",
    "openState": "Open · Closes 12 AM",
    "openNow": true,
    "businessStatus": "OPERATIONAL",
    "hours": [{ "day": "Friday", "hours": ["9:30 AM–12 AM"] }],
    "attributes": {
      "accessibility": { "has_wheelchair_accessible_entrance": true, "has_wheelchair_accessible_restroom": true }
    },
    "menu": {
      "url": "https://augustiner-klosterwirt.de/index.php/speisen-getraenke/speisekarte",
      "sections": [
        {
          "name": "Soups",
          "items": [{ "name": "Bavarian Liver Dumpling Soup", "description": "With fresh parsley", "price": "€7.50" }]
        }
      ],
      "photos": []
    },
    "menuStatus": "found"
  },
  "source": "searlo.tech(google_place)",
  "credits": 1
}
```

- `place` carries every field of a maps result (`openState`, `openNow`, `businessStatus`, `openingDate`, `bookingLinks`…) with the same meaning.
- `menu` is the menu Google holds for the place: `url` (the restaurant's own menu page) and `sections` of items with descriptions and prices as Google shows them, in the language `hl` names. Since October 2026 Google Maps shows signed-out visitors no menu, so for a place that serves food the menu is read from the place's Google Search panel instead. `photos` is empty for now. Any part Google does not have is empty, and `menu` is `null` when it has none.
- `menuStatus` says how the menu lookup went: `found`; `none` (Google holds no menu for the place); `pending` (still being read: ask again in a few minutes and it is there, which is how a large batch gets its menus, on a second pass); `unavailable` (the lookup failed: retry later). It is absent for a place that serves no food. A menu is kept for 7 days, so asking again costs no wait.
- `lite: true` means Google answered its reduced place record: since October 2026 that is what Google Maps shows every signed-out visitor, with accessibility as the only attribute group and no price level. A lite answer is never cached.
- A `placeId` that names no business (an address or plus-code id) or a `q` that matches nothing answers `400`, an id Google does not know `404`, both with `errorCode: "PLACE_NOT_FOUND"` and not charged.

## Code Examples

### cURL

```bash
# Map search
curl "https://api.searlo.tech/api/v1/search/maps?q=coffee+shops+in+austin&limit=10" \
  -H "x-api-key: sk_your_api_key"

# Looked up now instead of from the cache; gl=es places the search in Spain
curl "https://api.searlo.tech/api/v1/search/maps?q=farmacia&gl=es&hl=es&cache=false" \
  -H "x-api-key: sk_your_api_key"

# Businesses in a named place
curl "https://api.searlo.tech/api/v1/search/places?q=dentist&location=Austin,+Texas" \
  -H "x-api-key: sk_your_api_key"

# Everything about one place, by its Google Place ID
curl "https://api.searlo.tech/api/v1/search/place-details?placeId=ChIJd2ts_zTbmUcRqrBZ97_BxpA&hl=en" \
  -H "x-api-key: sk_your_api_key"

# Reviews for one place
curl "https://api.searlo.tech/api/v1/search/reviews?featureId=0x8644b5a4e3d8f52b:0x2f1e3d4c5b6a7980&limit=20" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY };

// 1. Find the businesses
const placesParams = new URLSearchParams({ q: "dentist", location: "Austin, Texas" });
const places = await (await fetch(`${API}/search/places?${placesParams}`, { headers })).json();

// 2. Read the reviews of the first one that carries a featureId
const place = places.places.find(p => p.featureId);
if (!place) throw new Error("No result carried a featureId");
const reviewParams = new URLSearchParams({ featureId: place.featureId, limit: "20" });
const reviews = await (await fetch(`${API}/search/reviews?${reviewParams}`, { headers })).json();

console.log(reviews.place.title, reviews.place.rating, reviews.place.reviewCount);
reviews.reviews.forEach(r => console.log(r.rating, r.relativeDate, r.text.slice(0, 80)));
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/maps",
    params={"q": "coffee shops in austin", "limit": 10, "gl": "us"},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
)
data = res.json()
if data.get("lite"):
    print("Reduced tier served: review counts, price and hours may be missing")
for p in data["places"]:
    if p["businessStatus"] in ("CLOSED_TEMPORARILY", "CLOSED_PERMANENTLY"):
        continue  # the place has closed
    print(p["position"], p["title"], p["rating"], p["reviews"], p["address"], p["openNow"])
```


---

<a id="scholar-patents"></a>

# Scholar & Patents

Google Scholar and Google Patents results. Both use the same `organic` array as web results, with the scholarly or patent fields alongside.

### Endpoints

```
GET /search/scholar
GET /search/patents
```

**Credits:** 1 per request

### Scholar Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes, unless `cluster` or `cites` is set | Search query (1–500 chars) |
| `limit` | number | No | Results (1–20, default: 10) |
| `page` | number | No | Page number (1–100, default: 1) |
| `hl` | string | No | Language code, max 5 chars |
| `yearFrom` | number | No | Earliest publication year (1500–2200) |
| `yearTo` | number | No | Latest publication year (1500–2200) |
| `sortByDate` | boolean | No | Sort by date instead of relevance |
| `includePatents` | boolean | No | Include patents in the results (has no effect when `includeCitations` is `false`) |
| `includeCitations` | boolean | No | Include Scholar's citation-only records (included by default); `false` leaves them out |
| `cluster` | string | No | A `clusterId` from a result (digits, max 32): every version of that work |
| `cites` | string | No | A `citesId` from a result (digits, max 32): the works that cite it |

### Scholar Response

```json
{
  "searchParameters": { "q": "transformer attention", "type": "scholar", "hl": "en", "num": 10, "page": 1, "yearFrom": 2020 },
  "page": 1,
  "totalResults": 128000,
  "hasNextPage": true,
  "organic": [
    {
      "position": 1,
      "title": "An example study of attention mechanisms",
      "link": "https://arxiv.org/abs/2001.00001",
      "snippet": "We study how attention...",
      "domain": "arxiv.org",
      "authors": ["A Author", "B Author"],
      "year": 2021,
      "publication": "arXiv preprint",
      "type": "PDF",
      "citedBy": 1520,
      "citedByLink": "https://scholar.google.com/scholar?cites=2960712678066186980",
      "pdfLink": "https://arxiv.org/pdf/2001.00001",
      "resultId": "u8Yx3kZ1abcJ",
      "citesId": "2960712678066186980",
      "clusterId": "2960712678066186980"
    }
  ],
  "source": "searlo.tech(google_scholar)",
  "credits": 1
}
```

- `citesId` and `clusterId` come back on every result. Pass one back as `cites` or `cluster` to walk the citation graph.
- `totalResults` is Scholar's own estimate for the whole query, not the page size.
- `type` is Scholar's tag for the result (`BOOK`, `PDF`, `HTML`, `CITATION`) or `null`.
- A query with no results returns `200` with an empty `organic` array.

### Patents Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results (1–100, default: 10). Google Patents serves pages of 10, 25, 50 or 100; other values round down to one of those, and anything under 10 fetches 10 |
| `page` | number | No | Page number (1–100, default: 1) |
| `sort` | string | No | `new` or `old`; relevance when omitted |
| `country` | string | No | Issuing authority, max 40 chars, e.g. `US` or `US,EP` |
| `language` | string | No | Publication language, max 20 chars (sent upper-cased) |
| `status` | string | No | `GRANT` or `APPLICATION` |
| `type` | string | No | `PATENT` or `DESIGN` |
| `inventor` | string | No | Inventor name, max 120 chars |
| `assignee` | string | No | Assignee name, max 120 chars |
| `before` | string | No | Upper date bound: `priority:YYYYMMDD`, `filing:YYYYMMDD` or `publication:YYYYMMDD` |
| `after` | string | No | Lower date bound, same format as `before` |

### Patents Response

```json
{
  "searchParameters": { "q": "solid state battery", "type": "patents", "num": 10, "page": 1 },
  "page": 1,
  "totalResults": 23000,
  "totalPages": 100,
  "hasNextPage": true,
  "organic": [
    {
      "position": 1,
      "title": "Solid-state battery with an example electrolyte",
      "link": "https://patents.google.com/patent/US11000000B2/en",
      "snippet": "A solid-state battery comprising...",
      "patentId": "patent/US11000000B2/en",
      "publicationNumber": "US11000000B2",
      "inventor": "Jane Inventor",
      "assignee": "Example Corp",
      "priorityDate": "2019-05-14",
      "filingDate": "2020-05-12",
      "grantDate": "2021-05-11",
      "publicationDate": "2021-05-11",
      "language": "en",
      "pdfLink": "https://patentimages.storage.googleapis.com/.../US11000000.pdf",
      "thumbnailUrl": "https://patentimages.storage.googleapis.com/...",
      "figures": [
        { "thumbnail": "https://patentimages.storage.googleapis.com/...", "full": "https://patentimages.storage.googleapis.com/..." }
      ]
    }
  ],
  "source": "searlo.tech(google_patents)",
  "credits": 1
}
```

- `totalPages` and `hasNextPage` come from Google Patents' own counts.
- `figures` lists the drawings, each as a `thumbnail` and a `full` image URL.
- A query with no results returns `200` with an empty `organic` array.

## Code Examples

### cURL

```bash
# Papers since 2020
curl "https://api.searlo.tech/api/v1/search/scholar?q=transformer+attention&yearFrom=2020&limit=10" \
  -H "x-api-key: sk_your_api_key"

# The works citing one paper (citesId from a previous result)
curl "https://api.searlo.tech/api/v1/search/scholar?cites=2960712678066186980" \
  -H "x-api-key: sk_your_api_key"

# Granted patents for an assignee, priority date from 2020
curl "https://api.searlo.tech/api/v1/search/patents?q=solid+state+battery&assignee=Example+Corp&status=GRANT&after=priority:20200101&limit=25" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY };

async function scholar(params) {
  const res = await fetch(`${API}/search/scholar?${new URLSearchParams(params)}`, { headers });
  return res.json();
}

// Find a paper, then walk to the works that cite it
const found = await scholar({ q: "transformer attention", yearFrom: "2020" });
const paper = found.organic[0];
console.log(paper.title, paper.year, paper.citedBy, paper.pdfLink);

const citing = await scholar({ cites: paper.citesId, limit: "20" });
citing.organic.forEach(r => console.log(r.year, r.title));
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/patents",
    params={
        "q": "solid state battery",
        "status": "GRANT",
        "country": "US",
        "after": "priority:20200101",
        "sort": "new",
        "limit": 25,
    },
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
)
data = res.json()
print(data["totalResults"], "results,", data["totalPages"], "pages")
for p in data["organic"]:
    print(p["publicationNumber"], p["assignee"], p["priorityDate"], p["title"])
```


---

<a id="search-simple"></a>

# Simple Search

Legacy web search that returns a simplified, flat result format. Use [Web Search](#search-web) for new integrations.

### Endpoint

```
GET /search/simple
```

**Credits:** 1 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results (1–10, default: 10) |

### Response

```json
{
  "success": true,
  "data": { "results": [ { "title": "...", "link": "...", "snippet": "..." } ] },
  "message": "Simple search completed successfully"
}
```

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/simple?q=hello&limit=5" \
  -H "x-api-key: sk_your_api_key"
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/simple",
    params={"q": "hello", "limit": 5},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
print(res.json()["data"])
```


---

<a id="search-suggestions"></a>

# Autocomplete (Suggestions)

Google Autocomplete: the suggestions Google offers while a query is being typed.

### Endpoint

```
GET /search/suggestions
```

**Credits:** 0.5 per request

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | Partial query (1–200 chars) |
| `limit` | number | No | Number of suggestions (1–10, default: 10) |
| `hl` | string | No | Language code (2–5 chars, default: `en`) |

### Response

```json
{
  "success": true,
  "data": {
    "query": "machine lear",
    "suggestions": ["machine learning", "machine learning python", "machine learning course"],
    "source": "searlo.tech(engine)",
    "timestamp": "2026-09-29T10:00:00.000Z"
  },
  "message": "Suggestions retrieved successfully"
}
```

The suggestions are in `data.suggestions`, as plain strings. An upstream failure returns `502` or `503` with `success: false`, and the credits are refunded.

### Trending (not available)

`GET /search/trending` is not available. The route exists but has no data source behind it: it answers `200` with an error object in `data` instead of trending queries. Do not build on it.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/suggestions?q=mach&limit=10&hl=en" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
async function getSuggestions(query, limit = 10) {
  const params = new URLSearchParams({ q: query, limit: String(limit) });
  const res = await fetch(
    `https://api.searlo.tech/api/v1/search/suggestions?${params}`,
    { headers: { "x-api-key": process.env.SEARLO_API_KEY } }
  );
  const { data } = await res.json();
  return data.suggestions;
}

const suggestions = await getSuggestions("mach");
console.log(suggestions); // ["machine learning", ...]
```

### Python

```python
import os
import requests

def get_suggestions(query, limit=10, hl="en"):
    r = requests.get(
        "https://api.searlo.tech/api/v1/search/suggestions",
        params={"q": query, "limit": limit, "hl": hl},
        headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
    )
    r.raise_for_status()
    return r.json()["data"]["suggestions"]

print(get_suggestions("mach"))
```


---

<a id="search-batch"></a>

# Batch Search

Run multiple web searches in a single request.

### Endpoint

```
POST /search/batch
```

**Credits:** 1 credit per distinct query that found something, up to 10 queries. A query that failed or found nothing costs nothing, and a query repeated in the batch (ignoring case) is charged once; each item's `result.credits` and `data.credits` say what was charged.

### Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `queries` | string[] | Yes | 1–10 query strings (each 1–500 chars) |

### Response

```json
{
  "success": true,
  "data": {
    "success": true,
    "results": [
      { "query": "react", "result": { "searchParameters": { "q": "react" }, "organic": [ /* ... */ ], "credits": 1 }, "source": "searlo.tech(engine)", "timestamp": "2026-10-05T09:00:00.000Z" },
      { "query": "vue", "result": { "searchParameters": { "q": "vue" }, "organic": [ /* ... */ ], "credits": 1 }, "source": "searlo.tech(engine)", "timestamp": "2026-10-05T09:00:00.000Z" }
    ],
    "totalQueries": 2,
    "credits": 2
  },
  "message": "Batch search completed successfully"
}
```

## Code Examples

### cURL

```bash
curl -X POST "https://api.searlo.tech/api/v1/search/batch" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "queries": ["react", "vue", "svelte"] }'
```

### JavaScript

```javascript
const res = await fetch("https://api.searlo.tech/api/v1/search/batch", {
  method: "POST",
  headers: {
    "x-api-key": process.env.SEARLO_API_KEY,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ queries: ["react", "vue", "svelte"] }),
});
console.log((await res.json()).data);
```

### Python

```python
import os
import requests

res = requests.post(
    "https://api.searlo.tech/api/v1/search/batch",
    json={"queries": ["react", "vue", "svelte"]},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
)
print(res.json()["data"])
```


---

<a id="full-serp"></a>

# Full SERP

Google's whole results page as JSON: organic results, videos, ads, People Also Ask, related searches, the knowledge graph and the AI Overview. Each page is fetched live from Google and parsed, in a few seconds, AI Overview included: the overview is read in full from the page as Google served it. An overview Google loads after the page is read from the rendered page instead, which adds several seconds. [Web Search](#search-web) returns organic results only, for 1 credit, in about a second.

### Endpoints

```
GET  /serp
POST /serp/batch
GET  /serp/features
GET  /search/news/serp
```

> `GET /search/serp` is an alias of `GET /serp`: same parameters, same price.

**Credits:** 3 per page delivered (`pages` asks for up to 10; a page Google does not have, or one that repeats the page before it, is not billed), +1 with `rawHtml=true`. A SERP with no results is not charged. An AI Overview read in full from the page as served is included at no extra charge. **From 2026-11-01**, a request whose AI Overview had to be completed by rendering the page costs 5 more (8 for a one-page request), and the response says `aioverview.rendered: true`; with `aioverview=false` it never does. Until then it stays 3. A cached result is billed like a live one, rendered or not. `credits` in the body and the `X-Credits-Deducted` header say what was charged.

Search operators (`site:`, `intitle:`, `inurl:`, `filetype:`, `before:`/`after:`), e-mail lookups and long OR chains are not served: they answer `422` `QUERY_NOT_SERVED` with `retryable: false`, not charged.

> Want Google's page itself rather than JSON, or the page an iPhone gets? See [SERP HTML](#serp-html).

### 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`) |
| `location` | string | No | A country, by name or two-letter code (max 120 chars), e.g. `Germany` or `DE`. Applied as `gl`, and takes precedence over it. A city or state returns `400` `CITY_LOCATION_UNAVAILABLE` (see [Location](#full-serp)); an unknown name returns `400` `UNKNOWN_LOCATION` with `suggestions`. Neither is charged. Cannot be combined with `uule` |
| `uule` | string | No | A Google `uule` token (max 300 chars). A country's token is applied as that country's `gl`; any other token (a city, a state, coordinates) returns `400` `CITY_LOCATION_UNAVAILABLE` |
| `device` | string | No | `desktop` (default). `mobile` and `tablet` are accepted but return the desktop layout; for the page an iPhone gets, use [SERP HTML](#serp-html) with `device=ios` |
| `page` | number | No | First results page to fetch (1–100, default: 1) |
| `pages` | number | No | Consecutive pages to fetch (1–10, default: 1). Each page delivered is billed; `pagesFetched` says how many came back |
| `num` | number | No | Results per page (1–10, default: 10). Google serves at most 10 per page: ask for more with `pages` |
| `tbs` | string | No | Google time or sort filter, max 40 chars. Only `qdr:h`, `qdr:d`, `qdr:w`, `qdr:m` and `qdr:y` (optionally with a number, e.g. `qdr:d3`), `li:1` and `sbd:1` are applied; other values are ignored |
| `safe` | string or boolean | No | `active` (or `true`) turns SafeSearch on; `off` (or `false`, the default) leaves it off. Cached apart from SafeSearch-off results |
| `aioverview` | boolean | No | Include the AI Overview (default: **true**). `false` drops the `aioverview` key and skips reading it in full, which makes the call faster |
| `googleGoto` | boolean | No | Resolve Google `/goto` redirect links in the AI Overview sources (default: true) |
| `googleGotoOrganic` | boolean | No | Also resolve `/goto` links on organic results (default: false) |
| `cache` | boolean | No | `false` skips the cache and fetches live (default: true) |
| `rawHtml` | boolean | No | `true` adds `rawHtml`: the page's HTML as Google served it, alongside the JSON (+1 credit). A result with the page is cached separately from one without it. For the rendered page, use [SERP HTML](#serp-html) with `js=true` |

### Response

```json
{
  "success": true,
  "searchParameters": { "q": "best running shoes", "gl": "us", "hl": "en", "page": 1, "pages": 1, "num": 10, "device": "desktop", "type": "serp" },
  "organic": [
    {
      "position": 1,
      "title": "The Best Running Shoes of 2026",
      "url": "https://www.example.com/best-running-shoes",
      "redirectUrl": null,
      "resolved": true,
      "displayedUrl": "https://www.example.com › reviews",
      "domain": "example.com",
      "snippet": "We ran more than 500 miles in 40 pairs...",
      "date": "Sep 12, 2026",
      "sitelinks": [{ "title": "Road shoes", "url": "https://www.example.com/road" }],
      "page": 1
    }
  ],
  "videos": [
    { "position": 1, "title": "Best Running Shoes 2026", "url": "https://www.youtube.com/watch?v=abc123", "redirectUrl": null, "resolved": true, "source": "YouTube", "channel": "Example Running" }
  ],
  "ads": [
    { "position": 1, "block": "top", "title": "Running Shoes Sale", "url": "https://shop.example.com/", "displayedUrl": "shop.example.com", "description": "Free shipping on all orders." }
  ],
  "peopleAlsoAsk": [{ "question": "What running shoes do podiatrists recommend?" }],
  "relatedSearches": [{ "query": "best running shoes for women" }],
  "knowledgeGraph": null,
  "aioverview": {
    "text": "The best running shoe depends on your gait and distance...",
    "markdown": "The best running shoe depends on your gait and distance...",
    "sources": [
      { "position": 1, "label": "Example Running", "title": "Best running shoes, tested", "supportingCount": 0, "url": "https://www.example.com/tested", "redirectUrl": null, "resolved": true, "domain": "example.com" }
    ],
    "complete": true
  },
  "aiModeAvailable": true,
  "pagesFetched": 1,
  "gotoResolved": 4,
  "fetchedAt": "2026-09-29T10:00:00.000Z",
  "source": "searlo.tech(google_serp)",
  "cached": false
}
```

- Organic results carry the destination in `url` (Web Search uses `link`). When Google wrapped a result in a `/goto` redirect, `redirectUrl` holds that link and `resolved` says whether `url` is the final destination. `gotoResolved` counts the links resolved for you.
- `peopleAlsoAsk` lists the questions only (`question`), not their answers. `relatedSearches` items carry `query`. `knowledgeGraph` (`title`, `type`, `description`, `attributes`, `website`) is `null` when the page has none.
- `aioverview` is `null` when Google showed no AI Overview; its `sources` are the pages it cited. `complete` is `true` when the overview was read in full, from the page as served or from the rendered page, and `false` when only part of it could be read. `pending: true` with an empty `text` marks an overview Google loads after the page; `GET /serp` then reads it from the rendered page. `rendered: true` marks an overview completed that way, which from 2026-11-01 makes the request cost 5 more (see Credits). Its `sources` come back resolved: `url` is the cited page and `redirectUrl` Google's `/goto` link. `aiModeAvailable` says whether the page offered an AI Mode tab.
- With `pages` above 1, organic results from the later pages are appended with continuing `position` values and their own `page`. Ads, videos, People Also Ask, related searches, the knowledge graph and the AI Overview come from the first page only.
- `position` counts from 1 within the response: with `page=3` the first organic result is `position` 1 on `page` 3.
- `pagesFetched` can be lower than `pages` when a later page did not render. Billing follows the pages you requested.
- Responses set `X-Cache` (`HIT`/`MISS`). Identical requests in flight at the same moment share one fetch, and each of them is billed.

### Location

Every Google endpoint applies the country and the language: `gl` sets the country and `hl` the language. `location` takes a country too, by name or two-letter code, and becomes its `gl`.

**City- and state-level location is not available yet.** In our tests (September 2026) Google no longer localizes a results page by `uule`, so a city, a state or a non-country `uule` is refused, and not charged, rather than answered with results for somewhere else:

```json
{
  "success": false,
  "code": "CITY_LOCATION_UNAVAILABLE",
  "retryable": false,
  "message": "City- and state-level location is not available yet. Country (gl) and language (hl) are applied: pass the country as gl, or as location (for example \"Germany\").",
  "location": "Austin,Texas,United States"
}
```

The same rule applies to `POST /serp/batch`, `GET /serp/features`, `GET /search/news/serp`, [SERP HTML](#serp-html), [Rank Checks](#rankings) and the `serp`, `news/serp` and `rankings/check` [async tasks](#async-tasks).

### Errors

| Status | `code` | Meaning |
|--------|----------|---------|
| `400` | `VALIDATION_ERROR` | A parameter is missing or out of range (`errors` lists which) |
| `400` | `CITY_LOCATION_UNAVAILABLE` | `location` or `uule` names a city or a state (see [Location](#full-serp)) |
| `400` | `UNKNOWN_LOCATION` | `location` is not a country we recognise; `suggestions` lists close matches |
| `429` | `AT_CAPACITY` | The endpoint is at capacity. Retry after `Retry-After`; a retry before then is refused |
| `403` | `FEATURE_NOT_AVAILABLE` | The endpoint is switched off on this deployment (`retryable: false`) |
| `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 (`retryable: false`) |
| `422` | `COUNTRY_UNAVAILABLE` | This `gl` cannot be served right now |
| `503` | `UPSTREAM_UNAVAILABLE` | No usable results page was available within the call (`retryable: true`). Retry after the short `Retry-After` |

Failed calls are refunded. The account [rate limit](#rate-limiting) `429` (`code` `4201` or `4202`) is separate from `AT_CAPACITY`.

### Batch Endpoint

```
POST /serp/batch
```

**Credits:** 3 per distinct keyword per page delivered. The batch reserves 3 × keywords × `pages` when it is accepted (plus 5 per keyword with `aioverview: true` from 2026-11-01) and is charged what it delivered: a keyword that failed or found nothing costs nothing. Duplicate keywords (ignoring case) are billed once. 20 keywords at 1 page cost at most 60 credits without rendered overviews. From 2026-11-01, with `aioverview: true`, a keyword whose AI Overview had to be completed by rendering the page costs 5 more (`aioverview.rendered: true` on its row). Each row's `credits` and `credits.used` say what was charged.

Up to 20 keywords run synchronously in one request, with one set of settings for all of them; `meta.max_sync_batch` reports the cap in force. For larger runs, queue `serp` tasks with [Async Tasks](#async-tasks).

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `keywords` | string[] | Yes | 1–20 keywords, each 1–400 chars |
| `gl` | string | No | Country, 2 letters (default: `us`) |
| `hl` | string | No | Language, 2–5 chars (default: `en`) |
| `location` | string | No | A country, as on `GET /serp`. A city or state refuses the whole batch with `400` `CITY_LOCATION_UNAVAILABLE` before any keyword runs (not charged). Cannot be combined with `uule` |
| `uule` | string | No | As on `GET /serp` |
| `device` | string | No | `desktop` (default). `mobile` and `tablet` are accepted and return the desktop layout, as on `GET /serp` |
| `pages` | number | No | 1–10 (default: 1) |
| `num` | number | No | 1–100 (default: 10) |
| `tbs` | string | No | As on `GET /serp` |
| `safe` | string or boolean | No | As on `GET /serp`: `active` or `off`. Every keyword in the batch is searched with it |
| `aioverview` | boolean | No | Include the AI Overview (default: **false** in a batch) |
| `googleGoto` | boolean | No | Default: true |
| `cache` | boolean | No | Default: true |

```json
{
  "schema": "serp-batch.v1",
  "results": [
    {
      "keyword": "running shoes",
      "success": true,
      "searchParameters": { "q": "running shoes", "gl": "us", "hl": "en", "page": 1, "pages": 1, "num": 10, "device": "desktop", "type": "serp" },
      "organic": [{ "position": 1, "title": "Running Shoes", "url": "https://www.example.com/running" }],
      "videos": [],
      "ads": [],
      "peopleAlsoAsk": [{ "question": "How often should you replace running shoes?" }],
      "relatedSearches": [{ "query": "running shoes sale" }],
      "knowledgeGraph": null,
      "aiModeAvailable": true,
      "pagesFetched": 1,
      "gotoResolved": 0,
      "fetchedAt": "2026-09-29T10:00:00.000Z",
      "source": "searlo.tech(google_serp)",
      "cached": false
    },
    {
      "keyword": "trail running shoes",
      "success": false,
      "status": 429,
      "code": "AT_CAPACITY",
      "retryable": true,
      "message": "Google SERP is at capacity. Retry in 60s — immediate retries will be refused."
    }
  ],
  "credits": { "used": 6 },
  "meta": { "requested": 2, "distinct": 2, "succeeded": 1, "failed": 1, "pages": 1, "gl": "us", "hl": "en", "device": "desktop", "max_sync_batch": 20 }
}
```

**A keyword that fails inside a 200 batch is not billed** (since 2026-10-05). The batch answers `200` whenever it ran; read `meta.failed` and each failed row's `retryable` before you resubmit.

### Features Endpoint

```
GET /serp/features
```

**Credits:** 3 per request (one results page). It only checks whether an AI Overview is present, so it never renders and never costs more.

Which features a query's results page shows, without the page itself. Parameters: `q` (required, 1–400 chars), `gl`, `hl`, `location` (a country) or `uule`, `device`, `safe` and `cache`, as on `GET /serp`.

```json
{
  "schema": "serp-features.v1",
  "query": "best running shoes",
  "features": ["organic", "ads", "ai_overview", "people_also_ask", "related_searches", "video", "sitelinks"],
  "counts": { "organic": 10, "ads": 3, "shopping": 0, "videos": 3, "people_also_ask": 4, "related_searches": 8, "local_pack": 0, "top_stories": 0, "image_pack": 0 },
  "organic_count": 10,
  "credits": { "used": 3 },
  "meta": { "gl": "us", "hl": "en", "device": "desktop", "cached": false, "fetchedAt": "2026-09-29T10:00:00.000Z" }
}
```

Feature names come from this list: `organic`, `ads`, `shopping`, `ai_overview`, `ai_mode`, `featured_snippet`, `people_also_ask`, `related_searches`, `knowledge_graph`, `video`, `local_pack`, `sitelinks`, `top_stories`, `image_pack`, `twitter`, `faq`. The results-page parse does not currently return shopping, local pack, top stories, image pack, Twitter, FAQ or featured-snippet blocks, so those names do not appear today and their `counts` stay 0.

### News Results Page

```
GET /search/news/serp
```

**Credits:** 2 per page requested.

Google's News tab, fetched live and parsed: cards with title, source, date, snippet and thumbnail, about a second per page. Parameters: `q` (required, 1–400 chars), `gl`, `hl`, `location` (a country) or `uule`, `page` (1–100), `pages` (1–10), `tbs` (e.g. `qdr:d` for the past day), `googleGoto` (default: true, resolves the cards' `/goto` links to the articles) and `cache`, as on `GET /serp`.

Cards come in Google's order, with continuing `position` values and the `page` each came from. For a developing story Google shows several articles as a group, and each is its own card, so a page can hold more than 10. `snippet` is empty for grouped articles, which Google shows without one. `hasNextPage` says whether Google offered a further page.

```json
{
  "success": true,
  "searchParameters": { "q": "electric vehicles", "gl": "us", "hl": "en", "page": 1, "pages": 1, "type": "news", "mode": "serp" },
  "news": [
    { "position": 1, "title": "EV sales rise again", "url": "https://news.example.com/ev-sales", "redirectUrl": null, "resolved": true, "source": "Example News", "snippet": "Sales of electric vehicles...", "date": "3 hours ago", "thumbnail": "https://news.example.com/thumb.jpg", "page": 1 }
  ],
  "hasNextPage": true,
  "pagesFetched": 1,
  "gotoResolved": 18,
  "fetchedAt": "2026-09-29T10:00:00.000Z",
  "source": "searlo.tech(google_news_serp)",
  "cached": false
}
```

## Code Examples

### cURL

```bash
# One results page; the AI Overview is included by default
curl "https://api.searlo.tech/api/v1/serp?q=best+running+shoes&gl=us&hl=en" \
  -H "x-api-key: sk_your_api_key"

# The same with SafeSearch on
curl "https://api.searlo.tech/api/v1/serp?q=best+running+shoes&safe=active" \
  -H "x-api-key: sk_your_api_key"

# Two pages of Google Germany, in German (6 credits)
curl "https://api.searlo.tech/api/v1/serp?q=kaffeemaschine&location=Germany&hl=de&pages=2" \
  -H "x-api-key: sk_your_api_key"

# Up to 20 keywords in one request
curl -X POST "https://api.searlo.tech/api/v1/serp/batch" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "keywords": ["running shoes", "trail running shoes"], "gl": "us", "aioverview": true }'

# Only the feature list
curl "https://api.searlo.tech/api/v1/serp/features?q=best+running+shoes" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY };

// One full results page
const params = new URLSearchParams({ q: "best running shoes", gl: "us", hl: "en" });
const serp = await (await fetch(`${API}/serp?${params}`, { headers })).json();

serp.organic.forEach(r => console.log(r.position, r.url));
console.log(serp.peopleAlsoAsk.map(p => p.question));
if (serp.aioverview) console.log(serp.aioverview.sources.map(s => s.domain));

// A batch: one row per distinct keyword, failed rows included (not billed)
const res = await fetch(`${API}/serp/batch`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({ keywords: ["running shoes", "trail running shoes"], gl: "us" }),
});
const batch = await res.json();
const retry = batch.results.filter(r => !r.success && r.retryable).map(r => r.keyword);
console.log(`${batch.credits.used} credits, ${batch.meta.failed} failed`, retry);
```

### Python

```python
import os
import requests

API = "https://api.searlo.tech/api/v1"
HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}

serp = requests.get(
    f"{API}/serp",
    params={"q": "best running shoes", "gl": "us", "hl": "en", "pages": 1},
    headers=HEADERS,
    timeout=120,
).json()

for r in serp["organic"]:
    print(r["position"], r["url"])
if serp.get("aioverview"):
    print([s["domain"] for s in serp["aioverview"]["sources"]])

batch = requests.post(
    f"{API}/serp/batch",
    json={"keywords": ["running shoes", "trail running shoes"], "gl": "us"},
    headers=HEADERS,
    timeout=300,
).json()
print(batch["meta"]["succeeded"], "ok,", batch["meta"]["failed"], "failed,", batch["credits"]["used"], "credits")
```


---

<a id="serp-html"></a>

# 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 `/goto` redirects, 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

```
GET /serp/html
```

**Credits:** 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](#full-serp)). 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](#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`)

```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.present` tells 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 (`showAll` `absent` or `complete`).
- **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 iOS `aio.expanded` means 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

- `429` `BUSY`: the endpoint is at capacity (with `js=true`, rendering capacity). Wait `Retry-After` seconds (1–10) and retry.
- `503` with `retryable: true` (`UPSTREAM_UNAVAILABLE`): no usable page was available within the call. Retry with backoff, for example after 2, 5 and 10 seconds.
- `400` and `422`: 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](#rate-limiting) | 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`:

```json
{
  "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=raw` when you store or parse the page: it arrives as `text/html` with no JSON escaping, and the metadata is in the `X-Searlo-*` headers.
- Send `Accept-Encoding: gzip`. A raw page is about 1 MB of HTML and compresses to a fraction of that.
- Honour `Retry-After` on every `429`, and retry `503`s marked `retryable: true` with backoff.
- Use the timeouts above: 30 s raw, 90 s rendered.
- Track spend from `X-Credits-Used` (`credits.used` in JSON) and `X-Credits-Remaining`.
- Read [`GET /search/capabilities`](#account) 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](#rate-limiting)). Ask us for custom limits and reserved capacity before a large ramp.

## Code Examples

### cURL

```bash
# 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 itself
curl "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, rendered
curl "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.html
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY };

// Rendered, AI Overview opened out
const 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));
```

### Python

```python
import os
import 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 itself
r = 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)
```


---

<a id="rankings"></a>

# Rank Checks

Where a domain ranks for a keyword, read from a live Google results page: the position, the page that ranks and the domains above it. Every check is stored, so the history builds up from your own checks.

### Endpoints

```
POST /rankings/check
POST /rankings/batch
GET  /rankings/history
```

**Credits:** 3 per 10 results of `depth`, because each 10 results is one results page read. The default `depth` of 100 costs 30 credits; `depth` 10 costs 3. A batch costs that for each distinct keyword. History is free.

### Check Parameters

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `keyword` | string | Yes | 1–400 chars |
| `domain` | string | Yes | 3–253 chars. Reduced to its registrable domain (`blog.example.com` becomes `example.com`): any subdomain counts as a match, and `url` says which page ranked |
| `depth` | number | No | How many results to search (10–100, default: 100), read in pages of 10. `depth` 25 reads 3 pages and costs 9 |
| `gl` | string | No | Country, 2 letters (default: `us`) |
| `hl` | string | No | Language, 2–5 chars (default: `en`) |
| `device` | string | No | `desktop` or `mobile` (default: `desktop`). Positions are read from Google's desktop results page for both today; `device` is recorded with the check as asked |
| `location` | string | No | A country, by name, two-letter code or `uule` (max 300 chars). Applied as `gl` and echoed in the result as `location`. A city or state returns `400` `CITY_LOCATION_UNAVAILABLE` before any credit is taken, for a batch too (see [Location](#full-serp)) |
| `cache` | boolean | No | Reuse a matching cached results page (default: true) |

### Check Response

```json
{
  "schema": "ranking-check.v1",
  "result": {
    "keyword": "project management software",
    "domain": "example.com",
    "position": 4,
    "url": "https://www.example.com/features",
    "title": "Project management features | Example",
    "found": true,
    "search_depth": 30,
    "organic_count": 30,
    "pages_fetched": 3,
    "gl": "US",
    "hl": "en",
    "device": "desktop",
    "serp_features": ["organic", "ads", "people_also_ask", "related_searches", "sitelinks"],
    "competitors_above": ["asana.com", "monday.com", "clickup.com"],
    "checked_at": "2026-09-29T10:00:00.000Z",
    "cached": false
  },
  "credits": { "used": 9 }
}
```

- `found: false` with `position: null` means the domain was not in the top `search_depth` results. `search_depth` is what was asked for, so a short results page does not change what `found: false` means.
- `competitors_above` lists up to 20 domains ranking above yours, top first. When the domain was not found, it lists the domains on the page.
- `serp_features` uses the [SERP features](#full-serp) vocabulary. A rank check skips the AI Overview, so `ai_overview` never appears here.
- A failed results-page read returns `429` (`AT_CAPACITY`) or `503` (`UPSTREAM_UNAVAILABLE`), as on `/serp`, and is refunded. An unparseable `domain` is `400` `INVALID_DOMAIN`.

### Batch Endpoint

```
POST /rankings/batch
```

One domain, up to 20 keywords: `keywords` (1–20, each 1–400 chars) and `domain`, plus the same `depth`, `gl`, `hl`, `device`, `location` and `cache` fields as a check. Each keyword is a separate check and a separate history entry. `meta.max_sync_batch` reports the cap in force.

```json
{
  "schema": "ranking-batch.v1",
  "results": [
    { "success": true, "keyword": "project management software", "domain": "example.com", "position": 4, "url": "https://www.example.com/features", "title": "Project management features | Example", "found": true, "search_depth": 30, "organic_count": 30, "pages_fetched": 3, "gl": "US", "hl": "en", "device": "desktop", "serp_features": ["organic", "ads"], "competitors_above": ["asana.com"], "checked_at": "2026-09-29T10:00:00.000Z", "cached": false },
    { "keyword": "kanban board software", "success": false, "code": "UPSTREAM_UNAVAILABLE", "retryable": true, "message": "Google SERP is temporarily unavailable. Please try again shortly." }
  ],
  "credits": { "used": 18 },
  "meta": { "domain": "example.com", "requested": 2, "distinct": 2, "measured": 1, "ranking": 1, "failed": 1, "max_sync_batch": 20 }
}
```

A keyword that fails inside a `200` batch is still billed.

### History Endpoint

```
GET /rankings/history
```

**Credits:** Free (0).

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `domain` | string | Yes | The domain you checked |
| `keyword` | string | No | One keyword. Set it to get a single series and `change` |
| `gl` | string | No | 2-letter country |
| `device` | string | No | `desktop` or `mobile` |
| `from` | string | No | ISO date: earliest `checked_at` |
| `to` | string | No | ISO date: latest `checked_at` |
| `limit` | number | No | 1–1000 (default: 100), newest first |

```json
{
  "schema": "ranking-history.v1",
  "series": [
    { "keyword": "project management software", "domain": "example.com", "gl": "US", "hl": "en", "device": "desktop", "position": 4, "url": "https://www.example.com/features", "search_depth": 30, "serp_features": ["organic", "ads"], "checked_at": "2026-09-29T10:00:00.000Z" },
    { "keyword": "project management software", "domain": "example.com", "gl": "US", "hl": "en", "device": "desktop", "position": 6, "url": "https://www.example.com/features", "search_depth": 30, "serp_features": ["organic", "ads"], "checked_at": "2026-09-28T10:00:00.000Z" }
  ],
  "change": { "delta": 2, "from": 6, "to": 4 },
  "credits": { "used": 0 },
  "meta": { "domain": "example.com", "returned": 2, "limit": 100 }
}
```

`change` appears when `keyword` is set and the series has at least two checks. A positive `delta` means the domain moved up. When one of the two checks found nothing, `delta` is `null` and `event` is `entered` or `dropped_out`. History holds only the checks you ran (batch rows and async `rankings/check` tasks included). It is not a back-filled index, and it is kept for a configurable retention period.

## Code Examples

### cURL

```bash
# Top 30 results, 9 credits
curl -X POST "https://api.searlo.tech/api/v1/rankings/check" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "keyword": "project management software", "domain": "example.com", "depth": 30, "gl": "us" }'

# The stored series for that keyword (free)
curl "https://api.searlo.tech/api/v1/rankings/history?domain=example.com&keyword=project+management+software" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY, "Content-Type": "application/json" };

const res = await fetch(`${API}/rankings/batch`, {
  method: "POST",
  headers,
  body: JSON.stringify({
    domain: "example.com",
    keywords: ["project management software", "kanban board software", "gantt chart tool"],
    depth: 20, // 2 pages per keyword = 6 credits per keyword
    gl: "us",
  }),
});
const { results, meta, credits } = await res.json();

for (const r of results) {
  if (!r.success) console.log(r.keyword, "not measured:", r.message);
  else console.log(r.keyword, r.found ? `#${r.position} ${r.url}` : `not in top ${r.search_depth}`);
}
console.log(`${meta.ranking}/${meta.distinct} ranking, ${credits.used} credits`);
```

### Python

```python
import os
import requests

API = "https://api.searlo.tech/api/v1"
HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}

check = requests.post(
    f"{API}/rankings/check",
    json={"keyword": "project management software", "domain": "example.com", "depth": 10},
    headers=HEADERS,
    timeout=120,
).json()["result"]
print(check["found"], check["position"], check["url"], check["competitors_above"][:3])

history = requests.get(
    f"{API}/rankings/history",
    params={"domain": "example.com", "keyword": "project management software", "limit": 30},
    headers=HEADERS,
).json()
print(history["change"])
for row in history["series"]:
    print(row["checked_at"], row["position"])
```


---

<a id="ai-answers"></a>

# AI Answers

Answers from AI assistants and the sources they cited: ChatGPT, Microsoft Copilot and Google Gemini. Perplexity answers are paused: `GET /search/ai/perplexity` answers `403` `FEATURE_NOT_AVAILABLE` and is not charged. Each answer is generated live for your prompt, with no account history shaping it: the answer the engine's own users see.

Searlo is independent and not affiliated with OpenAI, Microsoft, Google or Perplexity.

### Endpoint

```
GET /search/ai/{engine}
```

> `engine` is one of `chatgpt`, `copilot` or `gemini`; `GET /search/ai?engine=chatgpt` is the same route, and `GET /search/ai` without an engine asks ChatGPT. `perplexity` (paused), `aimode` (Google AI Mode) and `grok` are switched off in production: they answer `403` `FEATURE_NOT_AVAILABLE` and are not charged.

**Credits:** per answer: Gemini 4, ChatGPT 8, Copilot 8. A cached answer is billed at the full rate.

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `q` | string | Yes | The prompt (1–500 chars) |
| `gl` | string | No | Country, max 5 chars (e.g. `us`). Best-effort country for the answer |
| `hl` | string | No | Language, max 5 chars (e.g. `en`). The language to ask in. On Copilot, `gl` and `hl` become the Bing market code (e.g. `en-US`) |
| `cache` | boolean | No | Default `true`: a repeat of the same prompt within about an hour returns the cached answer. `false` forces a fresh run, billed as usual |
| `mode` | string | No | Max 20 chars. Echoed in `searchParameters.mode` and part of the cache key; it does not change the answer |
| `model` | string | No | Max 40 chars. Accepted but not used: answers come from each engine's default model |
| `html` | boolean | No | `true` also returns `html`, an HTML snapshot of the answer page, and `pageUrl` (default: `false`). Same credits. A snapshot is of its own run: it is never served from or written to the cache |

Structured output (`schema`) is not available on this endpoint yet. Do not send it: a JSON value is rejected with `400`, and a bracketed `schema[field]=type` is dropped.

These engines take no location beyond `gl`: `location` and `uule` are ignored.

### Response

```json
{
  "success": true,
  "searchParameters": { "q": "best crm for startups", "type": "ai", "engine": "chatgpt", "mode": "ui", "gl": "us", "hl": "en" },
  "answer": "For most early-stage startups, a free tier is enough to begin with...",
  "answerMarkdown": "For most early-stage startups, a **free tier** is enough to begin with...",
  "citations": [
    { "index": 1, "url": "https://www.example.com/best-crm-startups", "title": "The best CRMs for startups", "domain": "example.com" },
    { "index": 2, "url": "https://blog.example.org/crm-guide", "title": "Choosing a CRM", "domain": "blog.example.org" }
  ],
  "sources": ["example.com", "blog.example.org"],
  "citationPills": [],
  "related": ["Which CRM is free for small teams?"],
  "model": "",
  "threadUrl": null,
  "complete": true,
  "via": "dom",
  "fetchedAt": "2026-09-29T10:00:00.000Z",
  "source": "searlo.tech(chatgpt)",
  "cached": false
}
```

| Field | Description |
|-------|-------------|
| `answer` | The answer as plain text |
| `answerMarkdown` | The answer as markdown when the engine provides it, otherwise the same text |
| `citations` | The sources cited, in order: `index`, `url`, `title`, `domain`. Copilot citations also carry `snippet` and `date` |
| `sources` | The unique cited domains, for share-of-voice counts |
| `citationPills` | Copilot's source pills (`position`, `label`, `title`, `url`, `domain`); empty on the other engines |
| `related` | Follow-up questions the engine suggested, when it shows any |
| `model` | The model the engine reported: `gemini-flash (anonymous)` on Gemini, empty on ChatGPT and Copilot |
| `threadUrl` | A link to the engine's thread when it exposes one; `null` on ChatGPT and Copilot |
| `complete` | `false` when the answer was still streaming at the deadline, or (Copilot) when its text stops mid-sentence, so it may be cut short. Incomplete answers are never cached |
| `cached` | `true` when the answer came from cache (also `X-Cache: HIT`) |
| `html` | With `html=true`: an HTML snapshot of the answer page (the rendered DOM, not a screenshot). On Copilot it is the answer frame's document. Stylesheets and scripts stay linked, not inlined. Hundreds of KB to about 2 MB (Gemini) |
| `pageUrl` | With `html=true`: the page's URL at that moment, for resolving its relative links |

Responses also set `X-AI-Engine`.

### Latency, Caching and Variance

- A live answer typically takes 10–30 seconds. Set a generous client timeout.
- Answers are non-deterministic: the same prompt can return different text and different citations from one run to the next. That variance is part of what you are measuring.
- A repeat of the same prompt (ignoring case and extra spaces) on the same engine, with the same `gl`, `hl` and `mode`, within about an hour is served from cache and billed at the full rate. Pass `cache=false` to force a new run.
- Identical requests in flight at the same moment, `cache=false` ones included, are merged into one run: every call is billed and all of them get the same answer. Parallel identical calls are not independent samples, so space your samples out.
- ChatGPT and Gemini show sources only when they searched the web, so `citations` can be empty on a valid answer.
- `html=true` always runs the engine (a snapshot is never cached), and a snapshot request is merged only with identical snapshot requests in flight.

### Errors

| Status | `code` | Meaning |
|--------|----------|---------|
| `400` | `VALIDATION_ERROR` | A missing or invalid parameter, or an unknown engine |
| `429` | `AT_CAPACITY` | The engine is at capacity. Retry after `Retry-After`; a retry before then is refused |
| `403` | `FEATURE_NOT_AVAILABLE` | The engine is switched off on this deployment (`retryable: false`) |
| `503` | `UPSTREAM_UNAVAILABLE` | The engine returned something unusable (`retryable: true`, short `Retry-After`). Try again shortly |

Failed calls are refunded.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/ai/chatgpt?q=best+crm+for+startups&gl=us&hl=en" \
  -H "x-api-key: sk_your_api_key" \
  --max-time 180

# Force a fresh run instead of the cached answer (billed)
curl "https://api.searlo.tech/api/v1/search/ai/chatgpt?q=best+crm+for+startups&cache=false" \
  -H "x-api-key: sk_your_api_key" \
  --max-time 180
```

### JavaScript

```javascript
async function askAi(engine, prompt, { gl = "us", hl = "en", cache = true } = {}) {
  const params = new URLSearchParams({ q: prompt, gl, hl, cache: String(cache) });
  const res = await fetch(
    `https://api.searlo.tech/api/v1/search/ai/${engine}?${params}`,
    {
      headers: { "x-api-key": process.env.SEARLO_API_KEY },
      signal: AbortSignal.timeout(180_000), // live answers take 10-30 s, sometimes longer
    }
  );
  if (res.status === 429) {
    throw new Error(`${engine} at capacity, retry after ${res.headers.get("retry-after")}s`);
  }
  return res.json();
}

const answer = await askAi("copilot", "best crm for startups");
console.log(answer.answer);
console.log(answer.sources);       // unique cited domains
console.log(answer.citationPills); // Copilot only
```

### Python

```python
import os
import requests

def ask_ai(engine, prompt, gl="us", hl="en", cache=True):
    r = requests.get(
        f"https://api.searlo.tech/api/v1/search/ai/{engine}",
        params={"q": prompt, "gl": gl, "hl": hl, "cache": str(cache).lower()},
        headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
        timeout=180,
    )
    r.raise_for_status()
    return r.json()

# One sample per engine, run one after another rather than in parallel
for engine in ["chatgpt", "copilot", "gemini"]:
    a = ask_ai(engine, "best crm for startups")
    print(engine, "cached" if a["cached"] else "live", a["sources"])
```


---

<a id="web-data"></a>

# Web Data (Scrape, Map, Crawl, Extract)

The retrieval half of the API: read a page, list a site's URLs, crawl a site, or pull structured records out of pages. Pages that render client-side are rendered automatically. That automatic rendering is for accounts that have bought credits; on sign-up credits alone, pass `render: true` to render a page.

### Endpoints

```
POST   /scrape
POST   /scrape/batch
POST   /map
POST   /crawl
GET    /crawl
GET    /crawl/{jobId}
GET    /crawl/{jobId}/results
DELETE /crawl/{jobId}
POST   /extract
POST   /search-and-scrape
GET    /search/webpage
```

**Credits:**

| Endpoint | Credits |
|----------|---------|
| `POST /scrape`, `POST /map` | 1; 2 when your `maxAge` or `cache: false` forced a live fetch |
| `POST /scrape/batch`, `POST /crawl` | 1 per page fetched successfully, charged while the job runs |
| `POST /extract` | 1 per page fetched; a URL that failed, timed out or was not reached is free |
| `POST /search-and-scrape` | 1 for the search, plus 1 per page scraped, plus 1 per page fetched live when you set `maxAge` or `cache: false` |
| `GET /search/webpage` | 1 (a cached copy is billed the same) |
| `GET` and `DELETE` on `/crawl` | Free |

**From 2026-11-01: pages that needed a browser cost 5.** On every row above except `/map`, a page that had to be rendered costs 4 credits more than the table says: 5 instead of 1 per page. That covers `render: true`, `actions`, and automatic rendering when a plain fetch returns no usable content, such as a JavaScript-only page. The page's `via` says which it was: `browser` or `http`. A cached copy costs the same as the page it copies; a failed fetch is never charged; the live-fetch surcharge is added on top, not multiplied (a forced live fetch that needed a browser is 2 + 4 = 6). `render: false` keeps every page at the plain price. Until 2026-11-01 a browser page costs the same as any other.

### Freshness and the Live-Fetch Surcharge

`/scrape`, `/map` and `/search-and-scrape` keep a cache, controlled by two fields:

- Omit both, and any cached copy is used. The call costs 1 whether or not it was cached (from 2026-11-01, 5 for a page that needed a browser, cached or not).
- `maxAge` (seconds, 0–2592000): use a cached copy only if it is at most this old.
- `cache: false` is the same as `maxAge: 0`: always fetch live.

When your `maxAge` or `cache: false` makes Searlo fetch the page live, the call costs 2 and the response carries `X-Cache-Bypass: CHARGED`. If a cached copy satisfied your `maxAge`, it costs 1. `X-Credits-Deducted` always shows the total, and a cached answer carries `_cacheAgeSec`.

### URLs

Every `url` and `urls` field takes an http or https URL of up to 2,048 characters. An internationalised domain name works as you write it on `scrape`, `map`, `crawl`, `scrape/batch` and `extract`: `https://www.löschwagen.com/preise` is fetched, cached and billed under its encoded form, `https://www.xn--lschwagen-07a.com/preise`, and so is a path in any script. (`GET /search/webpage` takes the encoded `xn--` form only.)

A URL that cannot be fetched as written is a `400` `VALIDATION_ERROR` (`url must be an http(s) URL`), answered before anything is fetched and never charged: a control character or a space anywhere in it, an escaped one in the host (`%00`), a scheme other than http or https, or a URL that cannot be parsed. A private or reserved address is `422` `TARGET_NOT_ALLOWED`.

### Scrape

```
POST /scrape
```

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | http(s) URL, max 2048 chars |
| `formats` | string[] | No | Any of `markdown`, `text`, `html`, `links`, `images`, `headings`, `tables`, `json_ld` (default: `["markdown"]`) |
| `onlyMainContent` | boolean | No | Strip navigation, headers and footers (default: true) |
| `render` | boolean | No | `true` always renders the page; `false` never renders. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 |
| `includeLinks` | boolean | No | `false` removes links from the content and omits `links` unless `formats` asks for it |
| `includeImages` | boolean | No | `false` removes images from the content |
| `excludeSelectors` | string[] | No | CSS selectors to remove before extraction (up to 20) |
| `chunk` | boolean | No | Also split the markdown into `chunks` for embedding |
| `chunkMaxTokens` | number | No | Chunk size (256–16384) |
| `waitForSelector` | string | No | Wait for this selector before reading the page |
| `timeout` | number | No | Milliseconds (1000–60000) |
| `actions` | object[] | No | Up to 20 steps run in the page before it is read; implies `render: true`. `type` is one of `click`, `type`, `press`, `scroll`, `wait`, `waitForSelector`, `evaluate`, with `selector`, `text`, `key`, `ms` (0–15000) or `script` as the step needs. Each step's outcome comes back in `actions` |
| `cache` | boolean | No | Default: true (see Freshness) |
| `maxAge` | number | No | Seconds (see Freshness) |

```json
{
  "searchParameters": { "url": "https://example.com/pricing", "type": "scrape", "formats": ["markdown", "links"] },
  "url": "https://example.com/pricing",
  "requestedUrl": "https://example.com/pricing",
  "statusCode": 200,
  "via": "http",
  "bytes": 48213,
  "markdown": "# Pricing\n\nStart free, then pay as you go...",
  "metadata": {
    "title": "Pricing | Example",
    "description": "Plans and prices.",
    "author": null,
    "publishedDate": null,
    "language": "en",
    "canonicalUrl": "https://example.com/pricing",
    "robots": null,
    "wordCount": 812,
    "openGraph": { "title": "Pricing", "image": "https://example.com/og.png" },
    "twitter": null
  },
  "links": [{ "url": "https://example.com/signup", "text": "Sign up", "internal": true }],
  "source": "searlo.tech(scrape)",
  "credits": 1
}
```

`url` is the final URL after redirects and `requestedUrl` is what you sent. `via` is `http` or `browser` (`serp` for a Google results page). A page that comes back empty is returned but not cached.

| Status | `errorCode` | Meaning | Charged |
|--------|-------------|---------|---------|
| `422` | `TARGET_REFUSED` | The site refused every attempt, or answered with an error of its own. `targetStatus` holds the site's own HTTP status; `retryable` is `true` only when that was a `429` or a `5xx` | No |
| `422` | `TARGET_NOT_ALLOWED` | The URL points to a private or reserved network address (localhost, a private range, link-local, a cloud metadata address). Only pages on the public internet are fetched (`retryable: false`) | No |
| `422` | `TARGET_UNREACHABLE` | The site could not be fetched at all: its server refuses the secure-connection handshake, it redirects in a loop, or the page is larger than the size limit. `targetError` says which (`retryable: false`) | No |

### Map

```
POST /map
```

Every URL a site publishes, before you pay to crawl it: robots.txt, then its sitemaps (sitemap indexes included), then the homepage's links when there is no sitemap.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | The site, max 2048 chars |
| `limit` | number | No | Maximum URLs (1–5000, default: 1000) |
| `search` | string | No | Only URLs containing this text (max 200 chars) |
| `includeSubdomains` | boolean | No | Include subdomains |
| `sitemapOnly` | boolean | No | Sitemaps only, no homepage-link fallback |
| `cache`, `maxAge` | | No | See Freshness |

```json
{
  "searchParameters": { "url": "https://example.com", "type": "map", "limit": 1000 },
  "origin": "https://example.com",
  "discovery": "sitemap",
  "sitemaps": ["https://example.com/sitemap.xml"],
  "robots": { "present": true, "sitemapCount": 1 },
  "totalResults": 214,
  "urls": [
    { "url": "https://example.com/pricing", "lastModified": "2026-09-01" },
    { "url": "https://example.com/blog/launch", "lastModified": null }
  ],
  "source": "searlo.tech(map)",
  "credits": 1
}
```

`discovery` is `sitemap`, `links` (homepage links, when the site publishes no sitemap) or `none`.

### Crawl

```
POST /crawl
```

Queue a crawl of one site and get a job id back at once. Starting needs a minimum balance (configurable); after that, credits are charged per page as the crawl runs, so a job with `limit: 500` on a twelve-page site costs 12 (from 2026-11-01, plus 4 for each of those pages that needed a browser). robots.txt is obeyed by default.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `url` | string | Yes | Start URL |
| `maxDepth` | number | No | Link depth from the start URL (0–10, default: 2) |
| `limit` | number | No | Maximum pages (1–5000, default: 100) |
| `includePaths` | string[] | No | Only follow paths matching these globs, e.g. `/blog/*` (up to 25) |
| `excludePaths` | string[] | No | Skip paths matching these globs (up to 25) |
| `formats` | string[] | No | `markdown`, `text`, `html`, `links`, `json_ld` (default: `["markdown"]`) |
| `allowSubdomains` | boolean | No | Follow links to subdomains (default: false) |
| `allowExternal` | boolean | No | Follow links off the site (default: false) |
| `respectRobots` | boolean | No | Obey robots.txt (default: true) |
| `useSitemap` | boolean | No | Seed the crawl from the site's sitemaps (default: true) |
| `concurrency` | number | No | Pages fetched at once (1–8, default: 3) |
| `render` | boolean | No | `true` always renders the page; `false` never renders. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 |
| `onlyMainContent` | boolean | No | Default: true |
| `storeContent` | boolean | No | `false` keeps a link graph with no page bodies (default: true) |
| `webhook` | object | No | `{ "url": "https://…", "secret": "…" }`, called once when the crawl ends |

The snake_case spellings (`max_depth`, `include_paths`, `exclude_paths`, `allow_subdomains`, `allow_external`, `respect_robots`, `use_sitemap`, `only_main_content`, `store_content`) are accepted too.

```json
{
  "success": true,
  "jobId": "crawl_0123456789abcdef01234567",
  "status": "queued",
  "url": "https://example.com/docs",
  "options": { "maxDepth": 2, "limit": 50, "includePaths": ["/docs/*"], "excludePaths": [], "formats": ["markdown"], "allowSubdomains": false, "allowExternal": false, "respectRobots": true, "concurrency": 3, "render": null, "onlyMainContent": true, "useSitemap": true, "storeContent": true },
  "maxCredits": 50,
  "statusUrl": "/api/v1/crawl/crawl_0123456789abcdef01234567",
  "resultsUrl": "/api/v1/crawl/crawl_0123456789abcdef01234567/results",
  "createdAt": "2026-09-29T10:00:00.000Z"
}
```

The response is `202`. `maxCredits` is a ceiling, not a quote; from 2026-11-01 it allows 5 per page unless the job sets `render: false`.

### Batch Scrape

```
POST /scrape/batch
```

Up to 1000 URLs as one job, with no discovery: the job fetches exactly the URLs you send. They are normalised and de-duplicated first, so compare `submitted` with `urlCount`. Like a crawl, it needs a minimum balance to start and then bills 1 credit per page fetched (from 2026-11-01, 5 for a page that needed a browser). Status, results, cancellation and webhooks use the crawl endpoints below.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `urls` | string[] | Yes | 1–1000 http(s) URLs |
| `formats` | string[] | No | `markdown`, `text`, `html`, `links`, `json_ld` (default: `["markdown"]`). `images`, `headings` and `tables` are accepted but not stored |
| `render` | boolean | No | `true` always renders the page; `false` never renders. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 |
| `onlyMainContent` | boolean | No | Default: true |
| `concurrency` | number | No | Pages fetched at once (1–8, default: 3) |
| `storeContent` | boolean | No | `false` keeps only metadata and links (default: true) |
| `webhook` | object | No | `{ "url": "https://…", "secret": "…" }`, called once when the job ends |

`maxAge`, `cache`, `includeLinks` and `excludeSelectors` are accepted on a batch but have no effect: every page is fetched live, at 1 credit (5 from 2026-11-01 if it needed a browser).

The `202` response has the crawl shape plus `mode: "batch"`, `urlCount` (what was queued) and `submitted` (what you sent).

### Job Status and Results

```
GET /crawl/{jobId}
GET /crawl/{jobId}/results
GET /crawl
DELETE /crawl/{jobId}
```

All free, for crawls and batches alike.

```json
{
  "success": true,
  "jobId": "crawl_0123456789abcdef01234567",
  "status": "completed",
  "url": "https://example.com/docs",
  "options": { "maxDepth": 2, "limit": 50, "includePaths": ["/docs/*"], "excludePaths": [], "formats": ["markdown"], "allowSubdomains": false, "allowExternal": false, "respectRobots": true, "concurrency": 3, "render": null, "onlyMainContent": true, "useSitemap": true, "storeContent": true },
  "progress": { "discovered": 64, "completed": 50, "failed": 2, "skipped": 3 },
  "creditsCharged": 50,
  "stopReason": "page limit reached",
  "webhook": { "url": "https://example.com/hooks/crawl", "deliveredAt": "2026-09-29T10:04:10.000Z", "attempts": 1 },
  "createdAt": "2026-09-29T10:00:00.000Z",
  "startedAt": "2026-09-29T10:00:02.000Z",
  "finishedAt": "2026-09-29T10:04:09.000Z"
}
```

`status` is `queued`, `running`, `completed`, `failed` or `cancelled`. `stopReason` says how a job ended: `page limit reached`, `all urls processed` (a batch), `credit balance exhausted`, `job deadline reached` (jobs run for a configurable maximum time) or `cancelled by caller`. A job that runs out of credits keeps the pages it already fetched.

`GET /crawl/{jobId}/results` pages through what was fetched:

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `limit` | number | No | Pages per response (1–200, default: 50) |
| `cursor` | string | No | `nextCursor` from the previous response |
| `status` | string | No | `completed` (default), `failed`, `skipped`, `queued` or `all` |

```json
{
  "success": true,
  "jobId": "crawl_0123456789abcdef01234567",
  "status": "completed",
  "totalResults": 50,
  "hasMore": false,
  "nextCursor": null,
  "pages": [
    {
      "url": "https://example.com/docs/intro",
      "finalUrl": null,
      "depth": 1,
      "parentUrl": "https://example.com/docs",
      "status": "completed",
      "statusCode": 200,
      "via": "http",
      "title": "Introduction",
      "description": "Get started with Example.",
      "language": "en",
      "canonicalUrl": "https://example.com/docs/intro",
      "wordCount": 640,
      "markdown": "# Introduction\n...",
      "fetchedAt": "2026-09-29T10:00:05.000Z"
    }
  ]
}
```

`totalResults` counts the pages in this response, not the whole job. Skipped pages carry a `reason` (e.g. `disallowed by robots.txt`) and failed ones an `error`. `GET /crawl` lists your jobs, newest first (`limit` 1–100, default 20; optional `status`). `DELETE /crawl/{jobId}` stops a job; pages already fetched stay billed and readable. Jobs and their pages are kept for a configurable retention period.

### Job Webhooks

When a crawl or batch ends, Searlo POSTs once to `webhook.url`:

```json
{
  "jobId": "crawl_0123456789abcdef01234567",
  "event": "crawl.completed",
  "status": "completed",
  "url": "https://example.com/docs",
  "stopReason": "page limit reached",
  "progress": { "discovered": 64, "completed": 50, "failed": 2, "skipped": 3 },
  "creditsCharged": 50,
  "resultsUrl": "https://api.searlo.tech/api/v1/crawl/crawl_0123456789abcdef01234567/results",
  "startedAt": "2026-09-29T10:00:02.000Z",
  "finishedAt": "2026-09-29T10:04:09.000Z"
}
```

Headers: `X-Searlo-Job-Id`, `X-Searlo-Timestamp`, `X-Searlo-Event` and, when you set a secret, `X-Searlo-Signature`, signed the same way as [Async Tasks](#async-tasks) webhooks. The URL must be HTTPS and publicly reachable. A job webhook is attempted once and not retried: `webhook.attempts` and `webhook.deliveredAt` on the job show whether it landed.

### Extract

```
POST /extract
```

URLs plus a field schema in, one record per URL out. The URLs are fetched several at a time inside one overall time budget (75 seconds by default), so a long list takes about as long as its slowest pages, not the sum of them. Pages that arrive in time are served and billed; the rest come back as rows that say why.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `urls` | string[] | One of `urls`, `url` | 1–20 http(s) URLs |
| `url` | string | One of `urls`, `url` | A single URL |
| `schema` | object | Yes | 1–30 fields, each name mapped to a type: `string`, `number`, `integer`, `float`, `boolean` or `array`. Names use letters, digits and `_`, do not start with a digit, max 64 chars |
| `useLlm` | boolean | No | Use a language model for fields the page does not declare (default: true). It only has an effect when the deployment has a model configured; `llmAvailable` in the response says whether it does |
| `onlyMainContent` | boolean | No | Default: true |

Values come first from what the page declares (schema.org JSON-LD, then Open Graph and meta tags), then from unambiguous patterns in the text (prices, currencies, emails, phone numbers), then from the model if it is enabled. That first pass recognises common field names (`name`, `description`, `price`, `currency`, `rating`, `reviews`, `image`, `sku`, `brand`, `author`, `datePublished`, `availability`, `url`, `email`, `phone`, `address` and their usual aliases); other names are filled only by the model. A field nothing can fill comes back `null` and is listed in `missingFields`: it is not guessed. Your balance must cover one credit per URL before the call starts (`402` otherwise). Billing is one credit per page actually fetched, or from 2026-11-01, 5 for a row whose `via` is `browser`: a row that failed, timed out or was never reached is free.

```json
{
  "searchParameters": { "type": "extract", "urls": 3, "fields": ["name", "price", "currency", "availability"] },
  "mode": "structured+llm",
  "llmAvailable": true,
  "totalResults": 3,
  "partial": true,
  "data": [
    {
      "url": "https://shop.example.com/p/widget",
      "success": true,
      "data": { "name": "Widget Pro", "price": 49.99, "currency": "USD", "availability": null },
      "_sources": { "name": "json-ld", "price": "json-ld", "currency": "json-ld" },
      "missingFields": ["availability"],
      "via": "http",
      "llmUsed": false,
      "llmSkipped": "time budget"
    },
    {
      "url": "https://shop.example.org/p/gadget",
      "success": false,
      "data": null,
      "errorCode": "TARGET_REFUSED",
      "error": "target site refused the request (HTTP 403)",
      "retryable": false,
      "targetStatus": 403
    },
    {
      "url": "https://slow.example.net/p/thing",
      "success": false,
      "data": null,
      "errorCode": "NOT_FETCHED",
      "error": "not fetched: the request's time budget ran out before this URL's turn",
      "retryable": true
    }
  ],
  "source": "searlo.tech(extract)",
  "credits": 1
}
```

`_sources` gives each filled field's origin: `json-ld`, `meta`, `text` or `llm`. `credits` is the number of pages fetched. Every row has `url`, `success` and `data`; the rest depends on the row:

| Field | Description |
|-------|-------------|
| `success` | `true` when the page was fetched (1 credit), `false` when it was not (free, with `data: null`) |
| `errorCode` | On a failed row: `TARGET_REFUSED` (the site refused; `targetStatus` is its own HTTP status), `TARGET_NOT_ALLOWED` (a private or reserved address), `TARGET_UNREACHABLE` (the site cannot be fetched at all), `INVALID_REQUEST` (the URL was not accepted), `SCRAPE_FAILED` (the page could not be fetched right now), `TIMEOUT` (it did not arrive inside the time budget) or `NOT_FETCHED` (the budget ran out before this URL's turn) |
| `error` | On a failed row: what happened, in a sentence |
| `retryable` | On a failed row: whether the same URL may succeed on a retry. `true` for `SCRAPE_FAILED`, `TIMEOUT`, `NOT_FETCHED` and a refusal that was the site's own `429` or `5xx`; a site's `401` or `403` will not change |
| `partial` | At the top level, `true` when the time budget ran out before every URL was fetched. The rows say which (`TIMEOUT`, `NOT_FETCHED`); the rest are served and billed. Absent otherwise |
| `llmSkipped` | On a fetched row: `"time budget"` when the page came in too late to ask the model for its missing fields. `missingFields` lists them; send that URL again on its own to give the model its time |

A private or reserved address among the `urls` fails the whole call with `422` `TARGET_NOT_ALLOWED` before anything is fetched.

### Search and Scrape

```
POST /search-and-scrape
```

A web search, then the result pages scraped, in one call.

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `q` | string | Yes | Search query (1–500 chars) |
| `limit` | number | No | Results to scrape (1–20, default: 5). The search step returns at most 10 |
| `scrape` | boolean | No | `false` returns the search results only, for 1 credit (default: true) |
| `formats` | string[] | No | `markdown`, `text`, `html`, `json_ld` (default: `["markdown"]`) |
| `onlyMainContent` | boolean | No | Default: true |
| `render` | boolean | No | `true` always renders the page; `false` never renders. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 |
| `gl` | string | No | Country code, max 5 chars |
| `hl` | string | No | Language code, max 5 chars |
| `cache`, `maxAge` | | No | Apply to the scraped pages (see Freshness) |

```json
{
  "searchParameters": { "q": "vector database benchmarks", "type": "search-and-scrape", "limit": 3, "scrape": true },
  "totalResults": 3,
  "scrapedCount": 2,
  "results": [
    {
      "position": 1,
      "title": "Vector database benchmarks, 2026",
      "link": "https://www.example.com/benchmarks",
      "snippet": "We ran each database on the same hardware...",
      "domain": "example.com",
      "scraped": true,
      "via": "http",
      "statusCode": 200,
      "metadata": { "title": "Vector database benchmarks, 2026", "language": "en", "wordCount": 2310 },
      "markdown": "# Vector database benchmarks, 2026\n..."
    },
    { "position": 3, "title": "Benchmark notes", "link": "https://unreachable.example.net/post", "snippet": "...", "domain": "unreachable.example.net", "scraped": false, "error": "scrape failed" }
  ],
  "source": "searlo.tech(search-and-scrape)",
  "credits": 3
}
```

Each row is a web search result (`position`, `title`, `link`, `snippet`, `domain`, …) with its page attached. A page that could not be scraped comes back with `scraped: false` and an `error`, and is not billed.

### Webpage Fetch (GET or POST)

`GET /search/webpage` reads one page from a query string, for clients that can only send GET: one URL, one format, the page's metadata. `POST /search/webpage` takes the same fields as a JSON body, plus `excludeSelectors` (up to 20 CSS selectors to remove before extraction). 1 credit; from 2026-11-01, 5 when the page needed a browser (`via: "browser"`). A repeat call can be answered from cache (`X-Cache: HIT`) and is billed the same; to control freshness, use `POST /scrape` with `maxAge`.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `url` | string | Yes | An http(s) URL, max 2048 chars |
| `format` | string | No | `markdown` (default), `text` or `html` |
| `render` | boolean | No | `true` always renders the page; `false` never does. By default a page is rendered only when the plain fetch returns no usable content (an empty JavaScript shell, or a page the site refused), on accounts that have bought credits. From 2026-11-01 a rendered page costs 5 |
| `onlyMainContent` | boolean | No | Strip navigation, headers and footers (default: true) |
| `includeLinks` | boolean | No | `false` drops links from the content (default: true) |
| `includeImages` | boolean | No | `false` drops images from the content (default: true) |
| `chunk` | boolean | No | Also return `chunks`: the content split into overlapping pieces for embedding |
| `chunkMaxTokens` | number | No | Chunk size, 256–16384 tokens |
| `timeout` | number | No | Milliseconds, 1000–60000 |

```json
{
  "searchParameters": { "url": "https://example.com/blog/benchmarks", "type": "webpage", "format": "markdown" },
  "url": "https://example.com/blog/benchmarks",
  "requestedUrl": "https://example.com/blog/benchmarks",
  "domain": "example.com",
  "statusCode": 200,
  "via": "http",
  "bytes": 48213,
  "title": "Vector database benchmarks, 2026",
  "description": "We ran each database on the same hardware...",
  "author": null,
  "publishedDate": "2026-03-02",
  "openGraph": { "title": "Vector database benchmarks, 2026", "image": "https://example.com/og.png" },
  "wordCount": 2310,
  "content": "# Vector database benchmarks, 2026\n...",
  "source": "searlo.tech(webpage)",
  "credits": 1
}
```

`url` is where the page ended up after redirects; `requestedUrl` is what you sent. `via` is `http` for a plain fetch and `browser` for a rendered one.

A site that refuses the fetch is `422` `TARGET_REFUSED` (`targetStatus` is the site's own status), a private or reserved address is `422` `TARGET_NOT_ALLOWED`, and a site no attempt could fetch is `422` `TARGET_UNREACHABLE`, as on `POST /scrape`. None is charged.

## Code Examples

### cURL

```bash
# One page as markdown plus its links
curl -X POST "https://api.searlo.tech/api/v1/scrape" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/pricing", "formats": ["markdown", "links"] }'

# Start a crawl, then poll it and page through the results
curl -X POST "https://api.searlo.tech/api/v1/crawl" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/docs", "maxDepth": 2, "limit": 50, "includePaths": ["/docs/*"] }'

curl "https://api.searlo.tech/api/v1/crawl/crawl_0123456789abcdef01234567" \
  -H "x-api-key: sk_your_api_key"

curl "https://api.searlo.tech/api/v1/crawl/crawl_0123456789abcdef01234567/results?limit=50" \
  -H "x-api-key: sk_your_api_key"

# Structured records from a product page
curl -X POST "https://api.searlo.tech/api/v1/extract" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "urls": ["https://shop.example.com/p/widget"], "schema": { "name": "string", "price": "number", "currency": "string" } }'
```

### JavaScript

```javascript
const API = "https://api.searlo.tech/api/v1";
const headers = { "x-api-key": process.env.SEARLO_API_KEY, "Content-Type": "application/json" };

// Start the crawl
const job = await (await fetch(`${API}/crawl`, {
  method: "POST",
  headers,
  body: JSON.stringify({ url: "https://example.com/docs", maxDepth: 2, limit: 50 }),
})).json();

// Poll until it ends
let status;
do {
  await new Promise(r => setTimeout(r, 5000));
  status = await (await fetch(`${API}/crawl/${job.jobId}`, { headers })).json();
} while (status.status === "queued" || status.status === "running");
console.log(status.stopReason, status.progress, status.creditsCharged);

// Page through the results by cursor
let cursor = null;
do {
  const qs = new URLSearchParams({ limit: "200" });
  if (cursor) qs.set("cursor", cursor);
  const page = await (await fetch(`${API}/crawl/${job.jobId}/results?${qs}`, { headers })).json();
  page.pages.forEach(p => console.log(p.url, p.wordCount));
  cursor = page.nextCursor;
} while (cursor);
```

### Python

```python
import os
import requests

API = "https://api.searlo.tech/api/v1"
HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}

# Accept a cached copy up to an hour old; a live fetch forced by maxAge costs 2
page = requests.post(
    f"{API}/scrape",
    json={"url": "https://example.com/pricing", "formats": ["markdown", "tables"], "maxAge": 3600},
    headers=HEADERS,
    timeout=90,
)
print(page.headers.get("X-Cache"), page.headers.get("X-Credits-Deducted"))
print(page.json()["markdown"][:500])

out = requests.post(
    f"{API}/extract",
    json={
        "urls": ["https://shop.example.com/p/widget"],
        "schema": {"name": "string", "price": "number", "currency": "string", "availability": "string"},
    },
    headers=HEADERS,
    timeout=120,
).json()
if out.get("partial"):
    # The time budget ran out: these rows can be sent again
    print("retry:", [r["url"] for r in out["data"] if not r["success"] and r["retryable"]])
for row in out["data"]:
    print(row["url"], row.get("data"), row.get("_sources"), row.get("missingFields"))
```


---

<a id="async-tasks"></a>

# Async Tasks

Queue a request, get a task id back at once, and collect the result later by polling or by webhook. The task runs the same service the synchronous route uses.

### Endpoints

```
POST   /async/task
POST   /async/task/batch
GET    /async/task/{taskId}
GET    /async/status
DELETE /async/queue
```

**Credits:** the endpoint's price, deducted when the task is queued. Kept when the task completes; refunded when it fails or is cancelled. Reading tasks and status is free.

### Request

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `endpoint` | string | Yes | What to run (see the table below) |
| `request` | object | No | The parameters for that endpoint. Must include `q` (`ai/…` tasks also accept `prompt`), except for `goto` |
| `webhook` | object | No | `{ "url": "https://…", "secret": "…" }`. `url` is required inside it; `secret` (max 256 chars) signs each delivery |
| `priority` | number | No | 1–100 (default: 50). Higher runs first |
| `idempotencyKey` | string | No | Max 128 chars. Sending the same key again returns the existing task (`duplicate: true`, HTTP `200`) without a new charge |

The field is `endpoint`, not `type`, and the webhook is `webhook: { url, secret }`, not `webhookUrl`. Unknown top-level fields are stripped without an error: a request that sends `webhookUrl` is queued with no webhook, and one that sends `type` instead of `endpoint` is rejected with `400`.

| `endpoint` | Credits | Runs |
|------------|---------|------|
| `serp` | 3 per page; from 2026-11-01, +5 when the AI Overview had to be completed by rendering the page | [Full SERP](#full-serp). `request` takes the `GET /serp` fields. A country `location` (or its `uule`) is applied as `gl`; a city or state fails the task at once with `CITY_LOCATION_UNAVAILABLE`, refunded |
| `news/serp` | 2 per page | `GET /search/news/serp`, with the same location rule |
| `rankings/check` | 3 per 10 results of `depth` | [Rank Checks](#rankings). Send `keyword` and `domain` as usual and repeat the keyword as `q`, which the queue requires |
| `ai/chatgpt`, `ai/copilot` | 8 | [AI Answers](#ai-answers) |
| `ai/perplexity` | 2 | [AI Answers](#ai-answers) |
| `ai/gemini` | 4 | [AI Answers](#ai-answers) |
| `web` | 1 | Web search. `request` goes to the search service as is: use `num` (1–10), `start`, `country` and `language`, not `limit`, `page`, `gl` and `hl` |
| `maps` | 1 | Maps search. Use `num` for the result count; `gl`, `hl`, `ll`, `lat`, `lng` and `zoom` work as on `/search/maps` |
| `shopping` | 1 | Shopping search. Use `num` for the result count, plus `gl` and `hl` |
| `goto` | 0 | Resolves Google `/goto` redirect links: `url`, or `urls` (up to 50) |

The validator also accepts `keywords/…`, `domain/…`, `ai/aimode` and `ai/grok`; those endpoints are switched off in production, so do not queue them.

### Response

`POST /async/task` answers `202` with the queued task:

```json
{
  "success": true,
  "duplicate": false,
  "task": {
    "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e",
    "endpoint": "serp",
    "status": "QUEUED",
    "priority": 50,
    "attempts": 0,
    "creditsHeld": 6,
    "creditsUsed": 0,
    "creditsRefunded": false,
    "request": { "q": "best running shoes", "gl": "us", "pages": 2 },
    "webhook": { "url": "https://example.com/hooks/searlo", "attempts": 0, "lastStatus": null, "deliveredAt": null },
    "queuedAt": "2026-09-29T10:00:00.000Z",
    "startedAt": null,
    "completedAt": null,
    "durationMs": null,
    "expiresAt": "2026-10-06T10:00:00.000Z"
  }
}
```

`GET /async/task/{taskId}` returns `{ "success": true, "task": { … } }` in the same shape, or `404` for an unknown id. `status` is `QUEUED`, `PROCESSING`, `COMPLETED`, `FAILED` or `CANCELLED`.

- `result` appears once the task is `COMPLETED`. For `serp`, `news/serp` and `ai/…` it is the body the synchronous route returns; for `rankings/check` it is the check's `result` object with `success: true`.
- `error` (`code`, `message`, `retryable`) appears when the task is `FAILED`.
- A retryable failure (capacity, an unusable upstream page) is queued again after a delay, the engine's `Retry-After` when it gave one, up to a configurable number of attempts. After that, or on a non-retryable error, the task is `FAILED` and its credits are refunded (`creditsRefunded: true`). Errors in the request itself (`VALIDATION_ERROR`, `CITY_LOCATION_UNAVAILABLE`, `UNKNOWN_LOCATION`, `INVALID_DOMAIN`, a query shape Google refuses on sight) fail at once, without retries.
- A result larger than a configurable size has its bulkiest top-level fields removed, listed in `result._trimmed`.
- Tasks are deleted after a configurable retention period; `expiresAt` says when.

### Batch

`POST /async/task/batch` takes `{ "tasks": [ … ] }` with 1–500 task objects, each shaped like a single request. Each task is charged when it is queued. The response is `202` when at least one task was accepted, `400` otherwise:

```json
{
  "success": true,
  "accepted": 2,
  "total": 3,
  "tasks": [
    { "accepted": true, "duplicate": false, "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e", "status": "QUEUED", "creditsHeld": 8 },
    { "accepted": true, "duplicate": false, "taskId": "9b1d7e0a-2c4f-4e8b-a6d3-1f2e3d4c5b6a", "status": "QUEUED", "creditsHeld": 2 },
    { "accepted": false, "error": "request.q is required", "endpoint": "ai/copilot" }
  ]
}
```

If your credits run out part-way, the batch stops there: the remaining tasks are not queued and do not appear in `tasks`, so compare `tasks.length` with `total`. A task that fails validation (an unknown `endpoint`, say) rejects the whole batch with `400`.

### Queue Status

`GET /async/status` returns your queue: `queued`, `processing`, `priorityBreakdown` (`[{ "priority": 50, "count": 12 }]`), `last24h` (`completed`, `failed`) and `worker` (the answering process's `inflight` and `concurrency` per lane). `DELETE /async/queue` cancels every task still `QUEUED` and refunds it: `{ "success": true, "cleared": 12, "creditsRefunded": 36 }`.

### Webhooks

When a task ends as `COMPLETED` or `FAILED` (not when it is queued again for a retry), Searlo POSTs to `webhook.url`:

```json
{
  "taskId": "5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e",
  "endpoint": "serp",
  "status": "COMPLETED",
  "result": { "success": true, "organic": [], "peopleAlsoAsk": [] },
  "creditsUsed": 6,
  "completedAt": "2026-09-29T10:01:12.000Z"
}
```

A failed task sends `error` instead of `result`. Headers:

- `X-Searlo-Task-Id`, and `X-Searlo-Event` (`task.completed` or `task.failed`)
- `X-Searlo-Timestamp`: milliseconds since the epoch
- `X-Searlo-Signature` (when you set a secret): `v1=` followed by the hex HMAC-SHA256, keyed with your secret, of `<timestamp>.<raw body>`

Any `2xx` counts as delivered; redirects are not followed. A failed delivery (another status, or no answer within a configurable timeout) is retried after 5 minutes, 30 minutes, 2 hours and 12 hours, then given up. The URL must be HTTPS and resolve to a public address; it is checked when you queue the task and again before each delivery.

## Code Examples

### cURL

```bash
# Queue a two-page SERP with a signed webhook (6 credits held)
curl -X POST "https://api.searlo.tech/api/v1/async/task" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{
    "endpoint": "serp",
    "request": { "q": "best running shoes", "gl": "us", "pages": 2 },
    "webhook": { "url": "https://example.com/hooks/searlo", "secret": "whsec_change_me" },
    "idempotencyKey": "serp-best-running-shoes-2026-09-29"
  }'

# Poll it
curl "https://api.searlo.tech/api/v1/async/task/5f0c2b1e-7a3d-4c1b-9d0e-2a6f8b9c1d3e" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
import crypto from "node:crypto";

// Queue one AI answer per engine in a single call
const res = await fetch("https://api.searlo.tech/api/v1/async/task/batch", {
  method: "POST",
  headers: { "x-api-key": process.env.SEARLO_API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({
    tasks: ["chatgpt", "perplexity", "copilot"].map(engine => ({
      endpoint: `ai/${engine}`,
      request: { q: "best crm for startups", gl: "us" },
      webhook: { url: "https://example.com/hooks/searlo", secret: process.env.SEARLO_WEBHOOK_SECRET },
    })),
  }),
});
const { accepted, total, tasks } = await res.json();
console.log(`${accepted}/${total} queued`, tasks.map(t => t.taskId));

// In your webhook handler: verify the signature against the RAW request body
function verifySearloSignature(rawBody, headers, secret) {
  const timestamp = headers["x-searlo-timestamp"];
  const expected =
    "v1=" + crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
  const received = String(headers["x-searlo-signature"] || "");
  return (
    received.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
  );
}
```

### Python

```python
import os
import time
import requests

API = "https://api.searlo.tech/api/v1"
HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}

r = requests.post(
    f"{API}/async/task",
    json={
        "endpoint": "rankings/check",
        # rankings/check reads keyword and domain; the queue also requires q
        "request": {
            "q": "project management software",
            "keyword": "project management software",
            "domain": "example.com",
            "depth": 20,
        },
    },
    headers=HEADERS,
)
r.raise_for_status()
task_id = r.json()["task"]["taskId"]

while True:
    task = requests.get(f"{API}/async/task/{task_id}", headers=HEADERS).json()["task"]
    if task["status"] in ("COMPLETED", "FAILED", "CANCELLED"):
        break
    time.sleep(5)

print(task["status"], task.get("result") or task.get("error"), task["creditsUsed"])
```


---

<a id="search-analytics"></a>

# Search Analytics

Retrieve analytics about your own search usage. **Free (0 credits).**

### Endpoint

```
GET /search/analytics
```

### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `startDate` | string | No | ISO date — filter from this date |
| `endDate` | string | No | ISO date — filter up to this date (must be after `startDate`) |
| `searchType` | string | No | Filter by type (e.g. `web`, `image`, `news`, `batch`) |

### Response

```json
{
  "success": true,
  "data": {
    "totalSearches": 1284,
    "searchesByType": { "web": 900, "image": 200, "news": 184 },
    "recentSearches": [ { "query": "...", "searchType": "web", "createdAt": "..." } ],
    "popularQueries": [ { "query": "react", "count": 42 } ],
    "creditsUsed": 1284,
    "averageResponseTime": 412
  },
  "message": "Analytics retrieved successfully"
}
```

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/search/analytics?startDate=2026-01-01&searchType=web" \
  -H "x-api-key: sk_your_api_key"
```

### Python

```python
import os
import requests

res = requests.get(
    "https://api.searlo.tech/api/v1/search/analytics",
    params={"startDate": "2026-01-01", "searchType": "web"},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]}
)
print(res.json()["data"])
```


---

<a id="email-verify"></a>

# Email Verifier

Validate an email address's deliverability.

### Verify Endpoint

```
POST /email-verifier/verify
```

**Credits:** 5 per request. Rate-limited by the separate `email_verifier` tier group (see [Rate Limiting](#rate-limiting)). Verification is also paced: when it is busy the call answers `429` `VERIFIER_BUSY` with a `Retry-After` (30 seconds by default), and is not charged.

### Body

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `email` | string | Yes | Email address to verify |

### Response

Responses use the standard envelope (`statusCode`, `success`, `message`, `data`).

```json
{
  "statusCode": 200,
  "success": true,
  "message": "Email verified",
  "data": {
    "email": "user@example.com",
    "status": "valid",
    "domain": "example.com",
    "message": "Mailbox exists",
    "details": {
      "format": true,
      "mailboxStatus": "...",
      "mailboxType": "...",
      "mailboxExchange": "..."
    },
    "verifiedAt": "2026-05-20T10:30:00.000Z",
    "source": "searlo"
  }
}
```

`status` is one of: `valid`, `invalid`, `accept_all`, `unknown`, `error`.

### History Endpoint

```
GET /email-verifier/history
```

**Credits:** Free (0). Returns your past verifications, paginated.

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `page` | number | No | Page number (default: 1) |
| `limit` | number | No | Results per page (1–100, default: 20) |

## Code Examples

### cURL

```bash
curl -X POST "https://api.searlo.tech/api/v1/email-verifier/verify" \
  -H "x-api-key: sk_your_api_key" \
  -H "Content-Type: application/json" \
  -d '{ "email": "user@example.com" }'
```

### JavaScript

```javascript
const res = await fetch(
  "https://api.searlo.tech/api/v1/email-verifier/verify",
  {
    method: "POST",
    headers: {
      "x-api-key": process.env.SEARLO_API_KEY,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ email: "user@example.com" }),
  }
);
const { data } = await res.json();
console.log(data.status); // "valid" | "invalid" | "accept_all" | "unknown" | "error"
```

### Python

```python
import os
import requests

res = requests.post(
    "https://api.searlo.tech/api/v1/email-verifier/verify",
    json={"email": "user@example.com"},
    headers={"x-api-key": os.environ["SEARLO_API_KEY"]},
)
print(res.json()["data"]["status"])
```


---

<a id="account"></a>

# Account & Utilities

Your balance, and what this API serves right now. **All free (0 credits)**; each needs your API key, like every other call.

### Endpoints

```
GET /credits/balance
GET /credit/balance
GET /search/capabilities
GET /search/countries
GET /search/states
```

### Credit Balance

`GET /credits/balance` returns the credits available to your account, and nothing else about it. It works with an API key (`x-api-key`, or `Authorization: Bearer sk_...`) and with a dashboard login. It is free, and like any call it counts against your request limits.

```json
{
  "success": true,
  "credits": {
    "available": 2500,
    "reserved": 6,
    "expiresAt": "2027-09-01T10:00:00.000Z",
    "expiringCredits": 2500
  }
}
```

| Field | Description |
|-------|-------------|
| `available` | Credits you can spend now. It is the balance `GET /user/profile` shows (0 while the account is on hold) |
| `reserved` | Present when known: how much of `available` requests still in flight, or awaiting settlement, already hold |
| `expiresAt` / `expiringCredits` | Present when some credits expire: when the next ones do, and how many expire then |

The answer is never cached (`Cache-Control: no-store`).

`GET /credit/balance` is the fuller view: the same balance with a low-balance flag and the expiry of each grant.

```json
{
  "statusCode": 200,
  "data": {
    "balance": 2500,
    "available": 2500,
    "isLowBalance": false,
    "lastUpdated": "2026-09-29T08:12:03.000Z",
    "expiry": {
      "policyApplies": true,
      "durationDays": 365,
      "totalExpiringAmount": 2500,
      "nonExpiringAmount": 0,
      "nextExpiryAt": "2027-09-01T10:00:00.000Z",
      "daysUntilNextExpiry": 336,
      "nextExpiryAmount": 2500,
      "breakdown": [
        { "sourceType": "purchase", "amount": 2500, "grantedAt": "2026-09-01T10:00:00.000Z", "expiresAt": "2027-09-01T10:00:00.000Z", "daysRemaining": 336, "status": "active" }
      ]
    }
  },
  "message": "Credit balance retrieved successfully",
  "success": true
}
```

| Field | Description |
|-------|-------------|
| `available` | Credits you can spend now (`balance` is the same number) |
| `isLowBalance` | Below the low-balance threshold (100 credits by default) |
| `expiry.nextExpiryAt` / `nextExpiryAmount` | When credits next expire, and how many expire then |
| `expiry.breakdown` | Each purchase or grant still holding credits, soonest expiry first |

Every metered response also carries `X-Credits-Deducted` and `X-Credits-Remaining`, so a client rarely needs to poll this.

### Capabilities

`GET /search/capabilities` lists every endpoint this deployment serves, what it costs, whether it is switched on, and how it has done over the last two hours. Read it instead of hard-coding which endpoints are available: a switched-off endpoint answers `403` `FEATURE_NOT_AVAILABLE` (not charged), and this tells you before you pay the round trip.

```json
{
  "success": true,
  "generatedAt": "2026-09-29T17:20:00.000Z",
  "capabilities": [
    {
      "id": "serp/html",
      "method": "GET",
      "path": "/serp/html",
      "credits": 2,
      "creditsNote": "per page; 8 with js=true (rendered, AI Overview opened out)",
      "group": "google",
      "enabled": true,
      "state": "live",
      "health": { "window": "2h", "ok": 412, "fail": 6, "successRate": 0.99 }
    },
    {
      "id": "backlinks",
      "method": "GET",
      "path": "/backlinks",
      "credits": 0,
      "group": "backlinks",
      "enabled": false,
      "reason": "No backlink provider is bound to this deployment. The endpoints answer 501 rather than returning synthesised link data.",
      "state": "off",
      "health": { "window": "2h", "ok": 0, "fail": 0, "successRate": null }
    }
  ],
  "states": { "live": "serving", "degraded": "on, but most recent calls failed", "off": "switched off here" }
}
```

`state` is `off` when the endpoint is switched off (`reason` says why), `degraded` when at least 3 calls in the window and fewer than half of them succeeded, and `live` otherwise. `successRate` is a fraction (0–1), `null` with no calls in the window. The response may be cached for 30 seconds.

### Countries and States

`GET /search/countries` lists the countries the AI answer endpoints accept as `gl`. The search endpoints accept any two-letter `gl`, applied as the results country.

```json
{
  "success": true,
  "engine": "all",
  "countries": [
    { "code": "US", "name": "United States", "states": true, "exit": true },
    { "code": "GB", "name": "United Kingdom", "states": false, "exit": true }
  ]
}
```

`GET /search/states` lists the US states with Google's name and `uule` token for each. **State- and city-level location is not available yet** ([Location](#full-serp)): the response says `targeting: false`, and a state passed as `location` or `uule` returns `400` `CITY_LOCATION_UNAVAILABLE`. Only `country=US` is available; any other country returns `400`.

```json
{
  "success": true,
  "country": "US",
  "exitPinning": false,
  "targeting": false,
  "message": "State- and city-level location is not available yet: a state passed as location returns 400 CITY_LOCATION_UNAVAILABLE. Country (gl) and language (hl) are applied.",
  "states": [
    { "code": "TX", "name": "Texas", "location": "Texas,United States", "uule": "w+CAIQICIT..." }
  ]
}
```

`exitPinning: false`: results are not localized to the state.

## Code Examples

### cURL

```bash
curl "https://api.searlo.tech/api/v1/credits/balance" \
  -H "x-api-key: sk_your_api_key"

curl "https://api.searlo.tech/api/v1/credit/balance" \
  -H "x-api-key: sk_your_api_key"

curl "https://api.searlo.tech/api/v1/search/capabilities" \
  -H "x-api-key: sk_your_api_key"
```

### JavaScript

```javascript
const headers = { "x-api-key": process.env.SEARLO_API_KEY };
const res = await fetch("https://api.searlo.tech/api/v1/search/capabilities", { headers });
const { capabilities } = await res.json();

// Only call what is on here right now
const live = new Set(capabilities.filter(c => c.state !== "off").map(c => c.path));
console.log(live.has("/serp/html"));
```

### Python

```python
import os
import requests

HEADERS = {"x-api-key": os.environ["SEARLO_API_KEY"]}
balance = requests.get("https://api.searlo.tech/api/v1/credits/balance", headers=HEADERS).json()
print(balance["credits"]["available"], balance["credits"].get("expiresAt"))
```


---

<a id="rate-limiting"></a>

# Rate Limiting

API requests are rate-limited per user with sliding windows. **Every account gets the same limits**, free credits included: buying credits does not change them.

Limits are tracked **per endpoint group**, and each group has its own buckets:

| Group | Covers | Per Second | Per Minute | Per Hour | Per Day |
|-------|--------|-----------|-----------|---------|---------|
| `search` | `/search/*`, `/serp`, `/rankings`, AI answers, web data, async tasks | 600 | 20,000 | 400,000 | 1,800,000 |
| `local` | `/search/maps`, `/search/places`, `/search/reviews`, `/search/place-details` | 20 | 48 | 2,400 | 16,000 |
| `email_verifier` | `/email-verifier/*` | 40 | 200 | 10,000 | 40,000 |
| `mcp` | `/mcp` | 60 | 1,200 | 16,000 | 150,000 |

`local` is a tighter cap inside the search budget: a maps, places, reviews or place-details call must pass both. API-key and dashboard requests share one bucket per user. On top of the windows, an account can have at most 64 web, image, site, advanced and batch searches in flight at once (see Searches in Flight).

### Per API Key

Each API key also has its own caps, inside the account's budget: **20,000 requests a minute and 400,000 a day** by default for a new key. The dashboard shows each key's caps. They are fixed windows that start with the key's first request in each window. An account can hold several keys, and all of them draw on the account's limits above.

High-volume accounts get custom limits for the account and for each key, and reserved capacity on the SERP hosts: ask support with your expected daily volume and peak rate.

### Searches in Flight

Web, image, site, advanced and batch search (and the `GET /search` alias) also cap how many of an account's requests may run at once. Past the cap a request is refused at once with `429`, `code` `4203` and `errorCode` `CONCURRENCY_LIMIT`, a `Retry-After` of 1 second and an `X-Concurrency-Limit` header that carries the cap: 64 in flight per account, on every plan. It is refused before any credit is reserved, so it is never charged. A slot frees as soon as a response finishes: retry after the second.

```json
{
  "success": false,
  "message": "Too many searches in flight for this account (at most 64 at once). Retry in 1s.",
  "code": 4203,
  "errorCode": "CONCURRENCY_LIMIT",
  "retryable": true,
  "retryAfter": 1,
  "limit": 64
}
```

### Rate Limit Headers

Every rate-limited response carries the state of each window, and the Unix time (seconds) at which the earliest one resets:

```
X-RateLimit-Limit-Second: 600
X-RateLimit-Remaining-Second: 599
X-RateLimit-Limit-Minute: 20000
X-RateLimit-Remaining-Minute: 19987
X-RateLimit-Limit-Hour: 400000
X-RateLimit-Remaining-Hour: 399421
X-RateLimit-Limit-Day: 1800000
X-RateLimit-Remaining-Day: 1771305
X-RateLimit-Reset: 1790690460
```

### Handling Rate Limits

A `429 Too Many Requests` from the account limits has `code: 4201`:

```json
{
  "success": false,
  "message": "Rate limit exceeded. Please slow down and try again later.",
  "code": 4201,
  "retryAfter": 60,
  "limits": { "perSecond": { "limit": 600, "remaining": 0, "resetAt": 1790690000 } }
}
```

Wait `retryAfter` seconds (the same value is in the `Retry-After` header) before retrying. `limits` shows each window's state; the example is trimmed. A key's own cap answers the same way with `code: 4202` and `"message": "API key rate limit exceeded."`. The in-flight cap answers `code: 4203` (above).

A `429` can also mean a lane is full rather than a limit reached: `AT_CAPACITY` (Full SERP, AI answers, shopping), `BUSY` (SERP HTML) and `VERIFIER_BUSY` (e-mail verifier) carry a `Retry-After` too, and are not charged. See [Error Handling](#errors).

## Code Examples

### JavaScript

```javascript
async function fetchWithRetry(url, options, maxRetries = 3) {
  for (let i = 0; i < maxRetries; i++) {
    const res = await fetch(url, options);

    if (res.status === 429) {
      const retryAfter = res.headers.get("Retry-After") || 60;
      await new Promise(r => setTimeout(r, retryAfter * 1000));
      continue;
    }

    return res;
  }
  throw new Error("Max retries exceeded");
}
```

### Python

```python
import time
import requests

def fetch_with_retry(url, headers, max_retries=3):
    for i in range(max_retries):
        response = requests.get(url, headers=headers)

        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 60))
            time.sleep(retry_after)
            continue

        return response

    raise Exception("Max retries exceeded")
```


---

<a id="errors"></a>

# Error Handling

Every error answers JSON with `success: false` and a human-readable `message`, and almost every one adds a stable machine-readable name for the failure. Branch on that name and the status, not on `message`, which may be reworded. The name is in `code`; on a few errors, marked † below, it is in `errorCode` instead, so read `code` and fall back to `errorCode`.

### Error Response Format

```json
{
  "success": false,
  "code": "AT_CAPACITY",
  "message": "Google SERP is at capacity. Retry in 60s — immediate retries will be refused.",
  "retryable": true,
  "retryAfterSec": 60
}
```

- `retryable` says whether the same request can succeed later. When it is `true`, wait `Retry-After` (header) or `retryAfterSec` (body) before retrying.
- Validation errors add an `errors` array of `{ field, message }`:

```json
{
  "success": false,
  "code": "VALIDATION_ERROR",
  "message": "Query validation failed: \"q\" is required",
  "errors": [{ "field": "q", "message": "\"q\" is required" }]
}
```

- Insufficient-credit errors (`402`) add `details`:

```json
{
  "success": false,
  "error": "Insufficient credits",
  "code": "INSUFFICIENT_CREDITS",
  "details": { "required": 5, "available": 2, "shortfall": 3 },
  "message": "This request requires 5 credit(s), but you only have 2 credit(s) available. Please purchase more credits to continue.",
  "topUpUrl": "https://dashboard.searlo.tech/credits?tab=buy&source=api_402"
}
```

### Codes

Every status and code the API answers, in one table. **A call that answers with one of them is never charged**: it releases the credits it held, and the `429`s for rate limits and searches in flight are refused before any credit is reserved. A `200` that finds nothing (an empty list) is not charged either. The one exception is a failed row inside a `200` `POST /rankings/batch`, which is billed with the batch (see Refunds). † marks a name carried in `errorCode`.

| Status | `code` | Meaning | Retry |
|--------|----------|---------|-------|
| `400` | `VALIDATION_ERROR` | A parameter is missing, malformed or out of range (`errors` lists which). Also a URL that cannot be fetched as written, and an image page past Google's last | Fix the request |
| `400` | `INVALID_JSON` | The body is not valid JSON | Fix the request |
| `400` | `INVALID_IDEMPOTENCY_KEY` | The idempotency key is longer than 255 characters | Fix the request |
| `400` | `CITY_LOCATION_UNAVAILABLE` | `location` or `uule` names a city or a state; only countries are applied ([Location](#full-serp)) | No |
| `400` | `UNKNOWN_LOCATION` | `location` is not a country we recognise; `suggestions` lists close matches | Fix the request |
| `400` | `INVALID_DOMAIN` | A rank check's `domain` cannot be parsed | Fix the request |
| `400` | `PAGE_OUT_OF_RANGE` † | Web, site and advanced search: the page starts past result 101. `maxPage` is the last page that can be served | No |
| `400` | `DATE_FILTER_UNAVAILABLE` † | A date filter (`dateRestrict`, `dateRange`) combined with search operators such as `site:` or `filetype:` | Remove the filter or the operators |
| `400` | `INVALID_REQUEST` † | Web data: the fetcher did not accept the URL; `message` says why | Fix the request |
| `400` / `404` | `PLACE_NOT_FOUND` † | Place details: the `placeId` names no business or `q` matched nothing (`400`), or Google does not know the id (`404`) | No |
| `401` | `UNAUTHORIZED` | A missing, invalid or expired API key, or a removed account | Fix the key |
| `402` | `INSUFFICIENT_CREDITS` | Not enough credits for this call. `details` gives `required`, `available` and `shortfall`, and `topUpUrl` links to buying more. On `POST /extract`, on crawl and batch-scrape starts and on async tasks the name is in `errorCode` † | After a top-up |
| `403` | `FORBIDDEN` | The account is suspended | No |
| `403` | `FEATURE_NOT_AVAILABLE` | The endpoint is switched off on this deployment; `retryable: false` | No |
| `403` | `API_KEY_SCOPE_FORBIDDEN` | An API key used on an account-management route (API keys, data export, account deletion, payments, billing); those need a dashboard login. `GET /user/profile` stays open to keys | No |
| `404` | `NOT_FOUND` | Unknown route or resource | No |
| `409` | `IDEMPOTENCY_REQUEST_IN_PROGRESS` | A request with this idempotency key is still running | Shortly |
| `413` | `PAYLOAD_TOO_LARGE` | The request body is too large | No |
| `422` | `QUERY_NOT_SERVED` | Full SERP and SERP HTML: a query shape these endpoints do not serve (search operators, e-mail lookups, long `OR` chains) | No |
| `422` | `COUNTRY_UNAVAILABLE` | Full SERP and SERP HTML: this `gl` cannot be served right now | Later |
| `422` | `IDEMPOTENCY_KEY_REUSED` | This idempotency key was already used for a different request | With a new key |
| `422` | `TARGET_REFUSED` † | Web data: the site refused the fetch; `targetStatus` is its own status | Only if `retryable` |
| `422` | `TARGET_NOT_ALLOWED` | Web data: a private or reserved network address | No |
| `422` | `TARGET_UNREACHABLE` † | Web data: no attempt could fetch the site (its secure-connection handshake is refused, it redirects in a loop, or the page is too large); `targetError` says which | No |
| `429` | `4201` | The account's [rate limit](#rate-limiting) | After `Retry-After` |
| `429` | `4202` | The API key's own rate limit | After `Retry-After` |
| `429` | `4203` | The account has too many searches in flight (`errorCode` `CONCURRENCY_LIMIT`; see [Rate Limiting](#rate-limiting)) | After `Retry-After` (1 second) |
| `429` | `AT_CAPACITY` | Full SERP, rank checks, AI answers, shopping: at capacity | After `Retry-After` |
| `429` | `BUSY` | SERP HTML: at capacity | After `Retry-After` |
| `429` | `VERIFIER_BUSY` | E-mail verifier: verification is paced; `Retry-After` is 30 seconds by default | After `Retry-After` |
| `500` | `INTERNAL_ERROR` | Something failed on our side | Yes, with backoff |
| `503` | `UPSTREAM_UNAVAILABLE` | The upstream (Google, an AI engine, or our own backend) did not give a usable answer; `retryable: true` | Yes, after `Retry-After` |
| `503` | `UPSTREAM_RATE_LIMITED` | Search is backing off from its upstream; `retryAfter` is the wait | Yes, after `Retry-After` |
| `503` | `AT_CAPACITY` | Web, image, news and the other search endpoints at capacity (a `429` on SERP and AI answers) | Yes, after `Retry-After` |
| `503` | `WARMING` | The search endpoint is starting up | Yes, after `Retry-After` |
| `503` | `UPSTREAM_TIMEOUT` | Our backend did not answer in time; `retryable: true` | Yes, after `Retry-After` |
| `503` | `CREDIT_SYSTEM_UNAVAILABLE` | Credit checks are briefly unavailable, so the call was refused before it ran; `retryable: true` | Yes, after `Retry-After` |
| `503` | `REVIEWS_WITHHELD` | Reviews: Google sent the place without its reviews; `reviewCount` is what it reports; `retryable: true` | Yes |
| `503` | `SERVICE_UNAVAILABLE` | The service cannot take the call right now; see `retryable` | If `retryable` |
| `503` | `UPSTREAM_FAILED` | A search failed and a retry will not help; `retryable: false` | No |

The rate-limit `429`s carry a numeric `code` (`4201`, `4202`, `4203`); every other `code` is a string. Errors are JSON on every status, a `5xx` included: we answer `503` rather than `502` or `504`, which the network in front of us would replace with its own HTML page. Every retryable `503` carries `Retry-After`.

### Idempotency keys

Send `X-Idempotency-Key` (or `Idempotency-Key`), up to 255 characters, to make a retry of a paid call safe. Within an hour of the first use:

- the same key with the same request returns the stored response, marked `Idempotent-Replayed: true`, without running or charging again;
- the same key with a different request is `422` `IDEMPOTENCY_KEY_REUSED`, not charged;
- the same key while the first request is still running is `409` `IDEMPOTENCY_REQUEST_IN_PROGRESS`: retry shortly (`Retry-After: 2`).

A request that failed is not kept, so you can retry it with the same key. A key longer than 255 characters is `400` `INVALID_IDEMPOTENCY_KEY`. Async tasks take their own `idempotencyKey` in the body ([Async Tasks](#async-tasks)).

### Refunds

A call that fails is not charged: any response with status `400` or above, or with `success: false`, releases the credits it reserved. An answer with no results (an empty `organic`, `news`, `videos`, `images`, `places`, `reviews` or `shopping` list) is not charged either, and the body's `credits` always equals the `X-Credits-Deducted` header. In `POST /search/batch` and `POST /serp/batch` a failed or empty row costs nothing; a row inside a `200` `POST /rankings/batch` is billed like the rest of that batch (read each row's `success` and `retryable`). A failed [async task](#async-tasks) is refunded.

### Retrying

Retry only what says it can succeed later: `429`s, and `5xx` responses with `retryable: true`. Wait `Retry-After` when it is present, and back off otherwise (for example 2, 5, then 10 seconds). Because failures are refunded, a retry never bills twice.

## Code Examples

### JavaScript

```javascript
// Retries what says it can succeed later, waiting Retry-After when given.
// Failed calls are refunded, so a retry never bills twice.
async function searlo(path, params = {}, { retries = 3 } = {}) {
  const url = new URL(`https://api.searlo.tech/api/v1${path}`);
  Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));

  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { "x-api-key": process.env.SEARLO_API_KEY } });
    const body = await res.json();
    if (res.ok && body.success !== false) return body;

    const retryable = res.status === 429 || (res.status >= 500 && body.retryable !== false);
    if (!retryable || attempt >= retries) {
      const err = new Error(body.message || `HTTP ${res.status}`);
      err.status = res.status;
      err.code = body.code; // e.g. CITY_LOCATION_UNAVAILABLE, INSUFFICIENT_CREDITS
      throw err;
    }
    const wait = Number(res.headers.get("retry-after")) || [2, 5, 10][attempt] || 10;
    await new Promise(r => setTimeout(r, wait * 1000));
  }
}
```

### Python

```python
import os
import time
import requests

class SearloError(Exception):
    def __init__(self, status, code, message):
        super().__init__(message)
        self.status, self.code = status, code

def searlo(path, params=None, retries=3):
    url = f"https://api.searlo.tech/api/v1{path}"
    headers = {"x-api-key": os.environ["SEARLO_API_KEY"]}
    for attempt in range(retries + 1):
        r = requests.get(url, params=params, headers=headers, timeout=120)
        body = r.json()
        if r.ok and body.get("success") is not False:
            return body
        retryable = r.status_code == 429 or (r.status_code >= 500 and body.get("retryable") is not False)
        if not retryable or attempt == retries:
            raise SearloError(r.status_code, body.get("code"), body.get("message", "request failed"))
        time.sleep(float(r.headers.get("Retry-After") or [2, 5, 10][min(attempt, 2)]))
```


---

<a id="mcp-server"></a>

# MCP Server

Connect AI agents like Claude, Cursor, VS Code Copilot, and Windsurf to Searlo via Model Context Protocol (MCP).

### Endpoint

```
https://api.searlo.tech/api/v1/mcp
```

### Transport

Streamable HTTP — the latest MCP specification. No stdio, no Docker, no local bridge needed.

### Authentication

Use your API key in the `X-API-Key` header. The same key works for both MCP and the REST API.

### Server Info

`GET /mcp/info` (no key needed) returns the server's name, version and transport, and the tools it exposes with their credit costs. Read it rather than hard-coding the list below.

### Available Tools

| Tool | Credits | Description |
|------|---------|-------------|
| `web_search` | 1 | Google web search: organic results, knowledge panel, featured snippet |
| `news_search` | 1 | Google News articles with publisher and date |
| `image_search` | 1 | Google Images: image URL, thumbnail, dimensions, format and source page |
| `shopping_search` | 1 | Google Shopping listings: price, merchant, shipping, rating |
| `maps_search` | 1 | Google Maps places: rating, reviews, address, phone, hours, booking link |
| `scholar_search` | 1 | Google Scholar works, with citation counts and the ids to walk the citation graph |
| `ai_answer` | 4–8 | An AI answer engine's answer and the sources it cited: chatgpt 8 (default), gemini 4, copilot 8 |
| `scrape` | 1 | One page as markdown, text, html, links, images, headings, tables or JSON-LD |
| `site_map` | 1 | The URLs a site publishes, from robots.txt and its sitemaps |
| `search_and_scrape` | 1 + 1 per page read | Web search, then the top results read as markdown |
| `check_balance` | 0 | Current credit balance and low-balance status |
| `get_usage_stats` | 0 | Credit usage for a period |
| `get_search_history` | 0 | Your past searches |
| `get_search_stats` | 0 | Search analytics: timeline, top queries, type distribution |
| `get_transaction_history` | 0 | Credit transactions |
| `list_api_keys` | 0 | Your API keys, masked |

A failed tool call is not charged. `toon` output (the default on the search tools) is a compact tabular format that takes about 63% fewer tokens than the same results as JSON.

### Tool Parameters

- `web_search`: `query` (required, 1–2048 chars), `num` (1–10, default 10), `page` (default 1), `gl` (2-letter country), `lr` (results language, e.g. `en`), `safe` (`off`, `medium`, `high`), `format` (`toon`, `json` or `enhanced`)
- `news_search`: `query` (required), `num` (1–10), `page` (1–100), `gl`, `hl`, `dateRestrict` (`d7` = last 7 days; also `w`, `m`, `y`), `format` (`toon` or `json`)
- `image_search`: `query` (required), `num` (1–10), `page` (1–100), `size` (`icon` to `huge`), `imgType` (`photo`, `clipart`, `lineart`, `face`, `stock`, `animated`), `imgColorType` (`color`, `gray`, `mono`, `trans`), `gl`, `hl`, `format`
- `shopping_search`: `query` (required, 1–500 chars), `num` (1–60, default 55), `gl`, `hl`, `format`
- `maps_search`: `query` (required, 1–500 chars), `num` (1–20, default 20), `page`, `gl`, `hl`, `ll` (Google's `@lat,lng,zoomz` form, e.g. `@30.27,-97.74,13z`) or `lat` / `lng` / `zoom` (1–21), `format`
- `scholar_search`: `query` (required, 1–500 chars), `num` (1–20, default 10), `page`, `hl`, `yearFrom`, `yearTo`, `sortByDate`, `includePatents`, `includeCitations`, `cluster` (all versions of a work), `cites` (works citing it), `format`
- `ai_answer`: `query` (required, 1–500 chars), `engine` (`chatgpt`, the default, `copilot` or `gemini`; `perplexity` is paused, and `aimode` and `grok` are switched off), `cache` (default true)
- `scrape`: `url` (required), `formats` (up to 8 of `markdown`, `text`, `html`, `links`, `images`, `headings`, `tables`, `json_ld`; default `["markdown"]`), `onlyMainContent` (default true), `render`, `includeLinks`, `includeImages`, `excludeSelectors` (up to 20), `waitForSelector`, `timeout` (1000–60000 ms), `cache` (default true), `maxChars` (500–200000)
- `site_map`: `url` (required), `limit` (1–5000, default 1000), `search` (keep URLs containing this), `includeSubdomains`, `sitemapOnly`, `cache`
- `search_and_scrape`: `query` (required, 1–500 chars), `limit` (results to read, 1–20, default 5), `scrape` (`false` returns the search only, for 1 credit), `formats` (`markdown`, `text`, `html`, `json_ld`), `onlyMainContent`, `render`, `gl`, `hl`, `cache`, `maxChars`
- `get_usage_stats`: `days` (1–365, default 30)
- `get_search_history`: `limit` (1–10, default 10), `page` (default 1), `searchType` (`web`, `image` or `news`)
- `get_search_stats`: `startDate`, `endDate` (ISO dates), `groupBy` (`hour`, `day`, `week` or `month`; default `day`)
- `get_transaction_history`: `limit` (1–10, default 10), `page` (default 1), `type` (`deduction`, `grant`, `refund` or `purchase`)
- `check_balance`, `list_api_keys`: no parameters

### Quick Setup

Paste the config into your AI client's MCP settings file and replace `sk_your_api_key` with your key.

## Code Examples

### Claude Desktop

```json
{
  "mcpServers": {
    "searlo": {
      "url": "https://api.searlo.tech/api/v1/mcp",
      "headers": {
        "X-API-Key": "sk_your_api_key"
      }
    }
  }
}
```

### Cursor

```json
{
  "mcpServers": {
    "searlo": {
      "url": "https://api.searlo.tech/api/v1/mcp",
      "headers": {
        "X-API-Key": "sk_your_api_key"
      }
    }
  }
}
```

### VS Code

```json
{
  "servers": {
    "searlo": {
      "type": "http",
      "url": "https://api.searlo.tech/api/v1/mcp",
      "headers": {
        "X-API-Key": "sk_your_api_key"
      }
    }
  }
}
```

### Windsurf

```json
{
  "mcpServers": {
    "searlo": {
      "serverUrl": "https://api.searlo.tech/api/v1/mcp",
      "headers": {
        "X-API-Key": "sk_your_api_key"
      }
    }
  }
}
```


---

<a id="changelog"></a>

# Changelog

Changes to the API's behaviour and contract, newest first. Additive fields are listed too, so a strict client knows what to expect.

### 2026-10-20

- **Pricing may change during November–December 2026.** What it costs us to serve some requests changes at the turn of the year, so we may adjust prices in those two months. Any change is announced in advance here and by email. Credits you have already bought keep working, and packs stay valid for 12 months from purchase.
- **Full SERP: rendered AI Overview costs 8 from 2026-11-01.** `GET /serp`, `GET /search/serp`, `POST /serp/batch` and async `serp` tasks cost 3 credits per page as today, or 8 when the AI Overview had to be completed by rendering the page in a browser. The response says `aioverview.rendered: true` when that happened (live now, so you can see which of your calls it affects). A page whose overview arrives complete, or a request with `aioverview=false`, stays 3. `GET /serp/features` stays 3 and never renders; `GET /serp/html` stays 2 (8 with `js=true`).
- **Web data: a page that needed a browser costs 5 from 2026-11-01.** On `POST /scrape`, `GET|POST /search/webpage`, `POST /extract`, `POST /search-and-scrape`, `POST /crawl` and `POST /scrape/batch`, a page that had to be rendered (`render: true`, `actions`, or automatic rendering when a plain fetch returns no usable content, such as a JavaScript-only page) costs 5 credits instead of 1. Pages fetched without a browser stay 1. Responses already say `via: "browser"` or `via: "http"`. A cached copy costs the same as the page it copies, a failed fetch is never charged, and the live-fetch surcharge (`maxAge` or `cache: false`) still applies on top. A crawl's `maxCredits` ceiling allows for the browser price unless the job sets `render: false`. `POST /map` is unchanged.
- **Accounts that have never bought credits.** Every endpoint works on the sign-up credits, but the automatic browser fallback is off (an explicit `render: true` or `actions` still renders) and a web search that comes back thin is not topped up with live results. Both switch on with the first purchase. In effect now.

### 2026-10-15

- **Place details menus from Google Search.** Google Maps now shows signed-out visitors a reduced place record without the menu, so `menu` came back `null`. For a place that serves food, `GET /search/place-details` reads the menu (`url`, `sections` of items with prices) from the place's Google Search panel. Adds `place.menuStatus` (`found`, `none`, `pending`, `unavailable`) and the parameter `menu=false` to skip the lookup. `menu.photos` is empty for now. Same price.

### 2026-10-04 to 2026-10-05

- **Credit balance.** `GET /credits/balance` returns `{ success, credits: { available, reserved?, expiresAt?, expiringCredits? } }` for an API key or a dashboard login. Free. `available` is the balance `GET /user/profile` shows ([Account & Utilities](#account)).
- **API keys.** A key is for the data API and read-only views of its account. Creating or deleting keys, data export, account deletion and payments need a dashboard login (`403` `API_KEY_SCOPE_FORBIDDEN`); `GET /user/profile` stays open to keys. Keys are stored hashed, and lists show a masked preview.
- **Errors.** One table of every status and code ([Error Handling](#errors)). New: `400` `PAGE_OUT_OF_RANGE`, `DATE_FILTER_UNAVAILABLE` and `INVALID_IDEMPOTENCY_KEY`, `422` `TARGET_UNREACHABLE`, `429` `4203` (`CONCURRENCY_LIMIT`), `503` `CREDIT_SYSTEM_UNAVAILABLE` and `REVIEWS_WITHHELD`. A switched-off endpoint answers `403` `FEATURE_NOT_AVAILABLE` (was `503`); a site that refuses the fetch answers `422` `TARGET_REFUSED` (was `503`). No `502` or `504` leaves the API: both are answered as `503` JSON. None of these is charged.
- **Idempotency keys** are bound to the request they came with: the same key and request replays the stored response (`Idempotent-Replayed: true`, no charge), the same key with a different request is `422` `IDEMPOTENCY_KEY_REUSED`.
- **SafeSearch on the SERP.** `safe=active` or `off` (and `true` or `false`) is accepted on `GET /serp` and `GET /search/serp`. `POST /serp/batch` and `GET /serp/features` honour `safe`, which they ignored before.
- **Date filter.** `dateRestrict` (`d7`, `w2`, `m1`, `y1`) works on `GET /search/web` together with `gl` and `hl`, on `GET /search/news` and on `GET /search/advanced`. Combined with search operators it returns `400` `DATE_FILTER_UNAVAILABLE`, not charged.
- **Fresh answers.** `cache=false` forces a fresh answer on web, image, news, maps and places search. Same credits.
- **Web search pages.** A page that starts past result 101 returns `400` `PAGE_OUT_OF_RANGE` with `maxPage` at once, instead of a slow failure.
- **Image search.** Results carry `width`, `height`, `thumbnailUrl` with its size, `link` and `sourceUrl` (the page the image is on), `source`, `domain`, `format` and `imageId`. `size` (also `imgSize`), `imgType`, `imgColorType` and `imgDominantColor` now take effect.
- **Maps, places and place details.** New `businessStatus` (`OPERATIONAL`, `CLOSED_TEMPORARILY`, `CLOSED_PERMANENTLY` or `null`) and `openNow`. `openState` comes back in the language of `hl`. With no `ll`, `gl` sets where the search looks (51 countries). A `lite` answer is never cached.
- **Reviews.** The first page of reviews (about eight) comes back in any language, with `hasMore`. New `timestamp` (Unix seconds); an owner's reply carries `date` and `language`. A place Google sent without its reviews answers `503` `REVIEWS_WITHHELD`, not charged.
- **Extract.** URLs are fetched in parallel inside one time budget. A failed row carries `errorCode` and `retryable`; `partial` says the budget ran out before every URL was fetched; `llmSkipped` says the model was not asked. One credit per page fetched; failed rows are free.
- **Web data.** `scrape`, `map`, `crawl`, `scrape/batch` and `extract` accept internationalised domain names (`https://www.löschwagen.com`). A malformed URL, or one with a control character, is a `400`.
- **Searches in flight.** An account can have a limited number of web, image, site, advanced and batch searches running at once; past it, `429` `4203` ([Rate Limiting](#rate-limiting)).
- **No charge for nothing.** An answer with no results is free on every endpoint, SERP pages are billed per page delivered, and a failed row inside a `200` `POST /serp/batch` costs nothing.

### 2026-10-02

- **Place details.** `GET /search/place-details` returns one place's full record by `placeId` (`ChIJ…`), `featureId`, `cid` or `q`: every maps field plus all of Google's attributes and the menu (`url`, `sections` of items with prices, `photos`). 1 credit.
- **Attributes on maps and places.** `GET /search/maps` and `GET /search/places` add `attributes`, Google's "About" list keyed by Google's own ids (`attributes.payments.requires_cash_only`).
- **Reviews by Place ID.** `GET /search/reviews` accepts `placeId`.
- **Opening date on maps and places.** `GET /search/maps` and `GET /search/places` add `openingDate` (`2026-11-01`) and `openingDateText` (`Opens 1 Nov`, in your `hl`) to a place listed before it opens; `null` otherwise. Same price.
- **SERP HTML price.** `GET /serp/html` costs 2 credits per page (was 1), and 8 per page with `js=true` (was 4). Failed calls are still refunded.
- **Full SERP AI Overview from the served page.** `GET /serp` reads the AI Overview in full from the page as Google served it, so most overviews no longer wait for a rendered page: about 2 s instead of 8-10 s. An overview Google loads after the page comes back as `pending: true` and is still read from the rendered page. Overview sources come back resolved (`url` the cited page, `redirectUrl` the `/goto`, `resolved: true`). Same price.

### 2026-09-30

- **Credits are valid for one year per grant** (was 90 days) on accounts created after the policy launch; existing credits were moved to one year from when they were granted. `GET /credit/balance` reports `expiry.durationDays: 365`. Legacy accounts keep unlimited credits.
- **Google News results page from the live page.** `GET /search/news/serp` (and the `news/serp` async task and the Cloro-compatible news monitor) is fetched live and parsed like Full SERP: about a second per page, cards with title, source, date, snippet, thumbnail and resolved article links.
- **Location.** `location` takes a country by name or two-letter code on every Google endpoint and applies it as `gl`; a country's `uule` is applied the same way. City- and state-level `location`, and any other `uule`, return `400` `CITY_LOCATION_UNAVAILABLE` and are not charged: Google no longer localizes results by `uule` ([Location](#full-serp)). `GET /serp/html` accepts a country `location`. `GET /search/states` adds `targeting: false`.
- **Error codes.** Error bodies carry a stable `code` on search, SERP, SERP HTML, AI answer, rank check and validation errors ([Error Handling](#errors)). Failed batch rows carry the same `code`.
- **Async tasks.** Errors in the request itself (a location, a domain, a query shape) fail at once and are refunded, instead of being retried.
- **Full SERP.** Each page is fetched live and parsed; the AI Overview is read in full from the rendered page, and `aioverview.complete` says whether it was. `aiModeAvailable` reports the page's AI Mode tab. A multi-page request with a failed page is no longer cached.
- **SERP HTML.** `aio.showAll` (`pressed`, `absent`, `complete`, `failed`) and `show-all` in `X-Searlo-Aio`.
- **Web data.** `POST /search/webpage` accepts `excludeSelectors`; a page whose plain fetch returns no usable content is rendered.

### 2026-09-29

- **SERP HTML** (`GET /serp/html`): Google's results page as served (1 credit at launch; 2 since 2026-10-02) or rendered with the AI Overview opened out (`js=true`, 4 credits at launch; 8 since 2026-10-02), desktop or iOS, as JSON or `format=raw`.
- **AI answer snapshots:** `html=true` on `GET /search/ai/{engine}` adds the answer page's HTML (`html`, `pageUrl`) at no extra charge.
- **Full SERP:** `rawHtml=true` returns the page alongside the JSON (+1 credit).
- **Account & Utilities:** `GET /credit/balance`, `GET /search/capabilities`, `GET /search/countries`, `GET /search/states` documented.
- **MCP:** all 16 tools documented.

---


*Generated from https://searlo.tech/docs — Last updated: 2026-10-29*