Query API recipes

Each recipe is a complete query you can send as it is, then change to fit. They run on any account with Builder access, build on the overview, and use the fields described in the reference.

A small client

Two helpers cover everything below. In Python:

import os
import requests

API = "https://mcp.hiasynth.co/v1"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {os.environ['HIASYNTH_KEY']}"

def query(spec):
    r = session.post(f"{API}/query", json=spec, timeout=100)
    body = r.json()
    if r.status_code != 200:
        raise RuntimeError(f"{r.status_code} {body.get('error')}: {body.get('message')}")
    return body

def query_all(spec):
    """Every group, following the pages."""
    groups, offset = [], 0
    while offset is not None:
        body = query({**spec, "offset": offset, "limit": 1000})
        groups += body.get("groups", [])
        offset = body.get("page", {}).get("next_offset")
    return groups

And in JavaScript or TypeScript:

const API = "https://mcp.hiasynth.co/v1";

export async function query(spec) {
  const r = await fetch(`${API}/query`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.HIASYNTH_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(spec),
  });
  const body = await r.json();
  if (!r.ok) throw new Error(`${r.status} ${body.error}: ${body.message}`);
  return body;
}

Keep the key on your server. The Hiasynth-Data-Version response header tells you which release answered, so a cache keyed on it stays correct across releases.

An either-or audience

Women in Copenhagen who are either studying or job-seeking, or under 25 and working, by education:

{
  "place": "Copenhagen",
  "where": [
    { "attribute": "sex", "op": "=", "value": "F" },
    { "any": [
      { "attribute": "weekly_schedule_profile", "op": "=", "value": "student_or_seeking" },
      { "all": [
        { "attribute": "age", "op": "<", "value": 25 },
        { "attribute": "employment", "op": "=", "value": "employed" }
      ]}
    ]}
  ],
  "group_by": ["education"]
}

any is or, all is and, and they nest. Check a coded attribute's values in GET /v1/attributes?search=schedule before filtering on it; a value the attribute does not hold comes back as a 400 listing the valid ones.

A population pyramid with income

Everyone in Vienna aged 20 and over, by age band, sex and income quartile, with the median personal income in each group:

{
  "place": "Vienna",
  "where": [{ "attribute": "age", "op": ">=", "value": 20 }],
  "group_by": [
    { "attribute": "age", "bins": [30, 40, 50, 60, 70] },
    "sex",
    "income_q"
  ],
  "metrics": [{ "op": "median", "attribute": "person_net_income_tlc", "as": "median_income" }],
  "order_by": { "field": "age", "desc": false },
  "limit": 1000
}

That is 6 bands × 2 sexes × 4 quartiles, 48 groups in one call. median_income is in thousands of euros here, because Vienna is in one country.

Where an audience concentrates

Households with an electric car, as people, in every municipality of Bavaria, ranked by how concentrated they are:

{
  "place": "Bavaria",
  "where": [{ "attribute": "bev_owner", "op": "=", "value": true }],
  "group_by": [{ "geo": "lau" }],
  "compare_to": "scope",
  "order_by": { "field": "lift_shrunk", "desc": true },
  "limit": 25
}

Each group carries count (people in an EV home), base_count (everyone there), rate_pct and lift against Bavaria as a whole. Sorting by lift_shrunk keeps a 400-person village with three EVs from topping the list on luck. Sort by count instead for the biggest markets in absolute numbers.

People, by their home

Children under 15 in Lyon, by whether their home is owned or rented, with the average income decile of those homes and the share living in a flat:

{
  "place": "Lyon",
  "where": [{ "attribute": "age", "op": "<", "value": 15 }],
  "group_by": ["tenure"],
  "metrics": [
    { "op": "avg", "attribute": "hh_net_income_decile", "as": "avg_income_decile" },
    { "op": "rate",
      "where": [{ "attribute": "dwelling_type", "op": "=", "value": "apartment" }],
      "as": "in_apartment_pct" }
  ]
}

tenure, hh_net_income_decile and dwelling_type describe the home. Counting people, each child carries their home's values, so the counts are children and the averages are over children.

Spending and footprint

Among the fifth of Stockholm households that spend most on eating out, by tenure, with the median carbon footprint and the share of the budget that goes on eating out:

{
  "unit": "households",
  "place": "Stockholm",
  "where": [{ "attribute": "spend_restaurants_cafes_tlc", "op": "top_pct", "value": 20 }],
  "group_by": ["tenure"],
  "metrics": [
    { "op": "median", "attribute": "co2_total_t", "as": "median_co2_t" },
    { "op": "avg", "attribute": "spend_restaurants_cafes_share", "as": "eating_out_budget_share" }
  ],
  "compare_to": "scope"
}

top_pct cuts within Sweden, so the top fifth means the same thing anywhere you run it. With compare_to, rate_pct is the share of each kind of home that belongs to the big spenders, and lift shows which are over-represented compared with all Stockholm households.

A full map layer

The share of people aged 65 and over in every NUTS3 region of Germany, for a choropleth:

groups = query_all({
    "place": "Germany",
    "group_by": [{"geo": "nuts3"}],
    "metrics": [{"op": "rate",
                 "where": [{"attribute": "age", "op": ">=", "value": 65}],
                 "as": "over_65_pct"}],
})
layer = {g["nuts3"]: g["over_65_pct"] for g in groups}

The nuts3 codes match the official NUTS 2021 boundaries, so layer joins straight onto a GISCO or Eurostat shapefile. For municipalities use "geo": "lau", and for the finest statistical units "geo": "unit" inside a city.

Percentiles within a country

Personal net income by education in the Netherlands, at the median, the 75th and the 90th percentile:

{
  "country": "Netherlands",
  "where": [{ "attribute": "age", "op": "between", "value": [25, 64] }],
  "group_by": ["education"],
  "metrics": [
    { "op": "median", "attribute": "person_net_income_tlc", "as": "p50" },
    { "op": "quantile", "attribute": "person_net_income_tlc", "q": 0.75, "as": "p75" },
    { "op": "quantile", "attribute": "person_net_income_tlc", "q": 0.9, "as": "p90" }
  ]
}

To compare income across countries, group by "geo": "country" and each country's figures come back in its own currency, or use income_q and hh_net_income_decile, which are national ladders.

A dashboard in one request

Three panels for one city, sent together:

const r = await fetch("https://mcp.hiasynth.co/v1/query/batch", {
  method: "POST",
  headers: { Authorization: `Bearer ${process.env.HIASYNTH_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    queries: [
      { place: "Gothenburg", group_by: [{ attribute: "age", bins: [18, 30, 45, 65] }] },
      { place: "Gothenburg", unit: "households", group_by: ["tenure"] },
      { place: "Gothenburg", group_by: [{ geo: "unit" }], order_by: { field: "count" }, limit: 10 },
    ],
  }),
});
const { results } = await r.json();
const [ages, homes, districts] = results.map((x) => x.body);

Each result carries its own status, so one panel's error leaves the others intact. Each query that answers is charged as one.