Query contracts, paging, and errors
Inspect a service before constructing a request. Its detail response is the authority for accepted inputs, selectable outputs, examples, and source capabilities.
Request shape
All service types accept the same envelope:
{
"inputs": {},
"select": [],
"order": "asc",
"limit": 100,
"cursor": null
}
| Field | Rule |
|---|---|
inputs |
Use the names and types in contract.inputs. |
select |
Use output names from contract.outputs. An empty list requests every output. select decides what is read, not only what is returned: an unselected field is never fetched, so a narrow select is faster as well as smaller. Event services also accept the blocks every occurrence carries — sites, participants, media, classification, names — which are read only when named once select is given. |
order |
asc or desc; chronological for time series and events, stable source order for lookups. |
limit |
Rows returned in this page. It does not truncate the complete result. |
cursor |
Omit on the first request; return nextCursor unchanged for the next page. |
$apply |
Time series: answer with groups instead of rows — an OData $apply expression. See Aggregate. |
Each input descriptor can include a default, description, and choices. The admin query
form renders choices as a dropdown. inputAlternatives lists valid required-input
combinations; satisfy one complete combination, not part of several.
Grouped time-series services require the series input. Select a member from the service's
member directory or use the canonical member URL in a detail example.
Time ranges
from is inclusive and to is exclusive. Datetimes are UTC on the wire. Use the exact
field names and bounds returned by the service contract; not every lookup has a time range.
The date range defines the complete temporal result. On-demand services split it into the
time and row chunks supported by their source, retain every fetched row in ClickHouse, and
then return the requested response page.
Result metadata
items contains rows shaped by the selected outputs. The envelope also identifies the
service and contract revisions, provenance, delivery source, fetch time, cache reuse, and
the next cursor. For grouped services it also identifies the selected member series.
The source value explains where the result came from:
clickhouse: stored data whose coverage is valid (served without a source call);clickhouse_stale: stored data served although part of the window is missing, because the source is unavailable —coverage.missingnames the missing sub-intervals;fdw: a live on-demand source query filled at least one gap of the window;cache: a lookup answered from the source-layer cache within its freshness period; orcache_stale: a lookup answer past its freshness period, served because the source failed.
Every data response also carries coverage: served lists the valid stored intervals the
page was read from (half-open, clipped to the request window, each with its fetchedAt)
and missing lists the sub-intervals of the window with no valid coverage after this
request. Both are empty for a stored service.
Answer now, fill in later
A request that needs data QuickLookup has not fetched yet — a place and a day nobody has
asked about — normally waits while the source is called, which takes from a fraction of a
second to several seconds. An interface that shows many things at once should not wait for
the slowest of them. Ask to be answered now instead, with the standard Prefer header
(RFC 7240):
POST /v1/services/{serviceGid}/query
Prefer: respond-async
Whatever is already stored or computed quickly is answered at once, exactly as before
(200). What still has to be fetched comes back as 202 Accepted, with a
Retry-After header and a pending block, while QuickLookup fetches it:
{
"items": [],
"pending": {
"reason": "sourcing",
"message": "One moment, this data is being sourced.",
"retryAfterMs": 1800
}
}
Ask the same question again after retryAfterMs (with a little random delay, so that many
clients do not ask at the same instant). The answer is then complete (200), or still
pending with a new estimate. Never keep a 202 answer as final — it is sent with
Cache-Control: no-store.
reason |
Meaning |
|---|---|
sourcing |
Nothing is stored for the window yet; QuickLookup is fetching it. |
refreshing |
items holds older data; newer data is being fetched. |
reading |
The answer took longer than the caller's budget and is still being read. |
busy |
QuickLookup holds as much work as it may; nothing was started. |
Prefer: respond-async, wait=2 lets the answer take up to two seconds (at most eight)
before it is answered 202; the default is 0.8 seconds. A request without
respond-async waits for its sources, as every request always has.
Asking again is cheap: identical questions share one fetch, however many callers ask, and each source is asked for only a few things at once, so a burst of visitors never becomes a burst of calls to the source.
Warming data ahead of visitors? Say so with the standard Priority header
(RFC 9218): Priority: u=7 (any urgency of 5 or
more) marks the request as background work. Its fetches wait behind anyone who is waiting and
never take a source's last lane, so a visitor asking about a new place is never queued behind
a warm-up. A visitor asking for exactly what a warm-up queued moves it to the front. The same header works on /v1/data/query,
/v1/events/query, /v1/events/search, /v1/lookups/{key}/query, /v1/resolve and
/v1/context/day.
Many requests in one round trip
A page that shows a place's day asks many questions at once. Send them together as an OData JSON batch (OData JSON Format v4.01 §19):
POST /v1/$batch
Authorization: Bearer <token>
Prefer: respond-async
Content-Type: application/json
{
"requests": [
{"id": "morning", "method": "POST", "url": "services/{serviceGid}/query",
"body": {"inputs": {"lat": 64.14, "lon": -21.93, "date": "2026-10-03", "period": "Morning"}}},
{"id": "evening", "method": "POST", "url": "services/{serviceGid}/query",
"body": {"inputs": {"lat": 64.14, "lon": -21.93, "date": "2026-10-03", "period": "Evening"}}},
{"id": "events", "method": "POST", "url": "events/search", "body": {"…": "…"}}
]
}
{
"responses": [
{"id": "morning", "status": 200, "headers": {"etag": "…", "cache-control": "…"}, "body": {"items": ["…"]}},
{"id": "evening", "status": 202, "headers": {"retry-after": "2"}, "body": {"items": [], "pending": {"…": "…"}}},
{"id": "events", "status": 200, "headers": {"…": "…"}, "body": {"items": ["…"]}}
]
}
Each request is answered exactly as if it had been sent alone: its own status (200, 202,
304, 404, 503 …), its own ETag, Retry-After and body. Ask again only for the ones
that were 202. The batch's own headers — Authorization, Prefer, Priority — apply to
every request unless a request names its own in headers; If-None-Match belongs in a
request's headers. url is relative to /v1/ or an absolute path under it. Requests run
side by side and come back in the order they were asked, up to 100 per batch. dependsOn and
atomicityGroup are refused: every request stands alone.
Validators
The three data query routes (/v1/data/query, /v1/events/query,
/v1/services/{serviceGid}/query) return a weak ETag derived from the definition revision,
the newest served fetch time and the request identity, and a Cache-Control: private,
max-age=<seconds> set to the time until the earliest served interval leaves its freshness
period (0 when nothing was served from coverage). Send the ETag back as If-None-Match
to receive 304 Not Modified without a body while the page is unchanged; a refresh that
lands revised values changes the ETag.
Compression
Send Accept-Encoding: gzip (browsers and Node's fetch do by default) and answers larger
than a kilobyte come back gzip-encoded — an event page, which repeats each feed's provenance
on every item, shrinks about tenfold. The answer is the same either way, which is why ETags
are weak (W/"…"): they name the answer, not its bytes.
Paging
A cursor belongs to the exact service, revision, request inputs, selection, and ordering
that produced it. Do not edit it or reuse it with a changed request. Stop when
nextCursor is null.
Errors
REST errors use this shape:
{
"code": "invalid_request",
"message": "The request could not be accepted.",
"requestId": "…",
"fields": {"inputs.from": ["required"]}
}
| Status | Meaning |
|---|---|
202 |
Not final yet (only with Prefer: respond-async): ask again after Retry-After. |
400 |
Malformed envelope, filter, or cursor. |
401 |
Missing, invalid, or expired bearer token. |
403 |
The identity is known but the operation requires a higher role. |
404 |
The object is absent or not visible; the API does not distinguish those cases. |
409 |
Draft version or lifecycle conflict. |
422 |
Inputs, output selection, types, or bounds violate the published contract. |
503 |
The source is unavailable and no reusable result can be returned. |
Keep requestId when reporting a failure. Correct field errors before retrying; retry a
503 only after the source recovers or the documented retry policy permits it.