// field guide 004
Replace the Google Custom Search JSON API before January 1, 2027
Google ends the Custom Search JSON API on January 1, 2027. A parameter map, a response map and a drop-in Python adapter move your calls to Litescrape at $0.15 per 1,000.
Google has closed the Custom Search JSON API to new customers and ends it on January 1, 2027. This page maps every cse.list() parameter and response field to Litescrape's Google Search API, plus one Python file to import in place of the Google client. Litescrape charges $0.15 per 1,000 requests; the JSON API charges $5 per 1,000.
01 / notice
The JSON API ends January 1, 2027
The Custom Search JSON API overview carries this notice, as read on September 14, 2026:
The Custom Search JSON API is closed to new customers. Vertex AI Search is a favorable alternative for searching up to 50 domains. Alternatively, if your use case necessitates full web search, contact us to express your interest in and get more information about our full web search solution. Existing Custom Search JSON API customers have until January 1, 2027 to transition to an alternative solution.
The pricing section on the same page now reads: "The following pricing applies only to existing Custom Search JSON API customers until the service discontinuation on January 1, 2027." The rate is unchanged: "Custom Search JSON API provides 100 search queries per day for free. If you need more, you may sign up for billing in the API Console. Additional requests cost $5 per 1000 queries, up to 10k queries per day."
Vertex AI Search covers up to 50 domains that you list. An engine set to "Search the entire web" has no replacement there. Litescrape returns the live Google results page for any query as JSON.
02 / outcome
One file replaces the Google client
custom_search_migration.py exports customsearch_list(). It takes the keyword arguments of service.cse().list(), calls https://api.litescrape.com/api/google/search with your key, and returns the JSON API's Search shape. Code that reads result["items"] does not change.
from googleapiclient.discovery import build service = build("customsearch", "v1", developerKey=GOOGLE_API_KEY)result = ( service.cse() .list(q="coffee grinders", cx=ENGINE_ID, num=5, gl="us", hl="en") .execute())for item in result["items"]: print(item["title"], item["link"]) Over raw HTTP, the key moves from the query string to an Authorization header. The four query parameters stay the same.
GET https://customsearch.googleapis.com/customsearch/v1 ?key=GOOGLE_API_KEY&cx=ENGINE_ID &q=coffee%20grinders&num=5&gl=us&hl=enInstall httpx and set your key.
python -m pip install httpxexport LITESCRAPE_API_KEY=ls_live_... # from https://litescrape.com/#demo03 / parameters
Request mapping
build_params() implements this table. The names on the right come from the Litescrape API reference.
| Custom Search | Litescrape | Notes |
|---|---|---|
q | q | Passed through. Up to 2,048 characters. |
num | num | The JSON API allows 1 to 10. Litescrape accepts 1 to 100 as a row-count hint; Google may return fewer. |
start | start | The JSON API counts from 1. Google and Litescrape use a zero-based offset, so the adapter sends start - 1. |
gl | gl | Lowercased two-letter country code. |
hl | hl | Passed through. |
lr | lr | Same lang_xx values; join several with |. |
cr | cr | Same countryXX values; join several with |. |
safe | safe | active or off on both sides. |
siteSearch | as_sitesearch | Hostname only, up to 253 characters. |
siteSearchFilter | as_dt | i includes, e excludes. The adapter sends i when you omit it. |
dateRestrict | as_qdr | Same encoding: d7, w2, m6, y1. |
exactTerms | as_epq | Passed through. |
excludeTerms | as_eq | Passed through. |
orTerms | as_oq | Passed through. |
filter | filter | 0 or 1, same meaning. |
googlehost | google_domain | Deprecated on Google's side; google.de maps to a validated Google domain. |
hq | query text | Appended to q, which is what the JSON API did internally. |
fileType | query text | No parameter. The adapter appends the filetype:pdf operator to q. |
lowRange, highRange | query text | Appended as the low..high range operator. |
cx, key | none | Accepted and dropped. Authentication is the bearer header. |
c2coff | none | Accepted and dropped; there is no Chinese simplification switch. |
searchType=image and imgSize, imgType, imgColorType, imgDominantColor | none | Google Images is not a supported vertical. The API rejects tbm=isch with 400 unsupported_search_vertical; the adapter raises ValueError. |
sort, rights, linkSite | none | A plain Google results page has no sort expression, licensing filter, or link restriction. The adapter raises ValueError. |
Two Litescrape parameters have no Custom Search ancestor. tbs takes any Google search-filter string. fast_mode=true returns organic results only.
04 / response
Response mapping
to_custom_search() implements this table. The JSON API returns one Search resource; Litescrape returns search_metadata, search_parameters and one group per module Google served.
| Custom Search field | Litescrape source | Notes |
|---|---|---|
items[] | organic_results[] | One entry per organic row, in page order. |
items[].title | organic_results[].title | Passed through. |
items[].link | organic_results[].link | A resolved destination URL. Google's redirect tokens are unwrapped server-side. |
items[].snippet | organic_results[].snippet | Passed through when Google served one. |
items[].displayLink | hostname of link | The JSON API shows the host. Litescrape's displayed_link is Google's cite line, so the adapter derives the host from the URL. |
items[].formattedUrl | organic_results[].displayed_link | Google's cite text, such as clivecoffee.com › collections › grinders. |
searchInformation.totalResults | search_information.total_results | A string, as in the JSON API. Empty when Google shows no count. |
searchInformation.searchTime | search_information.time_taken_displayed | Google's own displayed time, not the round trip. |
queries.request[] | request parameters | startIndex, count, searchTerms, gl, hl, safe. |
queries.nextPage[] | pagination.next | Present when Google rendered a next link; startIndex is start + num. |
queries.previousPage[] | request start | Present when start is greater than 1. |
pagemap, htmlSnippet, htmlTitle, cacheId | none | Index-only fields. Absent from the adapter's items. |
The adapter adds a litescrape key holding search_metadata: the request ID, timestamps and the Google URL fetched. Include the request ID when you email [email protected].
05 / complete file
The adapter
Save this as custom_search_migration.py next to your code. It holds the parameter translation, the HTTP call, the response reshaping and a command line for spot checks.
"""Drop-in replacement for the Google Custom Search JSON API, served by Litescrape.
export LITESCRAPE_API_KEY=ls_live_...
python custom_search_migration.py "coffee grinders" --num 5 --gl us --hl en
customsearch_list() accepts the keyword arguments of customsearch.cse.list()
and returns a dictionary in the JSON API's Search shape: kind, queries,
searchInformation, and items. Only httpx and the standard library are used.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from typing import Any
from urllib.parse import urlsplit
import httpx
LITESCRAPE_SEARCH_URL = "https://api.litescrape.com/api/google/search"
DEFAULT_TIMEOUT = 60.0
ENGINE_TITLE = "Litescrape Google Search"
# Custom Search parameters with no Litescrape equivalent. Image search is
# rejected by the API as an unsupported vertical, and licensing filters, sort
# expressions, and link-based restrictions do not exist on a plain Google SERP.
UNSUPPORTED = {
"searchType",
"imgSize",
"imgType",
"imgColorType",
"imgDominantColor",
"sort",
"rights",
"linkSite",
"relatedSite",
}
# Accepted so existing call sites keep working, then dropped. The engine ID
# and API key belong to the Programmable Search Engine, and the Chinese
# simplification switch has no counterpart.
IGNORED = {"cx", "key", "c2coff", "alt", "fields", "prettyPrint", "quotaUser"}
# Custom Search name on the left, Litescrape name on the right.
RENAMED = {
"gl": "gl",
"hl": "hl",
"lr": "lr",
"cr": "cr",
"safe": "safe",
"filter": "filter",
"siteSearch": "as_sitesearch",
"siteSearchFilter": "as_dt",
"dateRestrict": "as_qdr",
"exactTerms": "as_epq",
"excludeTerms": "as_eq",
"orTerms": "as_oq",
"googlehost": "google_domain",
}
# Folded into the query string as Google search operators.
QUERY_OPERATORS = {"q", "hq", "fileType", "lowRange", "highRange"}
PAGING = {"num", "start"}
def build_params(**cse: Any) -> dict[str, str]:
"""Translate customsearch.cse.list() arguments into Litescrape query parameters."""
supplied = {name: value for name, value in cse.items() if value is not None and value != ""}
unsupported = sorted(name for name in supplied if name in UNSUPPORTED)
if unsupported:
raise ValueError(f"No Litescrape equivalent for: {', '.join(unsupported)}")
known = UNSUPPORTED | IGNORED | set(RENAMED) | QUERY_OPERATORS | PAGING
unknown = sorted(name for name in supplied if name not in known)
if unknown:
raise ValueError(f"Unknown Custom Search parameter(s): {', '.join(unknown)}")
query = str(supplied.get("q", "")).strip()
if not query:
raise ValueError("q is required")
terms = [query]
if "hq" in supplied:
terms.append(str(supplied["hq"]).strip())
if "fileType" in supplied:
terms.append(f"filetype:{str(supplied['fileType']).strip().lstrip('.')}")
if "lowRange" in supplied and "highRange" in supplied:
terms.append(f"{supplied['lowRange']}..{supplied['highRange']}")
params: dict[str, str] = {"q": " ".join(terms)}
num = int(supplied.get("num", 10))
if not 1 <= num <= 100:
raise ValueError("num must be between 1 and 100")
params["num"] = str(num)
# Custom Search counts results from 1; Google and Litescrape use a zero-based offset.
start = int(supplied.get("start", 1))
if start < 1:
raise ValueError("start must be 1 or greater")
if start > 1:
params["start"] = str(start - 1)
for cse_name, litescrape_name in RENAMED.items():
if cse_name in supplied:
params[litescrape_name] = str(supplied[cse_name]).strip()
if "gl" in params:
params["gl"] = params["gl"].lower()
if "as_sitesearch" in params and "as_dt" not in params:
params["as_dt"] = "i"
return params
def to_custom_search(data: dict[str, Any], params: dict[str, str], start: int) -> dict[str, Any]:
"""Reshape a Litescrape Google Search response into the JSON API's Search resource."""
organic = data.get("organic_results") or []
info = data.get("search_information") or {}
total = info.get("total_results")
count = int(params["num"])
def page(start_index: int) -> dict[str, Any]:
return {
"title": ENGINE_TITLE,
"totalResults": "" if total is None else str(total),
"searchTerms": params["q"],
"count": count,
"startIndex": start_index,
"inputEncoding": "utf8",
"outputEncoding": "utf8",
"safe": params.get("safe", "off"),
"gl": params.get("gl", ""),
"hl": params.get("hl", ""),
}
queries: dict[str, Any] = {"request": [page(start)]}
if start > 1:
queries["previousPage"] = [page(max(1, start - count))]
if (data.get("pagination") or {}).get("next"):
queries["nextPage"] = [page(start + count)]
items = []
for row in organic:
link = str(row.get("link", ""))
items.append(
{
"kind": "customsearch#result",
"title": row.get("title", ""),
"link": link,
"displayLink": urlsplit(link).hostname or "",
"snippet": row.get("snippet", ""),
"formattedUrl": row.get("displayed_link", ""),
}
)
search_time = info.get("time_taken_displayed")
return {
"kind": "customsearch#search",
"context": {"title": ENGINE_TITLE},
"queries": queries,
"searchInformation": {
"searchTime": search_time,
"formattedSearchTime": "" if search_time is None else f"{search_time:.2f}",
"totalResults": "" if total is None else str(total),
"formattedTotalResults": "" if total is None else f"{total:,}",
},
"items": items,
"litescrape": {"search_metadata": data.get("search_metadata", {})},
}
def customsearch_list(
*,
api_key: str | None = None,
timeout: float = DEFAULT_TIMEOUT,
**cse: Any,
) -> dict[str, Any]:
"""Run one Custom Search style query against Litescrape and return the JSON API shape."""
key = api_key or os.environ.get("LITESCRAPE_API_KEY", "")
if not key:
raise RuntimeError("Set LITESCRAPE_API_KEY or pass api_key=...")
params = build_params(**cse)
response = httpx.get(
LITESCRAPE_SEARCH_URL,
params=params,
headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
timeout=timeout,
)
if response.status_code != 200:
try:
envelope = response.json()
except ValueError:
envelope = {"error": response.text[:200]}
raise RuntimeError(
f"Litescrape returned HTTP {response.status_code}: "
f"{envelope.get('error_code', '')} {envelope.get('error', '')} "
f"(request_id={envelope.get('request_id', '')})".strip()
)
start = int(cse.get("start") or 1)
return to_custom_search(response.json(), params, start)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("q", help="search terms, exactly as you passed them to cse.list()")
parser.add_argument("--num", type=int, default=10, help="results per page, 1 to 100")
parser.add_argument("--start", type=int, default=1, help="1-based index of the first result")
parser.add_argument("--gl", help="two-letter country code")
parser.add_argument("--hl", help="interface language, for example en")
parser.add_argument("--site", dest="siteSearch", help="site to include or exclude")
parser.add_argument("--site-filter", dest="siteSearchFilter", choices=["i", "e"])
parser.add_argument("--date", dest="dateRestrict", help="d7, w2, m6, y1")
parser.add_argument("--filetype", dest="fileType", help="pdf, docx, ...")
args = parser.parse_args(argv)
try:
result = customsearch_list(**vars(args))
except (ValueError, RuntimeError) as error:
print(f"error: {error}", file=sys.stderr)
return 1
json.dump(result, sys.stdout, indent=2, ensure_ascii=False)
print()
return 0
if __name__ == "__main__":
raise SystemExit(main()) A non-200 response raises RuntimeError with Litescrape's error_code, message and request_id. A failed request is refunded automatically. A 503 service_unavailable is retryable: retry after a short wait, or use the SDK in section nine, which retries for you.
06 / first run
Run it
The command asks for five results for "coffee grinders" in the United States, in English, and prints the reshaped JSON.
python custom_search_migration.py "coffee grinders" --num 5 --gl us --hl enCompare these with your old responses before switching traffic:
items[].linkis the destination URL your code stores.displayLinkis a bare host.queries.nextPageappears only when there is a next page.
07 / limits
Paging and limits
| Limit | Custom Search JSON API | Litescrape |
|---|---|---|
| Results per page | num from 1 to 10 | num from 1 to 100, as a hint. Google decides the row count. |
| Results per query | 100. start + num above 100 is an error. | Any start offset. |
| First result index | 1 | 0. The adapter sends start - 1. |
| Queries per day | 10,000 | No cap. |
| Concurrent requests | Not published | 25 per key. Email [email protected] to raise it. |
Read len(items) instead of assuming a full page. queries.nextPage is set from Litescrape's pagination.next, which exists only when Google rendered a next link.
08 / cost
$0.15 per 1,000 against $5 per 1,000
Google's rate is the published one for existing customers. Its first 100 queries a day stay free until the shutdown.
| Queries per day | Custom Search JSON API | Litescrape |
|---|---|---|
| 100 | $0.00 | $0.015 |
| 1,000 | $4.50 | $0.15 |
| 10,000 (the JSON API ceiling) | $49.50 | $1.50 |
| 10,000 for 30 days | $1,485.00 | $45.00 |
A new Litescrape key has 10 free calls. Top up once through Stripe from $10, which buys 66,667 requests; credits last six months from each top-up. Every endpoint costs one call, and a failed request is refunded.
09 / other paths
Two other paths
The Python SDK sends the same request with retries, concurrency at your key's limit and results in input order. It returns Litescrape's native shape, so read organic_results instead of items.
# pip install litescrape-sdkfrom litescrape_sdk import GoogleSearch, scrape results = scrape( [GoogleSearch(q="coffee grinders", num=5, gl="us", hl="en")], api_key="ls_live_...",)for row in results[0].raise_for_error()["organic_results"]: print(row["title"], row["link"]) In n8n, install n8n-nodes-litescrape and pick the Google Search operation. Its Query field is q. The Country, Language, Result Count, Result Offset, Site Search, Site Search Mode and Date Range options cover gl, hl, num, start, siteSearch, siteSearchFilter and dateRestrict.
10 / questions
FAQ
- Is the Google Custom Search JSON API shutting down?
- Yes. Google has closed it to new customers and gives existing customers until January 1, 2027 to move; it names Vertex AI Search, which covers up to 50 domains, as the replacement.
- Can a new project still get a Custom Search JSON API key?
- No. Google states the API is not available for new customers.
- Does Litescrape need a Programmable Search Engine ID?
- No. A request carries a bearer API key and a query. The adapter accepts cx and key and drops them.
- Which Custom Search parameters have no equivalent?
- searchType=image and the image filters, sort, rights and linkSite. The adapter raises ValueError for them.
- Does the adapter return pagemap, htmlSnippet or cacheId?
- No. Each item has title, link, displayLink, snippet and formattedUrl.
- How much does it cost to run?
- $0.15 per 1,000 requests on every Litescrape endpoint. The JSON API charges $5 per 1,000 after 100 free queries a day, up to 10,000 a day.
