Search

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:

Code
- 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, Node.js, PHP, LangChain, Open WebUI, n8n and WordPress. Google's dates and the full parameter table are on the migration page.

Endpoint#

Code
GET https://api.searlo.tech/customsearch/v1GET https://api.searlo.tech/customsearch/v1/siterestrictGET 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 bills one: 1 credit per call, whatever num (1 to 10) and start are. A call with searchType=image is billed as Image Search: 1 credit. A call that returns an error is not charged.

Parameters#

ParameterStatusNotes
qSupportedRequired. Up to 500 characters
keySupportedYour Searlo API key
cxAccepted, no effectAn 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
numSupported1 to 10 (default 10)
startSupported1-based. As on Google, the last result can be at most the 100th: start=91&num=10 is the last full page
glSupportedCountry, two letters
crSupportedOne country, as countryXX. Read as gl
hlSupportedInterface language
lrSupportedOne language, as lang_xx. Read as the request language
safeSupportedactive or off
dateRestrictSupportedd[n], w[n], m[n] or y[n]. Combining it with operator parameters (siteSearch, fileType, orTerms…) or with image search returns 400
siteSearch, siteSearchFilterSupportedsite: (i, the default) or -site: (e)
exactTerms, excludeTerms, orTerms, fileType, hq, lowRange, highRangeSupportedApplied as the search operators Google applies
searchTypeSupportedimage for image search
imgSize, imgType, imgColorType, imgDominantColorSupportedImage filters, with Google's values
fields, prettyPrint, alt=jsonSupportedPartial responses and pretty-printed JSON
googlehost, quotaUser, userIpAccepted, no effectListed in the X-Searlo-Ignored-Params header, with cx
filter, c2coff, sortDefault onlyfilter=1, c2coff=0 and an empty sort are accepted. Any other value returns 400
linkSite, relatedSite, rights, callbackNot supportedReturns 400 with the reason
location, uule and other city or point locationsNot supportedCountry level only (gl, cr). Returns 400
Any other parameterNot supportedReturns 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": "…" }]  }}
StatusreasonWhen
400badRequest, invalid, requiredA parameter is missing or invalid
400keyInvalidThe key is not a valid Searlo API key (this includes a Google AIza… key)
400unsupportedParameter, cityLocationUnavailableA parameter Searlo does not take (see above)
403forbiddenNo key, or the account is suspended or has not confirmed its email
403dailyLimitExceededThe account is out of credits. The message links to the top-up
429rateLimitExceededThe rate limit, or too many searches in flight. Wait for Retry-After
503backendErrorSearch is temporarily unavailable. Wait for Retry-After
500backendErrorInternal 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.

Examples

# Before: Googlecurl "https://www.googleapis.com/customsearch/v1?key=$GOOGLE_API_KEY&cx=$CX&q=lectures&num=10" # After: Searlo. Only the host and the key changecurl "https://api.searlo.tech/customsearch/v1?key=$SEARLO_API_KEY&cx=$CX&q=lectures&num=10" # The same call with the key in a headercurl "https://api.searlo.tech/customsearch/v1?q=lectures&num=10" \  -H "x-api-key: $SEARLO_API_KEY" # Image searchcurl "https://api.searlo.tech/customsearch/v1?key=$SEARLO_API_KEY&q=red+panda&searchType=image&imgSize=large"
import osimport requests API_URL = "https://api.searlo.tech/customsearch/v1"  # was https://www.googleapis.com/customsearch/v1API_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
const API_URL = "https://api.searlo.tech/customsearch/v1"; // was https://www.googleapis.com/customsearch/v1const 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

Stuck? Write to [email protected], or check live status.