---
name: plainserp-search
description: Add web search to code or an agent with the Plainserp API. Use when the user wants Google search results in their app, wants to give an AI agent a search tool, or mentions Plainserp. Covers the endpoint, parameters, errors, billing, a tool definition and the MCP server.
---

# Plainserp search

Plainserp returns Google's organic results as JSON: position, title, URL and snippet. It leaves out AI Overviews, People also ask, ads and other boxes. It does not fetch or read the pages it links to.

## Setup

1. The user creates a key at https://plainserp.com/dashboard/keys. Keys start with `ps_live_`.
2. Store it in the `PLAINSERP_KEY` environment variable. Never hardcode it or send it to a browser.
3. If the key is missing, stop and ask the user for it. Do not invent one.

## The request

```
GET https://plainserp.com/v1/search
Authorization: Bearer $PLAINSERP_KEY
```

| Parameter | Default | Meaning |
|---|---|---|
| `q` | required | The search query, up to 400 characters. |
| `num` | 10 | Results per page, 1 to 20. |
| `pages` | 1 | How many pages to fetch in one call, 1 to 6. Each page is billed as one search. |
| `page` | 1 | Which page to start from, 1 to 6. `page + pages - 1` cannot go past 6. |
| `gl` | none | Two-letter country code to search from, such as `us` or `de`. |
| `hl` | `en` | Language code, such as `en` or `fr`. |
| `time` | none | `day`, `week`, `month` or `year`. |
| `safe` | `off` | `off`, `medium` or `high`. |

The deepest a query goes is 120 results (6 pages of 20).

## The response

```json
{
  "query": "postgres connection pooling",
  "results": [
    {
      "position": 1,
      "title": "PgBouncer - lightweight connection pooler for PostgreSQL",
      "url": "https://www.pgbouncer.org/",
      "snippet": "PgBouncer keeps a pool of server connections..."
    }
  ],
  "cost_usd": 0.0003
}
```

`thumbnail` is present on a result only when Google has one. A page can hold fewer results than asked for when Google runs out.

## Errors

Errors are JSON: `{ "error": { "code": "...", "message": "..." } }`. Failed requests are never charged.

| Status | Code | What to do |
|---|---|---|
| 400 | `invalid_request` | Fix the parameter named in the message. Do not retry unchanged. |
| 401 | `invalid_api_key` | The key is missing, wrong or revoked. Ask the user. |
| 402 | `insufficient_balance` | The balance is too low. Tell the user to top up at https://plainserp.com/dashboard/billing. |
| 429 | `rate_limited` | Wait for the `Retry-After` header (seconds), then retry. |
| 502 | `upstream_error` | Google did not answer. Retry with backoff: 1s, 2s, 4s, then give up. |

Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. The limit is per account, per minute, and is 300 by default.

## Billing

One successful page costs $0.0003, whatever `num` is. `pages=3` costs $0.0009. New accounts start with $0.30, which is 1,000 searches.

## Code

Python:

```python
import os, time, requests

def web_search(q: str, num: int = 10, pages: int = 1, **extra) -> list[dict]:
    for wait in (1, 2, 4, None):
        r = requests.get(
            "https://plainserp.com/v1/search",
            params={"q": q, "num": num, "pages": pages, **extra},
            headers={"Authorization": f"Bearer {os.environ['PLAINSERP_KEY']}"},
            timeout=30,
        )
        if r.status_code in (429, 502) and wait is not None:
            time.sleep(int(r.headers.get("Retry-After", wait)))
            continue
        r.raise_for_status()
        return r.json()["results"]
```

TypeScript:

```ts
export async function webSearch(q: string, num = 10, pages = 1) {
  const params = new URLSearchParams({ q, num: String(num), pages: String(pages) });
  const res = await fetch(`https://plainserp.com/v1/search?${params}`, {
    headers: { Authorization: `Bearer ${process.env.PLAINSERP_KEY}` },
  });
  if (!res.ok) throw new Error((await res.json()).error?.message ?? `HTTP ${res.status}`);
  return (await res.json()).results as { position: number; title: string; url: string; snippet: string }[];
}
```

## As an agent tool

Give the model this tool, and run `web_search` when it calls it:

```json
{
  "name": "web_search",
  "description": "Search Google. Returns titles, URLs and snippets.",
  "input_schema": {
    "type": "object",
    "properties": {
      "q": { "type": "string", "description": "The search query" },
      "num": { "type": "integer", "description": "Results to return, 1 to 20" }
    },
    "required": ["q"]
  }
}
```

Guidance for the agent using it:

- Ask for 5 to 10 results. It is usually enough to choose what to read and keeps the tool result short.
- Snippets are short. To read a page, fetch its URL with a separate tool.
- Use `time` for anything recent, and `gl` and `hl` for local results.

## MCP server

To give an AI client search without writing code, connect it to the MCP server:

- URL: `https://plainserp.com/mcp` (Streamable HTTP)
- Header: `Authorization: Bearer <key>`
- Tool: `web_search` with `q`, `num`, `pages`, `gl`, `hl`, `time`

Claude Code:

```
claude mcp add --transport http plainserp https://plainserp.com/mcp --header "Authorization: Bearer $PLAINSERP_KEY"
```

Full documentation: https://plainserp.com/docs
