Web Search API for LLM Agents#
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:
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:
{
"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 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.
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.
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.
- 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. - 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. - Normalise. Each provider hit is flattened into a neutral
LookupResultcarryingtitle,url,snippet, andposition. The category-specific extras land inextraso the contract never grows a column per provider quirk. - Charge and audit. The search succeeded, so one op is billed and
one
ch_lookup_queriesaudit row is written. The row id comes back aslookup_idfor support correlation. - 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. Each render is a full/v2/perceiveoperation: its own op billed against the shared allowance, its own operation row, its ownoperation_id. By default auto-perceive requests Markdown only, with no screenshot, PDF, or LLM extraction; send anenrichobject 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. 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. |
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.
{
"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. |
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.
{
"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
for the details.
Code examples#
curl: web search#
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#
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#
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#
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#
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.
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.
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.