Quick start
Three steps from an empty account to your first live result.
- Create an account and a token in API tokens. The full token is shown once, so store it in your secret manager.
- Your first 100 searches are free. Add credit by card or in USDT or USDC on Balance when you need more; the price and a cost calculator are on the pricing page.
- Send the request below with your token. The response is the current Google result set for that market.
curl -X POST https://api.serper.live/v1/search \
-H "Authorization: Bearer sl_live_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"best coffee shops","country":"AE"}'
const response = await fetch('https://api.serper.live/v1/search', {
method: 'POST',
headers: {
Authorization: 'Bearer sl_live_YOUR_TOKEN',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: 'best coffee shops', country: 'AE' }),
signal: AbortSignal.timeout(100000),
});
const result = await response.json();
console.log(result.organic);
import requests
response = requests.post(
"https://api.serper.live/v1/search",
headers={"Authorization": "Bearer sl_live_YOUR_TOKEN"},
json={"query": "best coffee shops", "country": "AE"},
timeout=100,
)
print(response.json()["organic"])
<?php
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer sl_live_YOUR_TOKEN\r\nContent-Type: application/json",
'content' => json_encode(['query' => 'best coffee shops', 'country' => 'AE']),
'timeout' => 100,
]]);
$result = json_decode(file_get_contents('https://api.serper.live/v1/search', false, $context), true);
print_r($result['organic']);
A live search usually returns in 2–3 seconds; the first request to a market after a pause takes longer while a fresh Google session opens there. Keep a client timeout of at least 100 seconds so a slow first request never fails on your side.
Authentication
Every request to /v1/search carries your token in the Authorization header:
Authorization: Bearer sl_live_YOUR_TOKEN
- Tokens start with
sl_live_and are created in API tokens. Create one token per application so you can revoke it without touching the others. - Keep tokens on your server. Never ship them in a browser, a mobile app or a public repository.
- A revoked token stops working immediately; the history of its requests stays in your account.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | unauthorized |
The Authorization header is missing. |
| 401 | invalid_token |
The token is malformed, revoked or unknown. |
Search
POST https://api.serper.live/v1/search
Runs one live Google search and returns the organic results. Request and response are JSON; the request body is limited to 16 KB.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
query |
string | yes | 2–120 characters. Whitespace is collapsed; control characters are rejected. |
country |
string | yes | Two-letter ISO code of the market, for example AE. Must be one of the codes from GET /v1/countries. |
language |
string | no | Google interface language (hl), for example tr, en or pt-BR. Defaults to the language of the country. |
device |
string | no | desktop (default) or mobile. Changes the browser persona and therefore the result layout. |
limit |
integer | no | Number of organic results, 1–100. Default 10. Every started block of 10 reserves one price: limit 25 reserves three, and only blocks that received results are charged. |
Response
| Field | Description |
|---|---|
requestId |
UUID of this request. It appears in your request history; quote it when you report a problem. |
query, country, language, device |
The normalised values that were actually used. |
fetchedAt |
ISO 8601 timestamp of the live fetch. |
latencyMs |
How long the live search took, in milliseconds. |
pages |
Blocks of 10 results that received results: never more than you asked for, 0 when Google found nothing. |
freePages |
How many of those blocks were covered by the free searches of your account: they are used before your balance. |
price, currency |
What this request cost, as a number in USD: the market price multiplied by the paid blocks (pages minus freePages). 0 when nothing was found or the free searches covered the request. |
organic[] |
Organic results in ranking order: position, title, url, domain and snippet. snippet is null when Google shows no description. |
peopleAlsoAsk[] |
Questions from Google's "People also ask" block, as plain strings in page order; an empty array when Google shows none. |
relatedSearches[] |
Related searches Google suggests under the results ("People also search for"), as plain strings without duplicates; an empty array when there are none. |
{
"requestId": "0199860b-2f4e-7a1c-9b3d-5e6f70819a2b",
"query": "best coffee shops",
"country": "AE",
"language": "en",
"device": "desktop",
"fetchedAt": "2026-09-28T14:00:00.000Z",
"latencyMs": 1873,
"pages": 1,
"freePages": 0,
"price": 0.005,
"currency": "USD",
"organic": [
{
"position": 1,
"title": "The 12 best coffee shops in town",
"url": "https://example.com/guides/best-coffee-shops",
"domain": "example.com",
"snippet": "A curated list of specialty coffee shops, updated every month."
},
{
"position": 2,
"title": "Coffee shops near me",
"url": "https://maps.example.org/coffee",
"domain": "maps.example.org",
"snippet": null
}
],
"peopleAlsoAsk": [
"What is the best coffee shop in town?",
"How much does a specialty coffee cost?"
],
"relatedSearches": [
"best coffee shops near me",
"specialty coffee roasters",
"coffee shop with wifi"
]
}
limit is reached, without Google's own navigation entries and without duplicates.Organic results: the organic[] fields
Each element of organic[] is one organic result, in the order Google shows them. Google's own navigation entries and duplicates are left out, and results are collected across result pages until limit is reached.
The fields are the same for desktop and mobile and for every market:
| Field | Type | Description |
|---|---|---|
position |
integer | The rank among organic results, from 1 up to limit (at most 100), counted continuously across Google's result pages. It counts organic entries only, so it is the number a rank tracker stores. |
title |
string | The result title as shown on the page. |
url |
string | The full address of the result. |
domain |
string | The host part of url, for example maps.example.org. Match this field when you look for a site. |
snippet |
string or null | The description shown under the title; null when Google shows none. |
Countries
GET https://api.serper.live/v1/countries
Public, no token needed. Returns the markets that are open right now and the default Google language of each; every market supports both desktop and mobile.
curl https://api.serper.live/v1/countries
const response = await fetch('https://api.serper.live/v1/countries');
const { countries } = await response.json();
console.log(countries);
import requests
response = requests.get("https://api.serper.live/v1/countries", timeout=10)
print(response.json()["countries"])
<?php
$result = json_decode(file_get_contents('https://api.serper.live/v1/countries'), true);
print_r($result['countries']);
{
"countries": [
{
"code": "AE",
"defaultLanguage": "ar"
},
{
"code": "AR",
"defaultLanguage": "es"
},
{
"code": "AT",
"defaultLanguage": "de"
},
{
"code": "AU",
"defaultLanguage": "en"
},
{
"code": "AZ",
"defaultLanguage": "az"
},
{
"code": "BE",
"defaultLanguage": "nl"
},
{
"code": "BG",
"defaultLanguage": "bg"
},
{
"code": "BR",
"defaultLanguage": "pt"
},
{
"code": "CA",
"defaultLanguage": "en"
},
{
"code": "CH",
"defaultLanguage": "de"
},
{
"code": "CL",
"defaultLanguage": "es"
},
{
"code": "CO",
"defaultLanguage": "es"
},
{
"code": "CZ",
"defaultLanguage": "cs"
},
{
"code": "DE",
"defaultLanguage": "de"
},
{
"code": "DK",
"defaultLanguage": "da"
},
{
"code": "EG",
"defaultLanguage": "ar"
},
{
"code": "ES",
"defaultLanguage": "es"
},
{
"code": "FI",
"defaultLanguage": "fi"
},
{
"code": "FR",
"defaultLanguage": "fr"
},
{
"code": "GB",
"defaultLanguage": "en"
},
{
"code": "GR",
"defaultLanguage": "el"
},
{
"code": "HU",
"defaultLanguage": "hu"
},
{
"code": "ID",
"defaultLanguage": "id"
},
{
"code": "IE",
"defaultLanguage": "en"
},
{
"code": "IN",
"defaultLanguage": "hi"
},
{
"code": "IT",
"defaultLanguage": "it"
},
{
"code": "JP",
"defaultLanguage": "ja"
},
{
"code": "KR",
"defaultLanguage": "ko"
},
{
"code": "KZ",
"defaultLanguage": "ru"
},
{
"code": "MX",
"defaultLanguage": "es"
},
{
"code": "NL",
"defaultLanguage": "nl"
},
{
"code": "NO",
"defaultLanguage": "nb"
},
{
"code": "PE",
"defaultLanguage": "es"
},
{
"code": "PL",
"defaultLanguage": "pl"
},
{
"code": "PT",
"defaultLanguage": "pt"
},
{
"code": "RO",
"defaultLanguage": "ro"
},
{
"code": "SA",
"defaultLanguage": "ar"
},
{
"code": "SE",
"defaultLanguage": "sv"
},
{
"code": "TH",
"defaultLanguage": "th"
},
{
"code": "TR",
"defaultLanguage": "tr"
},
{
"code": "UA",
"defaultLanguage": "uk"
},
{
"code": "US",
"defaultLanguage": "en"
},
{
"code": "VN",
"defaultLanguage": "vi"
},
{
"code": "ZA",
"defaultLanguage": "en"
}
]
}
New markets appear in this response and on the pricing page as they launch. A request for a closed market returns 422 country_unavailable together with the list of available codes.
Billing
Your account holds a prepaid balance in USD and starts with 100 free searches. Before the search starts, each request reserves the current price of its market for every started block of 10 results: limit 10 or less reserves one price, limit 25 reserves three. Free searches cover the first blocks of a request before any money is reserved.
- Successful search: the price is charged for every block of 10 that received results, and the rest of the reservation returns to your balance at once.
limit50 with 12 results costs two prices, not five; a search that finds nothing costs nothing. - Free searches: every new account starts with 100 of them, one per block of 10 results, on any market and device. A request uses them before your balance, and blocks that received no results go back to the free pool the same way money goes back to the balance;
freePagesin the response says how many blocks a request took from them. - Failed search, that is a
408,424or5xxresponse: the reservation is released automatically. You are never charged for a result you did not receive. - Prices can change over time, but the price reserved for a running request never does.
- Not enough credit:
402 insufficient_balance. Top up on Balance; the credit is available within minutes of the deposit being confirmed.
Every request, its status and its cost are listed in Request history; every balance change is in the ledger on the balance page.
Errors
Errors are JSON with a machine-readable error code, a human-readable message and, once a request has been created, its requestId:
{
"error": "insufficient_balance",
"message": "The balance does not cover one request",
"requestId": "0199860b-2f4e-7a1c-9b3d-5e6f70819a2b"
}
| HTTP | Code | Meaning | Retry? |
|---|---|---|---|
| 400 |
invalid_json
|
The body is not valid JSON. | Fix the request |
| 401 |
unauthorized
invalid_token
|
See Authentication. | Fix the token |
| 402 |
insufficient_balance
|
The balance does not cover one request. | After a top-up |
| 408 |
search_timeout
|
The search did not finish within 90 seconds. | Yes |
| 409 |
idempotency_in_progress
idempotency_replay_unavailable
|
See Safe retries. | Depends on the code |
| 413 |
payload_too_large
|
The body exceeds 16 KB. | Fix the request |
| 422 |
invalid_request
country_unavailable
|
A field failed validation (the message names it), or the market is not open. | Fix the request |
| 424 |
proxy_unavailable
captcha_failed
search_failed
|
The live search failed on our side. | Yes, after a short pause |
| 503 |
busy
billing_unavailable
configuration_error
|
The service is at capacity or temporarily unavailable. | Yes, with backoff |
408, 424 or 5xx response is ever charged. Retry 408, 424 and 503 with exponential backoff, for example after 5, 15 and 45 seconds, rather than in a tight loop, and with a new Idempotency-Key: the same key replays the same error for up to 15 minutes instead of starting a new search.Safe retries
Optional. You only need this if your client may retry a request that was cut off.
A live search runs for up to 90 seconds. If your connection drops in the meantime, you cannot tell whether the search finished and was charged. Sending an Idempotency-Key header lets you retry without paying twice:
- Same key: if the first search finished, you get the same response again with the header
Idempotent-Replayed: trueand no second charge; a search that failed replays the same error. If it is still running, the retry waits for it. Reuse the key only when you did not receive the outcome, after a dropped connection or a client timeout; after a408,424or5xxsend a new key. - The key alone identifies the search: a retry with a different body gets the first request’s response, so send a new search with a new key.
- Results are kept for up to 15 minutes. After that a retry with the old key returns
409 idempotency_replay_unavailable; send a new request with a new key. - Without the header every request is new and billed separately.
curl -X POST https://api.serper.live/v1/search \
-H "Authorization: Bearer sl_live_YOUR_TOKEN" \
-H "Idempotency-Key: 0199860b-2f4e-7a1c-9b3d-5e6f70819a2b" \
-H "Content-Type: application/json" \
-d '{"query":"best coffee shops","country":"AE","limit":10}'
const response = await fetch('https://api.serper.live/v1/search', {
method: 'POST',
headers: {
Authorization: 'Bearer sl_live_YOUR_TOKEN',
'Idempotency-Key': '0199860b-2f4e-7a1c-9b3d-5e6f70819a2b',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: 'best coffee shops', country: 'AE', limit: 10 }),
signal: AbortSignal.timeout(100000),
});
const result = await response.json();
import requests
response = requests.post(
"https://api.serper.live/v1/search",
headers={
"Authorization": "Bearer sl_live_YOUR_TOKEN",
"Idempotency-Key": "0199860b-2f4e-7a1c-9b3d-5e6f70819a2b",
},
json={"query": "best coffee shops", "country": "AE", "limit": 10},
timeout=100,
)
result = response.json()
<?php
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer sl_live_YOUR_TOKEN\r\nIdempotency-Key: 0199860b-2f4e-7a1c-9b3d-5e6f70819a2b\r\nContent-Type: application/json",
'content' => json_encode(['query' => 'best coffee shops', 'country' => 'AE', 'limit' => 10]),
'timeout' => 100,
]]);
$result = json_decode(file_get_contents('https://api.serper.live/v1/search', false, $context), true);
The key is any string of 8–128 characters from A-Za-z0-9._:-. A fresh UUID per logical request is the simplest choice; keep it identical across the retries of that request.
Limits and good to know
query2–120 characters,limit1–100, request body up to 16 KB.- Searches run concurrently up to the service capacity; beyond it you get
503 busy. Retry with backoff. - Usually 2–3 seconds for 10 results and about a second more per further page; a search still running after 90 seconds ends with
408 search_timeout. Set your client timeout to at least 100 seconds. - Every response carries an
X-Request-Idheader. Keep it in your logs next to therequestIdfrom the body. - Nothing is cached: two identical requests are two live searches and two charges. Cache on your side if you reuse results.
Finding a domain's position
The API returns the organic results up to limit, the top 10 by default; finding one site in it is a loop over organic[] in your code. Compare the domain of each entry with the host you track, and the position of the first match is the rank for that keyword, country and device. The example below does exactly that, with the same request in curl, JavaScript and Python.
Three rules keep the comparison honest:
- Normalise before comparing: lower-case both sides, strip a leading
www.and compare hosts only, never full URLs. - Decide how subdomains count.
blog.example.comis a different host fromexample.com; match the exact host for a strict tracker, or treat any host ending in.example.comas the same site for a brand-level one. - No match means outside the requested range for this check, not an error. Store it as such: a request returns at most
limitorganic results, so raiselimit(up to 100, one price per 10) if you track positions beyond the top 10.
curl -s -X POST https://api.serper.live/v1/search \
-H "Authorization: Bearer sl_live_YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"best coffee shops","country":"AE","device":"desktop"}' \
| jq '.organic[] | select(.domain == "example.com") | .position'
const response = await fetch('https://api.serper.live/v1/search', {
method: 'POST',
headers: {
Authorization: 'Bearer sl_live_YOUR_TOKEN',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: 'best coffee shops', country: 'AE', device: 'desktop' }),
signal: AbortSignal.timeout(100000),
});
const { organic } = await response.json();
const hit = organic.find(row => row.domain === 'example.com');
console.log(hit ? hit.position : 'outside the top 10');
import requests
response = requests.post(
"https://api.serper.live/v1/search",
headers={"Authorization": "Bearer sl_live_YOUR_TOKEN"},
json={"query": "best coffee shops", "country": "AE", "device": "desktop"},
timeout=100,
)
organic = response.json()["organic"]
position = next((row["position"] for row in organic if row["domain"] == "example.com"), None)
print(position or "outside the top 10")
<?php
$context = stream_context_create(['http' => [
'method' => 'POST',
'header' => "Authorization: Bearer sl_live_YOUR_TOKEN\r\nContent-Type: application/json",
'content' => json_encode(['query' => 'best coffee shops', 'country' => 'AE', 'device' => 'desktop']),
'timeout' => 100,
]]);
$result = json_decode(file_get_contents('https://api.serper.live/v1/search', false, $context), true);
$hit = array_find($result['organic'], fn(array $row) => $row['domain'] === 'example.com');
echo $hit['position'] ?? 'outside the top 10';
The rank tracking API page covers the full cycle of a tracker: keyword lists, scheduling, storage and what a month of checks costs.
Use with AI agents (MCP)
The Serper.live MCP server gives AI agents such as Claude, OpenAI Codex, Cursor and VS Code Copilot live Google search through this API. It runs locally with npx, needs Node.js 20 or newer and authenticates with the same API token in the SERPER_LIVE_TOKEN environment variable; searches are billed exactly like POST /v1/search.
google_search: live organic results for a keyword, country, language and device, up to 100 per call.domain_position: where a domain ranks for a keyword; a subdomain counts as the domain, and every position is returned if the site appears more than once.list_countries: the open markets and the default Google language of each; free, no token.
claude mcp add serper-live -e SERPER_LIVE_TOKEN=sl_live_YOUR_TOKEN -- npx -y @serper-live/mcp
{
"mcpServers": {
"serper-live": {
"command": "npx",
"args": [
"-y",
"@serper-live/mcp"
],
"env": {
"SERPER_LIVE_TOKEN": "sl_live_YOUR_TOKEN"
}
}
}
}
{
"inputs": [
{
"type": "promptString",
"id": "serper-live-token",
"description": "Serper.live API token",
"password": true
}
],
"servers": {
"serper-live": {
"type": "stdio",
"command": "npx",
"args": [
"-y",
"@serper-live/mcp"
],
"env": {
"SERPER_LIVE_TOKEN": "${input:serper-live-token}"
}
}
}
}
[mcp_servers.serper-live]
command = "npx"
args = ["-y", "@serper-live/mcp"]
[mcp_servers.serper-live.env]
SERPER_LIVE_TOKEN = "sl_live_YOUR_TOKEN"
The package is @serper-live/mcp on npm and io.github.serper-live/serper-live-mcp in the official MCP Registry; the README in the repository covers setup for more clients. Claude Desktop also installs it in one click from the .mcpb bundle and asks for the token itself, without Node.js.