API reference
| Base URL | https://api.topoloop.com |
|---|---|
| Auth | X-API-Key: tl_live_… header on every request — get a key |
| Format | JSON in, JSON out (Content-Type: application/json) |
| Endpoints | POST /places/search · POST /places/nearest · POST /places/details · POST /web/search · POST /web/page |
Authentication
Send your key as the X-API-Key header. Missing or invalid keys
return:
// HTTP 401
{
"error": "unauthorized",
"message": "Missing or invalid API key. Send it as the X-API-Key header. Get one at https://dash.topoloop.com"
}
Keys are managed at dash.topoloop.com (create up to 5, revoke any time — revocation takes effect within ~15 seconds).
Rate limits & quotas
| Tier | Rate | Monthly quota |
|---|---|---|
| Free | 2 requests/s, burst 10 | 10,000 requests |
Exceeding either returns HTTP 429. The burst allowance means a small agent fan-out (several parallel tool calls) won't instantly 429; sustained traffic should stay under the per-second rate. Usage is visible per-key in the dashboard.
POST /places/search
Semantic + lexical place search near a coordinate. This is the agent-facing workhorse: approximate names, translations, misspellings, and category-flavored queries ("ramen near the station") all resolve. Results are ranked by a learned reranker over semantic similarity, lexical match, distance, and name overlap — not by distance alone: an exact name match 2 km away outranks a vaguely-related place 100 m away.
Request
{
"query": "kinkakuji golden pavilion",
"lat": 35.03,
"lng": 135.72,
"limit": 5
}
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural-language place query (min 1 char). |
lat | float | yes | Latitude of the search center (−90..90). Use 3–4 decimals of precision — 1 decimal (~11 km) is too coarse and pulls the search to the wrong neighborhood. |
lng | float | yes | Longitude of the search center (−180..180). |
radius | float | no | Search radius in km (0–500). Omit for a dynamic radius picked from local landmark density: ~5 km in dense cities up to ~100 km in remote areas. Values below 1 clamp to 1. |
limit | int | no | Max results (1–100, default 10). For agent context, 3–5 is usually plenty. |
include_admin | bool | no | Include administrative-boundary entities (default false). A geocoding agent almost never wants the boundary polygon's centroid; turn on only for gazetteer/region-resolution callers. |
Response
{
"results": [
{
"osm_id": "3402555795",
"osm_type": "node",
"name": "金閣寺",
"name_en": "Kinkaku-ji",
"category": "buddhist place of worship",
"lat": 35.0394,
"lng": 135.7292,
"distance_meters": 1230.4,
"wikipedia": null,
"wikidata": null
}
],
"count": 5,
"radius_km": 5.0
}
| Field | Type | Description |
|---|---|---|
results | array | Matching places, best first. |
count | int | Number of results returned. |
radius_km | float | The radius actually used — when radius was omitted, this is the dynamically chosen one. |
Place object:
| Field | Type | Description |
|---|---|---|
osm_id | string | OpenStreetMap object ID. |
osm_type | string | node, way, or relation. Join osm_type[0].upper() + osm_id (e.g. "N3402555795") to get the ID that /places/details accepts. |
name | string | Primary name (typically the local language). |
name_en | string | null | English name when it exists and differs from name. |
category | string | null | Human-readable category ("cafe", "mountain peak", "railway station", …). |
lat, lng | float | Coordinates. |
distance_meters | float | Distance from the search center. |
wikipedia, wikidata | null | Always null in search responses — fetch via /places/details. |
POST /places/nearest
The N most relevant places around a coordinate — no query. Ranking blends distance with fame (a normalized encyclopedia + travel-writing signal), so a famous landmark outranks equally-near trivia. Use it for "what's around here" grounding: give your agent spatial awareness of a location it's working in.
Request
{
"lat": 35.71, "lng": 139.796,
"limit": 10,
"max_radius_km": 5
}
| Field | Type | Required | Description |
|---|---|---|---|
lat, lng | float | yes | The query point. |
limit | int | no | Places to return (1–100, default 10). |
max_radius_km | float | no | Hard distance cap in km (0–500, default 50). |
prominence_weight | float | no | Fame blend strength (0–10, default 2.0). Candidates are ranked by distance / (1 + prominence_weight × fame). 0 = pure nearest-by-distance. |
prominent_slots | int | no | Guaranteed spots for the most famous places within the radius (0–20, default 3), regardless of distance. This is what surfaces the landmark temple from a side street when the blend alone is crowded out by very-close trivia. 0 disables. |
include_admin | bool | no | Include administrative boundaries (default false). |
Response
Same shape as /places/search, ordered by blended effective distance.
Each result additionally carries fame (0..1 normalized score — the
ranking signal) and prom_pct (prominence percentile) when the place has
any signal. radius_km is the distance to the furthest returned place.
POST /places/details
OSM tag details for up to 50 places by ID. Use it to enrich search results —
/places/search intentionally omits these fields to keep agent context
lean.
Request
{ "osm_ids": ["N6960525765", "W12903132"] }
| Field | Type | Required | Description |
|---|---|---|---|
osm_ids | string[] | yes | 1–50 IDs in N/W/R + digits format — the joined form of osm_type + osm_id from search results. |
Response
An object keyed by the requested IDs. IDs that don't exist are simply absent. Only fields present in the data are included per place:
{
"W12903132": {
"name": "Space Needle",
"name_en": "Space Needle",
"website": "https://www.spaceneedle.com/",
"phone": "+1-206-905-2100",
"opening_hours": "Mo-Th 09:00-22:00; Fr-Su 08:00-00:00",
"wikipedia": "en:Space Needle",
"wikidata": "Q5317",
"wheelchair": "yes",
"fee": "yes",
"_sources": {"website": "osm", "phone": "osm"}
}
}
Possible fields: name, name_en, website,
phone, email, opening_hours,
wikipedia, wikidata, cuisine,
wheelchair, fee, stars,
description, image, address,
socials, plus extra_websites / extra_phones /
extra_emails arrays when multiple values exist.
opening_hours uses
OSM opening-hours
syntax (Mo-Th 09:00-22:00; Fr-Su 08:00-00:00) — compact,
machine-parseable, and LLMs read it natively.
Contact fields fall back to the Overture places dataset when OSM
lacks them; _sources records where each field came from
("osm" or "overture").
Errors: HTTP 400 with code INVALID_OSM_ID if any ID
isn't N/W/R + digits.
POST /web/search
Search a curated corpus of travel blogs and trip reports — the written evidence
behind places. Same key, same rate limit and quota as the places endpoints; each call
counts as one request. Use it when the agent needs why / how (which
hut to book, whether the trail is open in May, what a day in Chamonix actually looks
like), and /places/* when it needs where.
Request
{ "query": "best day hikes in the Dolomites", "limit": 3 }
| Field | Type | Required | Description |
|---|---|---|---|
query | string | yes | Natural-language query. Hybrid semantic + BM25 retrieval, so both concept phrasing and exact place names work. |
limit | int | no | Max results (1–20, default 5). |
Response
{
"query": "best day hikes in the Dolomites",
"limit": 3,
"count": 3,
"results": [
{
"title": "29 Best Day Hikes In The Dolomites Rated From Easy To Hard",
"url": "https://inafarawayland.com/top-day-hikes-in-the-italian-dolomites",
"blog": "inafarawayland.com",
"score": 0.812,
"context": "One-line summary of what this passage covers",
"content": "The matched passage, truncated to ~500 characters…",
"semantic_score": 0.741,
"bm25_score": 18.52
}
]
}
content is the retrieved chunk, not the whole article — cite the
url and feed the passage to the model. blog is the source
domain for attribution.
POST /web/page
Full text of one article from the corpus by URL (the url of a
/web/search result). Only URLs already in the corpus resolve — this is
not a live fetcher.
Request
{ "url": "https://inafarawayland.com/top-day-hikes-in-the-italian-dolomites" }
Response
{ "url": "…", "title": "…", "blog": "inafarawayland.com", "content": "…", "content_length": 12345 }
Errors: HTTP 404 if the URL is not in the corpus.
Errors
| Status | Meaning |
|---|---|
| 400 | Invalid parameters (JSON body includes error, message, code). |
| 401 | Missing or invalid API key. |
| 422 | Validation error (field out of range — body lists the offending fields). |
| 404 | Unknown route under /web/, or /web/page URL not in the corpus. |
| 429 | Rate limit (2 r/s burst 10) or monthly quota exceeded. |
| 5xx | Server error — safe to retry with backoff. |
In an agent loop, prefer catching errors in your dispatch layer and returning an empty result list (optionally with a reason) to the model, rather than surfacing raw HTTP errors — models handle "no results found" far more gracefully than stack traces. See Building a places harness.
Data licensing
Web results (/web/*) return short excerpts of third-party blog posts with
their source URL; link back and attribute the source blog. The excerpts are for
retrieval and citation, not republication.
Place data © OpenStreetMap
contributors, available under the Open Database License (ODbL). You may
cache, store, and train on API responses. Attribute "© OpenStreetMap
contributors" where you use the data. Contact fields marked "overture" in
_sources come from the
Overture Maps Foundation dataset
(CDLA-Permissive-2.0).