Census age-by-sex counts for 124,602 districts across 107 markets, aggregated to a fixed 20–39 band, plus cost, safety, salary and traits for 983 cities. Every response says what level each figure was measured at.
Start
- Sign in and create a key on the Pro page. The key is shown once.
- Send it as a Bearer token.
- Call
/api/v1to see which endpoints your key opens.
curl -H "Authorization: Bearer gmx_live_..." \
https://geomaxxing.org/api/v1
Keys are for server-side use. Anything pasted into a browser page is public, and so is a key committed to a repository. Revoke on the Pro page.
What a key opens
The API is for Pro accounts. Anyone signed in can create a key, and it starts working the moment the account unlocks.
| What | Pro | Without Pro |
|---|---|---|
| City measurements, country indicators, national age bands | yes | no |
| Geomaxxing Score, rank and component breakdown | yes | no |
| District census layer, per unit, all 17 age bands | yes | no |
| Dating-pool model and unmatched-women counts | yes | no |
| Fiance visa rate per 100,000 women | yes | no |
| Per-market census source | yes | no |
| Requests per minute | 30 | — |
| Requests per day | 2,000 | — |
Your account is read on every request, so unlocking Pro starts an
existing key working without reissuing it, and a lapsed subscription stops it. A
request from an account without Pro returns 402 payment_required.
Endpoints
All GET. All return JSON.
| Path | Returns |
|---|---|
/api/v1 | Your tier, your remaining quota, the endpoint list. |
/api/v1/meta | Field units and directions, level definitions, dataset counts. The scoring model on a Pro key. |
/api/v1/countries | Every market. ?status=live |
/api/v1/countries/{iso} | One market, with its 17-band national age arrays. |
/api/v1/cities | 983 cities. ?iso= ?country= ?region= ?max_ratio= ?min_ratio= ?max_cost= ?min_safety= ?min_pop= ?sort= ?limit= ?offset= |
/api/v1/cities/{id} | One city, for example phl-metro-manila. |
/api/v1/districts/{iso} | The finest census level for that market. ?level= ?from= ?to= ?q= ?bands=0 ?limit= ?offset= |
/api/v1/pool | The dating-pool model. ?iso= ?from= ?to= |
/api/v1/fiance | K-1 admissions per 100,000 women aged 20–39. ?iso= |
Response shape
Every list answers in the same envelope. meta.filters echoes the
parameters the server actually understood, so a filter that did nothing is visible
rather than silent.
GET /api/v1/cities?iso=THA&sort=ratio&limit=2
{
"object": "list",
"data": [
{
"id": "tha-chiang-mai",
"name": "Chiang Mai",
"country": "Thailand",
"iso": "THA",
"ya": 94.1,
"ya_level": "city",
"cost": 730,
"safety": 66,
"score": 78,
"rank": 41,
"score_coverage": 1,
"score_provisional": false
}
],
"meta": {
"count": 1, "total": 12, "limit": 2, "offset": 0,
"sort": "ratio", "tier": "pro",
"filters": { "iso": "THA" },
"version": "2026-08-30"
}
}
ya is men per 100 women aged 20–39: lower means more
women. ya_level says what it was measured at, in that market's own words:
bashki, municipio, county, and 55 more. Do not
match a list of them. Exactly one value is special: country means no finer
measurement exists for that city, and it must not be presented as a city figure.
/api/v1/meta returns the full observed list.
Python
The whole city table into a dataframe. Keep the key in an environment variable: a key in a notebook is a key in your shell history and in every copy of that notebook.
import os, time, requests, pandas as pd
S = requests.Session()
S.headers["Authorization"] = f"Bearer {os.environ['GEOMAXXING_KEY']}"
BASE = "https://geomaxxing.org/api/v1"
def get(path, **params):
"""One request, waiting out a 429 rather than failing on it."""
while True:
r = S.get(f"{BASE}/{path}", params=params)
if r.status_code == 429:
# Retry-After points at the REAL reset: the next minute for a burst,
# the next UTC midnight if the daily quota is gone. Never guess it.
time.sleep(int(r.headers.get("Retry-After", 10)))
continue
r.raise_for_status()
return r.json()
def pull(path, **params):
"""Walk every page. meta.total says how many rows exist."""
rows, offset = [], 0
while True:
body = get(path, **params, limit=1000, offset=offset)
rows += body["data"]
offset += len(body["data"])
if offset >= body["meta"]["total"] or not body["data"]:
return rows
cities = pd.DataFrame(pull("cities"))
# ya = men per 100 women aged 20-39. Lower means more women.
#
# ya_level says WHAT IT WAS MEASURED AT, in that market's own words: "bashki"
# in Albania, "municipio" in Mexico, "SA3 (statistical area)" in Australia.
# There are 58 such values, so DO NOT match a list of them. Exactly one value
# is special: "country" means no finer measurement exists for that city.
# Drop those before you rank, or you rank a country under a city's name.
sub = cities[cities["ya_level"].notna() & (cities["ya_level"] != "country")]
print(sub.nsmallest(10, "ya")[["name", "country", "ya", "ya_level", "cost"]])
That returns 907 rows of the 983: 62 are country-level and 14 carry no ratio at all. Neither is thrown away silently, and neither is guessed.
Districts, and what a big market costs
One market's finest census level, one row per unit. This is the call worth caching.
districts = pd.DataFrame(pull("districts/GBR", bands=0))
print(len(districts), districts["sex_ratio"].median())
The United Kingdom is 46,386 units. At 1,000 a page that is 47 requests, and the limit is 30 a minute, so the loop above sleeps through two blocks and finishes in about 80 seconds (measured). It is not stuck. Most markets are one or two pages.
bands=0 drops the 17-band age arrays and is roughly a tenth
of the bytes. Leave it out when you want the full age structure per unit.
Rate limits
Two limits run at once: a per-minute burst and a daily quota. Both are counted per key, and the counting is approximate under a burst.
| Header | Meaning |
|---|---|
RateLimit-Limit | Requests allowed this minute. |
RateLimit-Remaining | What is left of it. |
RateLimit-Reset | Seconds until the minute rolls over. |
X-Quota-Limit | Requests allowed today. |
X-Quota-Remaining | What is left of it. Resets at 00:00 UTC. |
Retry-After | On a 429 only. Seconds to wait. |
A 429 carries Retry-After pointing at the real reset: the
next minute for a burst, the next UTC midnight for a quota. Read it rather than
retrying on a fixed timer.
Repeatedly sending a key that does not work is refused separately,
with too_many_bad_keys, and that limit is per network rather than per
key. If a key stops working, create a new one instead of retrying the old one.
A second limit runs at the network edge, before any of this: 5 requests per 10 seconds per IP. It returns Cloudflare's own block page rather than JSON, so a client that receives HTML from this API is being shaped there. Stay under 30 requests a minute and you will not meet it.
Errors
Every failure returns {"error":{"code","message","docs"}}. Branch on
code: the message is prose and will be reworded.
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_iso | Not an ISO 3166-1 alpha-3 code. Three letters, for example THA. |
| 400 | invalid_band | from is not below to. |
| 401 | no_credential | No key was sent. |
| 401 | invalid_key | Unknown, malformed or revoked. The three are not distinguished. |
| 402 | payment_required | The account is not on Pro. |
| 404 | not_found | No such city or market. |
| 404 | no_district_layer | That market publishes nothing below the national level. |
| 405 | method_not_allowed | The API is read-only. |
| 429 | rate_limited | Burst or daily quota. scope says which. |
| 429 | too_many_bad_keys | Too many invalid keys from one network. Stop retrying a key that does not work. |
| 503 | api_disabled | The API is not open yet. |
| 503 | data_unavailable | A dataset could not be read. Retry. |
A missing measurement is null, never a substituted average
and never zero. A country with no district layer is a 404 with a reason, never an
empty list.
Keys and terms
- A key belongs to one account. Five per account.
- Only a hash is stored. A lost key is replaced, never recovered.
- Revoking takes effect within seconds.
- Sources and their own terms are listed on the methodology page. Site terms are at /terms/.
- No key, and a file rather than a request: the data downloads are the same census figures as CSV, with the licence stated per file.