---
seo_title: Lookup (Phase 2): Web Search API for Agents | EnConvert
meta_desc: Private beta, Phase 2: run a web, news, scholar, or maps search and optionally auto-perceive the top results. Provider-neutral search through one endpoint.
keywords: web search api for llm, serp api alternative, google search api for ai agents, search api with page content, serper api alternative, firecrawl search alternative, web search api for rag, news search api json
---

# Web Search API for LLM Agents

<div class="alert alert-warning">
<strong>Private beta.</strong> Lookup is callable today with your normal API key, on every plan including Founding, and it draws on your monthly ops allowance like any other call. It is not announced or generally available: the request and response shapes can change without notice, and there is no stability or support commitment, so do not build anything load-bearing on it yet. The roadmap is on <a href="/docs/coming-soon">Coming Soon</a>, and every release is announced in <a href="/changelog">the changelog</a>.
</div>

`POST /v2/lookup` is a web search API built for LLM agents: it runs a
search in one of six categories and hands back a flat, provider-neutral
result list. Set `perceive_top` and it also renders the top-N result URLs
in a real browser, so an agent gets the search-engine results page (SERP)
*and* the page content behind each hit in a single round trip. As a SERP
API alternative it collapses the usual stack (hit a search API, parse its
results, then fan out a scraper) into one call. It will be EnConvert's
answer to Firecrawl `/search`.

Serper is the search provider behind it. The request and response speak
a neutral search vocabulary (`category`, `country`, `locale`,
`time_filter`), so a future provider swap does not change the contract
you code against.

Here is the smallest useful call. Send a query, get the top web results
back:

```bash
curl -X POST https://api.enconvert.com/v2/lookup \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "headless chrome pdf rendering"
  }'
```

The response is a flat result list plus provenance for support
correlation:

```json
{
    "lookup_id": 81423,
    "query": "headless chrome pdf rendering",
    "category": "web",
    "total": 10,
    "results": [
        {
            "title": "Generate PDFs with headless Chrome",
            "url": "https://example.com/guide/chrome-pdf",
            "snippet": "Render a page and print it to PDF...",
            "position": 1
        },
        {
            "title": "Print to PDF with the Chrome DevTools Protocol",
            "url": "https://example.dev/cdp/print-to-pdf",
            "snippet": "Page.printToPDF returns base64 PDF data...",
            "position": 2
        }
    ],
    "perceive_top": 0,
    "perceive_operation_ids": [],
    "credits": 1,
    "cost_cents": 0.06,
    "warnings": []
}
```

---

## Endpoints

| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/v2/lookup` | Run one search and, optionally, auto-perceive the top-N result URLs. |

`/v2/lookup` is a single-call endpoint with no separate status or
retrieval path. When you auto-perceive, each rendered page becomes a
first-class [perceive](/docs/endpoints/perceive.md) operation with its own
`operation_id`, which you can re-fetch later through
`GET /v2/perceive/{operation_id}`.

**Content-Type:** `application/json`.

---

## Authentication

Authenticate with a private key in the `X-API-Key` header for
server-to-server calls. This is the path the examples below use.

```http
X-API-Key: sk_your_private_key
```

Public keys with a JWT bearer token also work, using the same flow as
every other endpoint: generate a token with your `pk_` key, then send it
as `Authorization: Bearer <token>`. The full flow, including domain
locking and token refresh, is in [the authentication guide](/docs/authentication.md).

Each API key carries an allowed-endpoints allowlist. If `/v2/lookup` is
not on the key's list, the request is rejected with `403`.

---

## How lookup works

One request runs one provider search and, only if you ask for it,
renders the top results afterward.

1. **Quota gate.** Before anything bills, the handler checks your
   plan's unified monthly ops allowance. A disabled plan or an exhausted
   allowance is rejected with `402`, so nothing is charged on a
   rejection.
2. **Search.** The query goes to the search provider (Serper) on the
   endpoint for your `category`. Recency, country, locale, location, page
   size, and autocorrect map onto the provider's parameters.
3. **Normalise.** Each provider hit is flattened into a neutral
   `LookupResult` carrying `title`, `url`, `snippet`, and `position`. The
   category-specific extras land in `extra` so the contract never grows a
   column per provider quirk.
4. **Charge and audit.** The search succeeded, so one op is billed and
   one `ch_lookup_queries` audit row is written. The row id
   comes back as `lookup_id` for support correlation.
5. **Auto-perceive (optional).** If `perceive_top > 0`, the top-N result
   URLs are rendered one at a time through the shared headless Chrome
   singleton, the same pipeline as [the perceive endpoint](/docs/endpoints/perceive.md).
   Each render is a full `/v2/perceive` operation: its own op billed
   against the shared allowance, its own operation row, its own
   `operation_id`. By default auto-perceive
   requests Markdown only, with no screenshot, PDF, or LLM extraction; send
   an `enrich` object to widen the outputs, run them in parallel, or add
   schema extraction and a synthesized answer.

Auto-perceive is best-effort. A single URL that fails, or the ops
allowance running out part-way through, degrades to a warning and still
returns the search results. The SERP is the primary product here, so a
perception problem never sinks the whole call.

---

## Request parameters

### Query and category

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `query` | `string` | -- | The search query. 1–512 characters, trimmed of leading/trailing whitespace. A query that is empty after trimming is rejected with `422`. Required. |
| `category` | `string` | `"web"` | One of `web`, `news`, `images`, `scholar`, `patents`, `maps`. |

### Targeting and recency

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `country` | `string` | `null` | Google `gl` country code, e.g. `us`, `in`. Max 8 characters. |
| `locale` | `string` | `null` | Google `hl` interface language, e.g. `en`. Max 16 characters. |
| `time_filter` | `string` | `null` | Restrict to results from the past period: `hour`, `day`, `week`, `month`, or `year`. |
| `location` | `string` | `null` | Free-text location string, e.g. `"Austin, Texas"`. Max 128 characters. |
| `autocorrect` | `boolean` | `true` | Whether the provider may autocorrect the query spelling. |

### Pagination

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `num_results` | `integer` | `10` | Results per page. 1–100. |
| `page` | `integer` | `1` | Page number. 1–10. |

### Auto-perceive

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `perceive_top` | `integer` | `0` | Auto-perceive the first N result URLs that have a navigable link. 0–10. Each one is a full browser render that bills one op from your monthly allowance, which is why it is capped at 10. For larger sets, take the `url` fields and call [the perceive batch endpoint](/docs/endpoints/perceive.md#batch-perception). `0` disables auto-perceive. |

### Enrichment (`enrich`)

An optional `enrich` object tunes how the top-N results (`perceive_top`)
are read, and can synthesize one grounded answer across them. When
`enrich` is omitted, `perceive_top` keeps its default behaviour
(Markdown-only, one result at a time).

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `enrich.outputs` | `string[]` | `["markdown"]` | Which perceive outputs to produce per enriched result, for example `markdown`, `html_cleaned`, `links`, `screenshot`, `structured`. See [perceive outputs](/docs/endpoints/perceive.md#outputs). |
| `enrich.concurrency` | `integer` | `3` | How many result URLs to enrich in parallel. 1–5. Markdown/HTML renders parallelize; screenshot/PDF renders serialize on the shared browser. |
| `enrich.schema` | `object` | `null` | Run schema-driven structured extraction against each enriched result. The extracted data appears under each result's `perceive.structured`. JSON-Schema object or a flat `{field: description}` map. |
| `enrich.synthesize_answer` | `boolean` | `false` | Synthesize one cited, grounded answer to the query across the enriched results, returned as `answer` (with `answer_sources`). Uses the perceived page content when available, otherwise the result snippets. |
| `enrich.answer_prompt` | `string` | `null` | A question to answer instead of the raw query. Only used when `synthesize_answer` is `true`. Max 1,000 characters. |

`enrich.schema` and `enrich.synthesize_answer` use the LLM extraction
tier. If that extraction step cannot run, they degrade to a warning and
the rest of the response is unaffected.

```json
{
  "query": "best open-source vector databases",
  "perceive_top": 3,
  "enrich": {
    "outputs": ["markdown"],
    "concurrency": 3,
    "synthesize_answer": true
  }
}
```

---

## Categories

Each category hits a different provider endpoint and surfaces a slightly
different result shape. The universal fields (`title`, `url`, `snippet`,
`position`) are always typed; category-specific fields land in `extra`.

| `category` | What it searches | Notable fields populated |
|------------|------------------|--------------------------|
| `web` | General web results | `title`, `url`, `snippet`, `date`, `position` |
| `news` | News articles | adds `source`, `image_url` |
| `images` | Image results | `image_url`, `thumbnail_url`, `source` (often no `snippet`) |
| `scholar` | Academic results | same shape as `web`; citation counts in `extra` |
| `patents` | Patent results | same shape as `web`; patent fields in `extra` |
| `maps` | Local places | `url` is the place website; `snippet` carries the address; rating, coordinates in `extra` |

Worth flagging: for `images` and `maps`, `url` can be `null` for a given
hit when the provider returns no navigable link. Auto-perceive skips any
result whose `url` is `null`, so a `perceive_top` of 5 on a SERP with two
URL-less hits perceives at most three pages.

---

## Response

The endpoint omits `null` fields, so a minimal `web` result carries only
the fields that are actually populated.

| Field | Type | Description |
|-------|------|-------------|
| `lookup_id` | `integer` | The `ch_lookup_queries` audit-row id. Quote it to support. `null` if the audit write failed, though the results are still valid. |
| `query` | `string` | The (trimmed) query you sent. |
| `category` | `string` | The category searched. |
| `country` | `string` | Echo of the `country` you sent, if any. |
| `locale` | `string` | Echo of the `locale` you sent, if any. |
| `time_filter` | `string` | Echo of the `time_filter` you sent, if any. |
| `total` | `integer` | Number of results returned. |
| `results` | `LookupResult[]` | The result list. See below. |
| `perceive_top` | `integer` | How many results were *actually* perceived: at most the value you requested, and lower if the ops allowance ran out or URLs failed. |
| `perceive_operation_ids` | `string[]` | The `per_...` operation ids of the perceived results, in order. |
| `answer_box` | `object` | The provider's answer box, when present. |
| `knowledge_graph` | `object` | The provider's knowledge graph panel, when present. |
| `answer` | `string` | The synthesized cited answer across the enriched results. Present only when `enrich.synthesize_answer` is `true` and it succeeded. |
| `answer_sources` | `string[]` | The URLs used as grounding for `answer`, in citation order. |
| `credits` | `integer` | Provider credits consumed by this query. |
| `cost_cents` | `number` | Monetary cost of the search in cents. A flat `0.06` per query today. |
| `warnings` | `string[]` | Non-fatal notes: a skipped URL-less result, an auto-perceive failure, the ops allowance running out mid-loop. |

### LookupResult

| Field | Type | Description |
|-------|------|-------------|
| `title` | `string` | Result title. |
| `url` | `string` | Canonical page link, the thing you would perceive. `null` for results with no navigable URL. |
| `snippet` | `string` | Result snippet. For `maps`, this carries the address. |
| `position` | `integer` | The result's position on the SERP. |
| `source` | `string` | Source/publisher, for `news` and `images`. |
| `date` | `string` | Published date, when the provider reports one. |
| `image_url` | `string` | Image URL, for `images` and `news`. |
| `thumbnail_url` | `string` | Thumbnail URL, for `images`. |
| `extra` | `object` | Category-specific fields not in the neutral set: ratings, coordinates, citation counts, and so on. |
| `perceive` | `PerceiveResponse` | The full inline perceive result for this URL, present only for the top-N when `perceive_top > 0` and the render succeeded. Same object shape as [the perceive endpoint](/docs/endpoints/perceive.md#response). |

---

## Reading auto-perceive results

When you send `perceive_top`, walk the results and check for the
`perceive` field. It is present only on the results that were perceived,
and only when their render succeeded. The Markdown for each lives behind a
pre-signed download URL (a short-lived, signed link to object storage)
under `perceive.outputs.markdown.url`, the same as a direct perceive call.

```json
{
    "lookup_id": 81910,
    "query": "react server components data fetching",
    "category": "web",
    "total": 10,
    "results": [
        {
            "title": "Data fetching with RSC",
            "url": "https://example.com/rsc/data",
            "snippet": "Fetch on the server, stream to the client...",
            "position": 1,
            "perceive": {
                "operation_id": "per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7",
                "status": "completed",
                "url": "https://example.com/rsc/data",
                "outputs": {
                    "markdown": {
                        "url": "https://spaces.example.com/...signed...",
                        "size_bytes": 7421,
                        "content_type": "text/markdown; charset=utf-8",
                        "expires_in": 900
                    }
                },
                "cost_cents": 0.0,
                "duration_ms": 5840
            }
        }
    ],
    "perceive_top": 1,
    "perceive_operation_ids": [
        "per_3f9a2c1b8e7d4a6f90b1c2d3e4f5a6b7"
    ],
    "credits": 1,
    "cost_cents": 0.06,
    "warnings": []
}
```

Those signed URLs expire after 15 minutes. To download a perceived page
later, re-fetch its operation with `GET /v2/perceive/{operation_id}` using
the id from `perceive_operation_ids`. That re-signs the URLs and does not
re-render, so it costs no ops. See
[the perceive retrieve section](/docs/endpoints/perceive.md#retrieve-an-operation)
for the details.

---

## Code examples

### curl: web search

```bash
curl -X POST https://api.enconvert.com/v2/lookup \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "open source vector database",
    "num_results": 20
  }'
```

### curl: recent news, localised

```bash
curl -X POST https://api.enconvert.com/v2/lookup \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "rbi monetary policy",
    "category": "news",
    "country": "in",
    "locale": "en",
    "time_filter": "week"
  }'
```

### curl: search plus auto-perceive the top 3

```bash
curl -X POST https://api.enconvert.com/v2/lookup \
  -H "X-API-Key: sk_your_private_key" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "langchain retrieval augmented generation",
    "perceive_top": 3
  }'
```

### Python

```python
import requests

response = requests.post(
    "https://api.enconvert.com/v2/lookup",
    headers={"X-API-Key": "sk_your_private_key"},
    json={
        "query": "langchain retrieval augmented generation",
        "perceive_top": 3,
    },
)
response.raise_for_status()
data = response.json()

# Pull the Markdown of every result that was perceived
for result in data["results"]:
    perceived = result.get("perceive")
    if not perceived:
        continue
    markdown_url = perceived["outputs"]["markdown"]["url"]
    page_text = requests.get(markdown_url).text
    print(result["url"], len(page_text), "chars")

for note in data["warnings"]:
    print("warning:", note)
```

### Node.js

```javascript
const res = await fetch("https://api.enconvert.com/v2/lookup", {
    method: "POST",
    headers: {
        "Content-Type": "application/json",
        "X-API-Key": "sk_your_private_key"
    },
    body: JSON.stringify({
        query: "langchain retrieval augmented generation",
        perceive_top: 3
    })
});

const data = await res.json();

// Pull the Markdown of every result that was perceived
for (const result of data.results) {
    if (!result.perceive) continue;
    const markdownUrl = result.perceive.outputs.markdown.url;
    const pageText = await fetch(markdownUrl).then(r => r.text());
    console.log(result.url, pageText.length, "chars");
}

for (const note of data.warnings) {
    console.log("warning:", note);
}
```

If you call EnConvert from Claude, Cursor, or another Model Context
Protocol (MCP) client, the search capability will be exposed as a tool
there too. See [the MCP server page](/mcp.md).

---

## Error responses

The handler never echoes raw provider text to the client. Provider and
SSRF detail stays in the server logs, and the client gets a clean,
generic message.

| Status | Condition |
|--------|-----------|
| `401 Unauthorized` | Missing or invalid API key / JWT token. |
| `402 Payment Required` | Lookup is not on your current plan, or your monthly ops allowance is exhausted. |
| `403 Forbidden` | `/v2/lookup` is not in the API key's allowed endpoints. |
| `422 Unprocessable Entity` | Request validation failed: empty/over-length `query`, an unknown `category` or `time_filter`, `num_results` or `page` out of range, `perceive_top` over 10. |
| `502 Bad Gateway` | The search provider returned an error response or a non-retryable transport fault (`SearchUpstreamError`). Retrying may help. |
| `503 Service Unavailable` | The search provider is misconfigured server-side (a missing key on our side, `SearchConfigError`), or it is temporarily unavailable: the circuit breaker is open, or the provider rate-limited us (`SearchUnavailableError`). Retry later. |
| `500 Internal Server Error` | An unexpected failure. The message is generic; quote the time of the call to support. |

A failing auto-perceive never raises an error of its own. It lands in
`warnings` and the call still answers `200`. The full status-code
reference is in [the error-codes guide](/docs/reference/errors.md).

---

## Limits

| Limit | Value |
|-------|-------|
| `query` length | 1–512 characters (trimmed) |
| `country` length | 8 characters |
| `locale` length | 16 characters |
| `location` length | 128 characters |
| `num_results` | 1–100 |
| `page` | 1–10 |
| `perceive_top` | 0–10 |
| Ops per call | 1 for the query, plus 1 per auto-perceived result |
| Auto-perceive outputs | Markdown by default; widened with `enrich.outputs` |
| Auto-perceive concurrency | Sequential by default; 1–5 with `enrich.concurrency` |
| Cost per search | 0.06 cents flat |
| Perceived-page signed URL expiry | 15 minutes |

---

## Frequently asked questions

### How do I run a web search and get page content back in one REST API call?

Send `POST /v2/lookup` with a `query` and set `perceive_top` (0–10). The top-N result URLs are rendered in a real browser, and each perceived result carries an inline `perceive` object whose Markdown sits behind a pre-signed URL at `perceive.outputs.markdown.url`.

### Is /v2/lookup a SERP API alternative I can adopt without provider lock-in?

Yes. Serper is the search provider behind it, but the request and response speak a neutral search vocabulary (`category`, `country`, `locale`, `time_filter`), so a future provider swap does not change the contract you code against.

### What search categories does the lookup API support?

Six: `web` (the default), `news`, `images`, `scholar`, `patents`, and `maps`. The universal fields (`title`, `url`, `snippet`, `position`) are always typed, and category-specific extras land in `extra`.

### Why did lookup perceive fewer pages than my perceive_top value?

Auto-perceive skips results whose `url` is `null`, stops if the monthly ops allowance runs out mid-loop, and degrades a failed render to a warning. The response's `perceive_top` reports how many pages were actually perceived, and `warnings` explains the gaps.
