Search API
POST https://api.groundroute.ai/v1/search
The core endpoint. Requires Authorization: Bearer <api_key>. The request body is JSON. Unknown fields are ignored, not rejected.
Request
| Field | Type | Default | Notes |
|---|---|---|---|
query | string (1–2048 chars) | — | Required. |
mode | enum | auto | Intent hint; the router may refine it. One of auto, web, news, academic, answer, page. |
max_results | int (1–50) | 10 | Part of the cache key. |
freshness | enum | null | null | Explicit freshness override: fresh, semi, or static. fresh caps cache TTL and bypasses the semantic tier. |
domains | string[] | [] | Include-filter. Part of the cache key. |
exclude_domains | string[] | [] | Exclude-filter. |
lang | string (ISO 639-1, ≤8 chars) | null | null | Part of the cache key. |
country | string (ISO 3166-1 alpha-2, ≤2 chars) | null | null | Part of the cache key. |
tenant_id and is_byok are server-authoritative — any value sent in the body is dropped and replaced with the value resolved from your API key.
curl -s https://api.groundroute.ai/v1/search \
-H "Authorization: Bearer $GROUNDROUTE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "transformer attention complexity",
"mode": "academic",
"max_results": 10,
"freshness": "static",
"domains": ["arxiv.org"],
"lang": "en"
}'
Response — 200
All four *_meta blocks are always present, even on a cache hit or a degraded response.
{
"request_id": "8f1c2e7a-…", // uuid
"tenant_id": "…",
"results": [
{
"url": "https://arxiv.org/abs/...",
"title": "Attention Is All You Need",
"snippet": "…",
"content": null, // full body only when mode/engine provides it
"score": 0.82, // normalized relevance, 0–1, or null
"source_engine": "exa", // serper|brave|exa|tavily|perplexity|firecrawl
"published_at": null, // ISO 8601 or null
"rank": 0 // 0-based position as served
}
],
"answer": null, // synthesized answer (answer engines), or null
"citations": [ // present when an answer cites sources
{ "url": "https://...", "title": "…", "index": 0 }
],
"degraded": false, // true = partial results (one engine failed)
"routing_meta": {
"strategy": "price_led", // price_led | learned | fanout
"query_class": "academic", // academic | web | news | page | answer
"chosen_engine": "exa",
"reason": "price_led:exa $7.00/1k (first eligible for academic, source=static)",
"candidates": [
{ "engine": "exa", "cost_per_1k_usd": 7.0, "eligible": true, "picked": true, "note": "cheapest eligible" },
{ "engine": "brave", "cost_per_1k_usd": 5.0, "eligible": true, "picked": false, "note": null }
],
"cost_usd": 0.007, // actual provider cost incurred this request
"latency_ms": 412,
"degraded": false,
"failover_from": null, // engine that failed before chosen_engine, or null
"missing_engines": []
},
"cache_meta": {
"cache_tier": "miss", // miss | exact_private | exact_pooled | semantic
"poolable": false,
"poolability_reason": "cell_not_allowlisted",
"screens_post_nfkc": true,
"freshness_bucket": "none", // hourly | daily | none
"cache_key_private": null, // sha256 hex (no raw query)
"cache_key_pooled": null, // set only when poolable
"cached_at": null, // ISO 8601 when a hit
"ttl_seconds": null,
"cost_avoided_usd": 0.0, // provider $ saved by this hit
"similarity": null // semantic tier only
},
"quality_meta": {
"quality_score": null, // composite 0–1, or null when not scored
"components": {},
"judge_sampled": false,
"engine_agreement_rbo": null
},
"usage_meta": {
"request_id": "8f1c2e7a-…",
"tenant_id": "…",
"account_id": "…",
"api_key_id": "…",
"ts": "2026-06-14T07:48:14Z",
"billable": true,
"searches_counted": 1,
"provider_key_source": "platform", // platform (managed) | byok
"cost_usd": 0.007
}
}
Object shapes
SearchResult: url, title, snippet, content?, score? (0–1), source_engine, published_at?, rank (0-based).
Citation: url, title?, index (maps into the answer text).
Response headers
| Header | Value |
|---|---|
X-Request-Id | The request UUID. |
X-Cache-Tier | miss, exact_private, exact_pooled, or semantic. |
X-Degraded | true / false. |
X-GR-Timing | Per-stage timing, e.g. auth=2(hit);enforce=1;engine=410;write=0;total=413. |
Modes and query classes
mode is your intent hint. The router classifies each query into one of five query classes that drive engine selection and cache TTLs:
| Query class | Triggered by | Default engine order |
|---|---|---|
web | mode: web / auto fallthrough | serper → brave → exa |
news | mode: news, freshness: fresh, or news keywords | serper → brave |
academic | mode: academic, or academic keywords | exa → brave → serper |
answer | mode: answer | perplexity → tavily → exa |
page | mode: page | firecrawl |
In auto/web mode, lightweight keyword rules can promote a query to academic (e.g. "paper", "arxiv", "doi") or news (e.g. "latest", "today", "breaking"). Explicit modes always win over keyword heuristics. See Routing and policies.
Errors
Errors use the standard error envelope:
{ "error": { "type": "auth", "message": "…", "request_id": "…", "retryable": false } }
| Status | When |
|---|---|
401 | Missing / invalid / revoked key (auth). |
402 | Billing block — insufficient credit, no-free-managed, free cap exceeded, or spend hard-stop (invalid_request). |
403 | Account inactive (invalid_request). |
422 | Invalid request body. |
429 | Rate limit or concurrency cap (rate_limit). |
500 | Internal error (unavailable, retryable). |
200 + degraded: true | Partial results — one engine failed but at least one returned. Not an error. |
If all engines fail with no successful failover, the request returns an error envelope rather than partial results.
GET /v1/usage
GET /v1/usage returns a usage summary for the authenticated tenant. Detailed usage analytics are exposed through the console API under /v1/console.