Skip to content

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.missing names 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; or
  • cache_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.