Documentation
One search API over every grounding provider. Route by configuration, not code.
Quickstart
Every request goes to one endpoint. Which providers answer it is decided by the routing config bound to your API key — not by anything in the request.
curl https://api.groundrouter.ai/v1/search \
-H "Authorization: Bearer $GROUNDROUTER_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "offshore wind capacity 2026", "top_k": 5}'
Authentication
Send your key as a bearer token. Keys are shown once at creation and stored only as a hash — a lost key must be rotated, not recovered.
Authorization: Bearer gr-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
A search key cannot mint keys or change routing policy. Control-plane calls under /v1/admin require a separate admin token, so a leaked search key does not escalate to account control.
MCP server
GroundRouter is an MCP server over Streamable HTTP. Most harnesses need one line and no SDK.
claude mcp add --transport http groundrouter \
https://api.groundrouter.ai/mcp \
--header "Authorization: Bearer $GROUNDROUTER_KEY"
Two tools are exposed:
| Tool | Arguments | Purpose |
|---|---|---|
| search | query, top_k, domains, days, include_text | Search every routed provider. Returns compact text with rank, URL, provenance tags and passage. |
| list_providers | — | Which providers this key can reach and what each can filter on. Use after an unsupported_filter error. |
The search tool takes flattened arguments (days: 7 rather than a nested recency object) because agents write more reliable calls against a flat schema.
POST /v1/search
Request
| Field | Type | Description |
|---|---|---|
| query required | string | The search query. 1–2000 characters. |
| top_k | integer | Results to return after merging. 1–100, default 10. |
| include_text | boolean | Include passage text. Default true. Set false to cut latency and response size. |
| filters.domains | object | { values: string[], mode: "include" | "exclude" } |
| filters.recency | object | { mode: "rolling", days: n } or { mode: "range", from, to } |
| filters.publisher_ids | object | Licensed corpus only. Ozone publisher IDs. |
| filters.article_types | object | Licensed corpus only, e.g. hard_news, analysis. |
| filters.language | string | ISO language code. |
| providers | string[] | Override the bound routing policy for this request. For experiments; normally omitted. |
Response
{
"query": "who won the 2026 UK general election",
"results": [
{
"rank": 3,
"url": "https://www.bbc.com/news/articles/...",
"domain": "bbc.com",
"canonical_url": "bbc.com/news/articles/...",
"title": "Election 2026: results in full",
"text": "...",
"score": 0.74,
"published_date": "2026-05-02",
"provenance": {
"provider": "ozone",
"found_by": ["tavily", "ozone"],
"licensed": true,
"publisher_id": "OZONEBBC4784",
"publisher_name": "BBC",
"promoted": "ozone_precedence"
}
}
],
"routing": {
"policy": "fanout",
"calls": [
{ "provider": "tavily", "status": "ok", "latency_ms": 985, "cost_units": 1 },
{ "provider": "ozone", "status": "ok", "latency_ms": 412 }
]
},
"usage": { "latency_ms": 1002, "cached": false, "licensed_result_count": 1 },
"request_id": "..."
}
routing.calls reports every provider attempt, including failures. Under fanout a partial failure still returns 200 with the results that arrived — check this array rather than assuming every provider answered.
score is the provider’s own relevance, as reported, and is not comparable across providers. rank is the merged ordering and is the only cross-provider signal. Sort on rank.
GET /v1/providers
The live catalogue: every provider, its filter capabilities, and whether your account holds working credentials for it (configured).
Errors
Errors carry a stable machine-readable code. Branch on that, never on the message text.
| Code | Status | Meaning |
|---|---|---|
| unauthorized | 401 | Missing, invalid or revoked API key. |
| invalid_request | 400 | Malformed body or parameter out of range. |
| unsupported_filter | 400 | A routed provider cannot honour a filter you sent. See below. |
| rate_limited | 429 | Per-minute limit exceeded. See X-RateLimit-Reset. |
| no_provider_available | 502 | Every routed provider failed. details.calls says why. |
| provider_error | 502 | An upstream provider failed and no fallback remained. |
Routing policies
| Policy | Shape | Behaviour |
|---|---|---|
| single | provider | One provider. Errors surface directly. |
| failover | providers[] | Ordered; first success wins. Cheapest resilience. |
| rotate | {provider, weight}[] | Weighted per request. Spreads spend across quotas. |
| fanout | providers[], timeout_ms | All in parallel, merged and deduplicated. Best recall; the only policy where cross-provider licence precedence applies. |
A missing credential moves to the next provider — that says nothing about whether the next one works. A rejected credential or a malformed request does not: it would fail identically downstream, and continuing spends another provider’s quota to hide a configuration error you need to fix.
Filter capabilities
Providers do not support the same filters. publisher_ids, article_types and temporal_sensitivity describe a licensed corpus and are meaningless on the open web.
Sending publisher_ids to a config routed at Tavily returns 400 unsupported_filter. It does not return unfiltered results.
The alternative would be worse than an error: you would receive plausible-looking results that violate the constraint you set, with no way to tell. Set strict_capabilities: false on the config to drop unsupported filters instead — the response still reports what was dropped.
Deduplication and ranking
Canonicalisation
Providers return the same document under different URLs. GroundRouter collapses tracking parameters (utm_*, fbclid, gclid…), www./m./amp. hosts, AMP path suffixes and trailing slashes, and sorts remaining query parameters.
Unknown query parameters are kept. For many sites ?id=123 is the document identity, and merging those would serve one article in place of two. Over-merging is the more damaging direction, so canonicalisation is deliberately conservative.
Rank fusion
Results are fused by rank using Reciprocal Rank Fusion, not by score. Provider scores come from different models over different corpora, so combining them arithmetically would assert a comparability that does not exist. Rank is the one thing every provider agrees on the meaning of.
A document several providers rank highly therefore outranks one only a single provider liked.
Routing configs
A config is data, not code. Update it and the next request routes differently — no redeploy, no client release.
{
"name": "production",
"routing": {
"policy": "fanout",
"providers": ["ozone", "exa", "tavily"],
"per_provider_top_k": 10,
"timeout_ms": 6000
},
"merge": { "strategy": "precedence" },
"strict_capabilities": true,
"cache_ttl_seconds": 300
}
Configs are resolved in order: the config bound to the API key, then your account default, then a built-in fanout over whatever providers hold credentials. The account default is what lets a whole fleet of keys be re-routed with one edit.
Validation happens on write. A provider name that does not exist, or one that is inactive, is rejected at edit time rather than surfacing later as a failed search.
Bring your own keys
GroundRouter does not resell search. You supply your own Exa, Tavily and Brave keys; they are encrypted with AES-256-GCM under a key held outside the database.
curl -X PUT https://api.groundrouter.ai/v1/admin/credentials \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"provider": "tavily", "api_key": "tvly-..."}'
The response returns a fingerprint and the last four characters only. No endpoint returns a stored credential. A dashboard needs to show which key is configured, not the key itself — otherwise a compromised admin token would exfiltrate every provider key in one request.
Your stored credential takes precedence over any platform key. Ozone is the exception: it is GroundRouter’s own upstream, reached with a GroundRouter-issued token.
API keys
| Endpoint | Method | Purpose |
|---|---|---|
| /v1/admin/keys | POST | Issue a key. The plaintext is returned once and never again. |
| /v1/admin/keys | GET | List keys by prefix, with last-used timestamps. |
| /v1/admin/keys/:id | PATCH | Rebind to a different routing config. |
| /v1/admin/keys/:id | DELETE | Revoke. Soft delete, so usage history survives. |
Content licensing
Ozone holds content licences from its publisher network. The commodity search providers do not.
When any provider returns a URL on an Ozone publisher domain, Ozone’s record is served for it — its licensed passage text and publisher metadata, rather than a scraped snippet — and the result is marked licensed: true with promoted: "ozone_precedence". A result Tavily found on a BBC page is still licensed content; which provider surfaced it does not change who owns it.
Licensed content receives no ranking advantage over content Ozone does not own. It ranks where the rank fusion puts it. An agent that cannot trust the ordering stops using the router, so the ordering stays honest and the licence is reported as metadata.
If you want licensed content positioned differently, that is an explicit merge strategy (top_slots or interleave) you opt into per config — never a hidden default.
Rate limits and caching
Limits are per minute, reported on every response:
X-RateLimit-Limit: 120
X-RateLimit-Remaining: 119
X-RateLimit-Reset: 1788529560
X-Cache: MISS
Responses are cached per cache_ttl_seconds. The cache key covers the request and the routing and merge policy, so editing a config never serves results produced under the old one. Keys are also account-scoped, so no result crosses between accounts.