# Web Search API

A single endpoint that returns Google search results as clean JSON. Send a query,
get back ranked results with titles, URLs and snippets.

Base URL (local): `http://127.0.0.1:8787`

| Endpoint | Purpose |
| --- | --- |
| `GET\|POST /v1/search` | Run a search |
| `GET /v1/stats` | Current capacity and remaining quota |
| `GET /docs` | These docs, as Markdown |
| `GET /health` | Liveness probe |

---

## Authentication

Send your API key with every request to `/v1/search` and `/v1/stats`:

```
X-API-Key: your_key_here
```

or, if a header is inconvenient, as a query parameter:

```
?api_key=your_key_here
```

`/health` and `/docs` need no key.

A missing or wrong key returns HTTP `401`:

```json
{ "ok": false, "error": { "code": "UNAUTHORISED", "message": "send your key as the X-API-Key header" } }
```

---

## `GET|POST /v1/search`

| Parameter | Type | Default | Notes |
| --- | --- | --- | --- |
| `q` | string | **required** | The search query. Max 400 characters. |
| `gl` | 2-letter country code | `us` | Country to search from. **Always send this** — see *Geography*. |
| `hl` | language code | `en` | Interface language, e.g. `en`, `fr`, `de`. |
| `page` | integer 1–10 | `1` | Result page. ~10 results per page. |

Both methods take the same parameters — query string for `GET`, JSON body for `POST`.

```bash
curl "http://127.0.0.1:8787/v1/search?q=best+pizza+nyc&gl=us&hl=en" \
  -H "X-API-Key: $KEY"

curl -X POST http://127.0.0.1:8787/v1/search \
  -H "X-API-Key: $KEY" \
  -H 'content-type: application/json' \
  -d '{"q":"python asyncio event loop","gl":"us","hl":"en","page":1}'
```

### Response

```json
{
  "ok": true,
  "result": {
    "query": "python asyncio event loop",
    "page": 1,
    "gl": "us",
    "hl": "en",
    "results": [
      {
        "title": "A Conceptual Overview of asyncio",
        "url": "https://docs.python.org/3/howto/a-conceptual-overview-of-asyncio.html",
        "snippet": "The event loop takes a job from its backlog of work and invokes it...",
        "source": "Python documentation",
        "position": 1
      }
    ],
    "peopleAlsoAsk": [
      { "question": "How does the asyncio event loop work?", "snippet": "...", "url": "..." }
    ],
    "relatedSearches": ["python asyncio event loop example"],
    "totalResults": 123,
    "resolvedCountry": "US",
    "tookMs": 2140
  }
}
```

| Field | Notes |
| --- | --- |
| `results[].position` | **Absolute across pages** — page 2 starts at 11, not at 1. |
| `results[].source` | Publisher name when available, otherwise `null`. |
| `results[].snippet` | May be `null` for results Google renders without one. |
| `peopleAlsoAsk[]` | `snippet` and `url` may be `null`; `question` is always present. |
| `totalResults` | Google's own estimate. May be `null`. |
| `resolvedCountry` | Where the search actually resolved. See *Geography*. |
| `tookMs` | Server-side time for this request. |

**Code defensively.** Google returns different blocks for different queries, so
`peopleAlsoAsk` and `relatedSearches` are often empty arrays and several fields are
nullable. Only `query`, `page`, `gl`, `hl` and `results` are always present.

---

## Errors

Errors come back as **HTTP 200 with `"ok": false`**, not as HTTP error codes. A "no
capacity right now" answer carries a machine-readable time to come back, and
squeezing that into a 429 or 503 would throw the timing away and invite every proxy
and client library in between to retry on its own terms.

The exceptions are the two cases where the request never reached the search layer:
bad parameters return `400`, and a bad key returns `401`.

```json
{
  "ok": false,
  "error": {
    "code": "CAPACITY",
    "message": "no source has capacity",
    "retryAtMs": 1788372000000
  }
}
```

A `Retry-After` header (in seconds) accompanies `CAPACITY` and `QUEUE_TIMEOUT` for
clients that prefer headers.

| `error.code` | HTTP | Meaning | What to do |
| --- | --- | --- | --- |
| `CAPACITY` | 200 | No capacity right now. | Retry at `retryAtMs`. |
| `QUEUE_TIMEOUT` | 200 | Waited its allowance and gave up. | Retry at `retryAtMs`. |
| `ALL_ATTEMPTS_FAILED` | 200 | The search was attempted and failed. `attempts` is the number of tries; `upstreamStatus` lists what came back. | Retry once; if `upstreamStatus` is a 4xx other than 429, the query itself is the problem. |
| `NO_ELIGIBLE_SOURCE` | 200 | The service is temporarily unable to serve. | Retry shortly; if it persists, contact the operator. |
| `BAD_REQUEST` | 400 | Bad or missing parameters. | Fix the request. |
| `BAD_JSON` | 400 | Body is not valid JSON. | Fix the request. |
| `UNAUTHORISED` | 401 | Missing or wrong key. | Check `X-API-Key`. |
| `INTERNAL` | 500 | Unexpected server error. | Retry; report if it persists. |

Handling it:

```js
const res  = await fetch(url, { headers: { 'X-API-Key': KEY } });
const body = await res.json();

if (body.ok) {
  use(body.result.results);
} else if (body.error.retryAtMs) {
  scheduleRetry(body.error.retryAtMs);      // CAPACITY / QUEUE_TIMEOUT
} else {
  reportFailure(body.error.code);
}
```

---

## `GET /v1/stats`

Whether it is worth sending traffic right now, and how much quota is left before it
resets.

```bash
curl http://127.0.0.1:8787/v1/stats -H "X-API-Key: $KEY"
```

```json
{
  "ok": true,
  "now": "2026-09-07T10:14:02.117Z",
  "status": "ok",
  "capacity": { "availableNow": 17, "inFlight": 0, "queued": 0 },
  "quota": {
    "remainingThisPeriod": 17403,
    "resetsAt": "2026-10-01T00:00:00.000Z",
    "daysUntilReset": 23.6
  },
  "usage": { "requestsServed": 412, "requestsFailed": 3 }
}
```

| Field | Meaning |
| --- | --- |
| `status` | `ok`, `saturated` (no free slots this instant), or `exhausted` (quota spent). |
| `capacity.availableNow` | How many searches could start immediately without queueing. |
| `capacity.queued` | Requests currently waiting for a slot. |
| `quota.remainingThisPeriod` | Approximate searches still available before `resetsAt`. |
| `quota.resetsAt` | When the quota refills. |
| `usage.requestsServed` | Total served since the service started. |

Poll it at most once a minute; it is a health signal, not a per-request check. If
you need to pace a bulk job, read `capacity.availableNow` and keep your concurrency
at or below it.

---

## `GET /health`

No key required. `200` with `{"ok":true,"status":"ready"}` once the service can
serve; `503` with `{"ok":false,"status":"starting"}` while it is still starting up.

---

## `GET /docs`

Returns this document as raw Markdown (`text/markdown`), unrendered.

---

## Geography

The `gl` you send pins the **country** a search resolves in, and
`result.resolvedCountry` confirms where it landed — but **city-level location varies
between calls** and cannot be pinned.

- For informational queries ("how do solar panels work") results are stable and
  reproducible.
- For local-intent queries ("pizza near me", "plumber", "opening hours") the same
  query can return different cities on different calls. Include the location in the
  query text itself — `q=plumber in brooklyn` rather than relying on `gl` — and do
  not cache local results as if they were canonical.

Always send `gl` explicitly. Omitting it defaults to `us`, which is rarely what you
want for a non-US audience.

---

## Limits

- **~10 organic results per page.** There is no `num` parameter; use `page`.
- **`page` maxes out at 10**, so about 100 results per query.
- **Rate limiting is handled for you.** The service paces requests internally, so
  you will receive a `CAPACITY` answer with a retry time rather than a raw rate-limit
  error. Respect `retryAtMs` and you will not be throttled further.
- **Queries are capped at 400 characters.**
- **Request bodies are capped at 64 KB.**

---

## Quick integration

```js
async function search(q, opts = {}) {
  const res = await fetch('https://your-subdomain/v1/search', {
    method: 'POST',
    headers: { 'X-API-Key': process.env.SEARCH_API_KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ q, gl: opts.gl ?? 'us', hl: opts.hl ?? 'en', page: opts.page ?? 1 }),
  });

  const body = await res.json();
  if (!body.ok) throw Object.assign(new Error(body.error.message), {
    code: body.error.code,
    retryAtMs: body.error.retryAtMs,
  });

  return body.result.results;   // [{ title, url, snippet, source, position }]
}
```

```python
import requests

def search(q, gl="us", hl="en", page=1):
    r = requests.post(
        "https://your-subdomain/v1/search",
        headers={"X-API-Key": API_KEY},
        json={"q": q, "gl": gl, "hl": hl, "page": page},
        timeout=45,
    )
    body = r.json()
    if not body["ok"]:
        raise RuntimeError(f"{body['error']['code']}: {body['error']['message']}")
    return body["result"]["results"]
```

Allow a generous client timeout — a search normally answers in 1–5 seconds, but can
take longer under load.
