Google Custom Search API Dies Jan 1, 2027: Drop-In Replacement Code in Node, Python and Go

By Serpent API Team · · 12 min read

The deadline is real and it's dated: Google's own announcement says your transition off the Custom Search JSON API "needs to be completed by January 1, 2027," and the developer docs already mark the API closed to new customers. It's the second CSE-family shutdown in two years — the Site Restricted variant stopped serving on January 8, 2025.

This post is deliberately narrow: just the code. If you're still weighing options — Vertex AI Search, the Programmable Search Element, SERP APIs — read our full migration guide first. If you've chosen a SERP API and want your integration moved with the least rewrite possible, everything below was written for you, and every snippet was executed against the live API on July 21, 2026.

TL;DR: Don't rewrite the code that consumes items[] — replace the transport under it. Below are drop-in cseList() shims for Node, Python and Go that call a SERP API and return CSE-shaped JSON: queries, searchInformation, items[] with title/link/snippet/displayLink. Notably, Google's own Vertex migration doc contains no field mapping at all — so we built one.

The request mapping (every parameter)

CSE calls go to customsearch.googleapis.com/customsearch/v1 with the key and engine ID in the query string. The replacement is one authenticated GET to /api/search:

The full parameter table, from the official cse.list reference to the Serpent parameters:

CSE parameterReplacementNotes
keyX-API-Key headerkey leaves the URL (and your logs)
cxno engine object to create or manage
qqunchanged
num (1–10)num (1–100)10× the per-call ceiling
startslice client-side (shim does it)see pagination
glcountry2-letter code, same values
hl / lrlanguage2-letter ISO code
safe (active/off)safe (strict/moderate/off)activestrict
dateRestrict (d/w/m/y[n])freshness (h/d/w/m/y)multipliers approximate to the nearest window (d3w)
siteSearch + siteSearchFiltersite: / -site: in qverified live: a site:stackoverflow.com query returned 7/7 results from that domain
exactTerms / excludeTerms / orTerms"…" / -term / (a OR b) in qstandard operators
searchType=image + imgSize/imgType/imgColorType/api/images + size/type/colorsee image search
fileType, lowRange..highRangefiletype:, a..b operators in qoperator support varies by engine — test your queries
c2coff, cr, googlehost, rights, filter, sortno direct equivalentgrep your code before switching; googlehost is deprecated in CSE itself

The response mapping — and what doesn't map

The fields your code most likely reads, from the official Search resource to the live response (shape verified July 21, 2026):

CSE fieldSerpent field
items[].titleresults.organic[].title
items[].linkresults.organic[].url
items[].snippetresults.organic[].snippet
items[].displayLinkresults.organic[].displayedUrl
(implicit rank = array order)results.organic[].position — explicit
searchInformation.totalResultsmetadata.totalOrganicResults
queries.nextPage[]shim emits it while any start-based caller remains

Semantics change: CSE's totalResults was Google's index-size estimate (often millions); the replacement reports how many results the call actually returned. If your UI prints "About 1,240,000 results", that string dies in the migration — an honest loss worth knowing up front.

Also without a 1:1 equivalent: pagemap (CSE's raw structured-data dump), cacheId, promotions (your hand-configured engine promotions), and the spelling object. In exchange, the full (non-simple) response carries SERP features CSE never had — People Also Ask, featured snippet, AI Overview, video and shopping blocks — documented in the API reference and visible live in the playground. Grep your codebase for the four dead fields before cutover; if nothing matches, the shim below is a complete swap.

Node: the drop-in shim

Zero dependencies (native fetch, Node 18+). Executed against the live API before publishing — output follows the code:

// serpent-cse-compat.mjs — drop-in replacement for the Custom Search JSON API
const BASE = 'https://apiserpent.com/api/search';
const API_KEY = process.env.SERPENT_API_KEY;

const SAFE = { active: 'strict', off: 'off' };

// CSE dateRestrict (d3, w2, m6, y1…) → nearest freshness window
function mapDateRestrict(dr) {
  if (!dr) return null;
  const unit = dr[0].toLowerCase();
  const n = parseInt(dr.slice(1) || '1', 10);
  if (unit === 'd') return n <= 1 ? 'd' : n <= 7 ? 'w' : 'm';
  if (unit === 'w') return n <= 1 ? 'w' : 'm';
  if (unit === 'm') return n <= 1 ? 'm' : 'y';
  return 'y';
}

export async function cseList({
  q, num = 10, start = 1, gl, hl, safe, dateRestrict,
  siteSearch, siteSearchFilter, exactTerms, excludeTerms, orTerms,
}) {
  // CSE's dedicated params become standard search operators in q
  let query = q;
  if (exactTerms) query += ` "${exactTerms}"`;
  if (excludeTerms) query += ' ' + excludeTerms.split(/\s+/).map(t => `-${t}`).join(' ');
  if (orTerms) query += ` (${orTerms.split(/\s+/).join(' OR ')})`;
  if (siteSearch) query += ` ${siteSearchFilter === 'e' ? '-' : ''}site:${siteSearch}`;

  const params = new URLSearchParams({ q: query, engine: 'google', num: String(Math.min(start - 1 + num, 100)) });
  if (gl) params.set('country', gl);
  if (hl) params.set('language', hl);
  if (safe && SAFE[safe]) params.set('safe', SAFE[safe]);
  const freshness = mapDateRestrict(dateRestrict);
  if (freshness) params.set('freshness', freshness);

  const t0 = Date.now();
  const res = await fetch(`${BASE}?${params}`, { headers: { 'X-API-Key': API_KEY } });
  if (!res.ok) throw new Error(`Serpent API error ${res.status}: ${await res.text()}`);
  const data = await res.json();

  // CSE returned 10 per page from `start`; we fetch once and slice
  const organic = data.results.organic;
  const pageItems = organic.slice(start - 1, start - 1 + num);

  const items = pageItems.map(r => ({
    kind: 'customsearch#result',
    title: r.title,
    htmlTitle: r.title,
    link: r.url,
    displayLink: r.displayedUrl || new URL(r.url).hostname,
    snippet: r.snippet || '',
    htmlSnippet: r.snippet || '',
    formattedUrl: r.url,
  }));

  return {
    kind: 'customsearch#search',
    queries: {
      request: [{ searchTerms: q, count: items.length, startIndex: start }],
      ...(start - 1 + num < organic.length
        ? { nextPage: [{ searchTerms: q, count: num, startIndex: start + num }] } : {}),
    },
    searchInformation: {
      // count of results actually returned — not Google's index-size estimate
      totalResults: String(data.metadata.totalOrganicResults),
      searchTime: (Date.now() - t0) / 1000,
      formattedTotalResults: String(data.metadata.totalOrganicResults),
      formattedSearchTime: ((Date.now() - t0) / 1000).toFixed(2),
    },
    items,
  };
}

Your existing consumer code doesn't change. Our test run — a siteSearch query, exactly what a recruiting or dev-tools integration would send:

const data = await cseList({ q: 'python decorators', siteSearch: 'stackoverflow.com', num: 5, gl: 'us' });
for (const item of data.items) console.log('-', item.title, '|', item.displayLink);

// Output (live run, Jul 21 2026):
// - python - What is the purpose of decorators (why use them…) | Stack Overflow
// - class - Python decorators in classes - Stack Overflow      | Stack Overflow
// - Python decorator? - can someone please explain this?       | Stack Overflow
// - python - How do I make function decorators and chain them… | Stack Overflow
// - python - Decorators with parameters? - Stack Overflow      | Stack Overflow

Python: the drop-in shim

If you call CSE through googleapiclient.discovery.build("customsearch", "v1"), this replaces the service.cse().list(…).execute() call — same keyword arguments, same returned dict shape:

# serpent_cse_compat.py — drop-in replacement for the Custom Search JSON API
import os
import time
import requests

BASE = "https://apiserpent.com/api/search"
API_KEY = os.environ["SERPENT_API_KEY"]

_SAFE = {"active": "strict", "off": "off"}


def _map_date_restrict(dr):
    """CSE dateRestrict (d3, w2, m6, y1...) -> nearest freshness window."""
    if not dr:
        return None
    unit, n = dr[0].lower(), int(dr[1:] or 1)
    if unit == "d":
        return "d" if n <= 1 else ("w" if n <= 7 else "m")
    if unit == "w":
        return "w" if n <= 1 else "m"
    if unit == "m":
        return "m" if n <= 1 else "y"
    return "y"


def cse_list(q, num=10, start=1, gl=None, hl=None, safe=None, dateRestrict=None,
             siteSearch=None, siteSearchFilter=None, exactTerms=None,
             excludeTerms=None, orTerms=None):
    query = q
    if exactTerms:
        query += f' "{exactTerms}"'
    if excludeTerms:
        query += " " + " ".join(f"-{t}" for t in excludeTerms.split())
    if orTerms:
        query += " (" + " OR ".join(orTerms.split()) + ")"
    if siteSearch:
        prefix = "-" if siteSearchFilter == "e" else ""
        query += f" {prefix}site:{siteSearch}"

    params = {"q": query, "engine": "google", "num": min(start - 1 + num, 100)}
    if gl:
        params["country"] = gl
    if hl:
        params["language"] = hl
    if safe in _SAFE:
        params["safe"] = _SAFE[safe]
    freshness = _map_date_restrict(dateRestrict)
    if freshness:
        params["freshness"] = freshness

    t0 = time.time()
    resp = requests.get(BASE, params=params, headers={"X-API-Key": API_KEY}, timeout=90)
    resp.raise_for_status()
    data = resp.json()

    organic = data["results"]["organic"]
    page_items = organic[start - 1:start - 1 + num]

    items = [{
        "kind": "customsearch#result",
        "title": r["title"],
        "htmlTitle": r["title"],
        "link": r["url"],
        "displayLink": r.get("displayedUrl") or r["url"].split("/")[2],
        "snippet": r.get("snippet") or "",
        "htmlSnippet": r.get("snippet") or "",
        "formattedUrl": r["url"],
    } for r in page_items]

    elapsed = time.time() - t0
    out = {
        "kind": "customsearch#search",
        "queries": {"request": [{"searchTerms": q, "count": len(items), "startIndex": start}]},
        "searchInformation": {
            "totalResults": str(data["metadata"]["totalOrganicResults"]),
            "searchTime": elapsed,
            "formattedTotalResults": str(data["metadata"]["totalOrganicResults"]),
            "formattedSearchTime": f"{elapsed:.2f}",
        },
        "items": items,
    }
    if start - 1 + num < len(organic):
        out["queries"]["nextPage"] = [{"searchTerms": q, "count": num, "startIndex": start + num}]
    return out

Live run, exercising the dateRestrict mapping (y1freshness=y):

data = cse_list("javascript fetch api", num=5, gl="us", dateRestrict="y1")
# - Using the Fetch API - Web APIs | MDN          | Mozilla Developer
# - JavaScript Fetch API - W3Schools              | W3School
# - Fetch API in JavaScript - GeeksforGeeks       | GeeksForGeeks
# - Fetch API - Web APIs | MDN                    | Mozilla Developer
# - JavaScript Fetch API                          | JavaScript Tutorial

More Python patterns for the non-compat route (calling the API natively) are in our Python guide.

Go: the drop-in shim

The google.golang.org/api/customsearch/v1 client is in maintenance mode (its own package page says new features are off the table). This standalone file replaces it — typed structs matching what your CSE code unmarshals today. Compiled and run in a golang:1.23 container against the live API:

// serpent_cse_compat.go — drop-in replacement for the Custom Search JSON API (Go 1.21+)
type CseItem struct {
    Kind         string `json:"kind"`
    Title        string `json:"title"`
    HTMLTitle    string `json:"htmlTitle"`
    Link         string `json:"link"`
    DisplayLink  string `json:"displayLink"`
    Snippet      string `json:"snippet"`
    HTMLSnippet  string `json:"htmlSnippet"`
    FormattedURL string `json:"formattedUrl"`
}

type CseResponse struct {
    Kind    string `json:"kind"`
    Queries struct {
        Request  []CseQuery `json:"request"`
        NextPage []CseQuery `json:"nextPage,omitempty"`
    } `json:"queries"`
    SearchInformation struct {
        TotalResults          string  `json:"totalResults"`
        SearchTime            float64 `json:"searchTime"`
        FormattedTotalResults string  `json:"formattedTotalResults"`
        FormattedSearchTime   string  `json:"formattedSearchTime"`
    } `json:"searchInformation"`
    Items []CseItem `json:"items"`
}

func CseList(p CseParams) (*CseResponse, error) {
    if p.Num == 0 { p.Num = 10 }
    if p.Start == 0 { p.Start = 1 }

    query := p.Q
    if p.ExactTerms != "" { query += fmt.Sprintf(" %q", p.ExactTerms) }
    for _, t := range strings.Fields(p.ExcludeTerms) { query += " -" + t }
    if p.SiteSearch != "" {
        prefix := ""
        if p.SiteSearchFilter == "e" { prefix = "-" }
        query += " " + prefix + "site:" + p.SiteSearch
    }

    q := url.Values{}
    q.Set("q", query)
    q.Set("engine", "google")
    q.Set("num", strconv.Itoa(min(p.Start-1+p.Num, 100)))
    if p.GL != "" { q.Set("country", p.GL) }
    if p.HL != "" { q.Set("language", p.HL) }
    if p.Safe == "active" { q.Set("safe", "strict") }
    if f := mapDateRestrict(p.DateRestrict); f != "" { q.Set("freshness", f) }

    req, _ := http.NewRequest("GET", "https://apiserpent.com/api/search?"+q.Encode(), nil)
    req.Header.Set("X-API-Key", os.Getenv("SERPENT_API_KEY"))

    resp, err := (&http.Client{Timeout: 90 * time.Second}).Do(req)
    if err != nil { return nil, err }
    defer resp.Body.Close()
    // decode, slice organic[start-1 : start-1+num], fill CseResponse —
    // same mapDateRestrict + slice-and-fill logic as the Node/Python shims above
}

Live run output, same shape your Go code expects:

$ docker run --rm -v "$PWD":/app -w /app -e SERPENT_API_KEY golang:1.23 go run serpent_cse_compat.go
totalResults: 7 | searchTime: 13.78s
- Tutorial: Getting started with generics - The Go Programming | The Go Programming Language
- Go by Example: Generics                                      | Go by Example
- Golang Generics Explained (Functions, Structs, JSON & Real…  | GoLinuxCloud
- Go Generics Tutorial: A Complete Introduction to Type…       | Marc Nuri
- Generics in Golang - GeeksforGeeks                           | GeeksForGeeks

Go-specific API patterns beyond the compat layer live in the Go tutorial.

Pagination: delete the loop when you're ready

CSE capped num at 10, so every integration grew a start=1, 11, 21… loop (and paid one query per page, to a hard stop around result 100). The replacement returns up to 100 results in one call — the shims fetch once, slice to your requested page, and emit queries.nextPage so existing loop code still terminates correctly.

When you're ready, delete the loop: call cseList({ q, num: 100 }) once. One request replaces ten. If the engine returns fewer than requested, the response says so honestly — meta.partialResults carries a returned count and a reason instead of padding.

Image search migration

CSE's searchType=image becomes the dedicated /api/images endpoint. The filter parameters map directly, and the response fields cover CSE's image object (shape verified live, July 21, 2026):

CSE image field/param/api/images equivalent
imgSizesize (small/medium/large/wallpaper)
imgTypetype (photo/clipart/lineart/animated/face)
imgColorType/imgDominantColorcolor
items[].link (full image)original
image.thumbnailLinkthumbnail
image.contextLink (hosting page)pageUrl
image.width / image.heightwidth / height

A note on scope: this post migrates the web and image search surface. If your CSE was a ≤50-domain site-search box rendered on a page, Google's Programmable Search Element continues past the API shutdown — that trade-off is covered in the migration guide, along with why "just use Vertex AI Search" is not the drop-in it sounds like. And if part of your stack also leaned on Bing's API, which died in August 2025, the same shim pattern applies. For a wider comparison of free tiers to test with, see our free-tier test and the broader alternatives guide; the Google SERP API page documents the underlying product.

Migrate before the deadline, not during it

Serpent is a pay-as-you-go SERP API — structured Google, Bing, Yahoo and DuckDuckGo results, up to 100 per call, with the SERP features CSE never returned. 10 free searches, then from $0.60 per 1,000 down to $0.03 at Scale. No subscription.

Get Your Free API Key

Explore: Google SERP API · Documentation · Playground

FAQ

When exactly does the Custom Search JSON API shut down?

January 1, 2027 — Google's Programmable Search Engine blog post of January 20, 2026 sets the completion deadline, and the developer docs already mark the API closed to new customers. The Site Restricted variant died earlier, on January 8, 2025.

Can I keep my existing CSE parsing code after migrating?

Yes — that's the point of the shims. They return the same queries / searchInformation / items[] shape, so code reading title, link, snippet or displayLink keeps working while the transport changes underneath.

What CSE fields have no direct equivalent?

pagemap, cacheId, promotions and spelling. And totalResults changes meaning — returned count, not Google's index estimate. Grep for those five before cutover.

Do search operators really replace siteSearch and exactTerms?

Yes — verified live: a site:stackoverflow.com query returned 7 of 7 results from that domain. exactTerms becomes a quoted phrase, excludeTerms becomes -term, orTerms becomes (a OR b).

Is there a free tier to test the migration?

Yes — free API calls on signup, no card, and the playground runs the exact request from this post in the browser. CSE's own 100 free queries/day keep working until the shutdown, so run both side by side during cutover.