Unmapped Dataworks

Docs

The API, call by call

Every dataset is built to be read by people and by systems: free tool pages for everyone, and an API, CSV files and spreadsheet links behind them. Each record names its source, and each numbered release says what changed.

Open now: the free tools and their spreadsheet templates, with no sign-up. Not open yet: accounts and API keys, while we finish the datasets. These docs show what every call returns. Nothing is for sale.

Records and paging

Needs a key (not open yet)

GET /v1/datasets/{id}/records returns records a page at a time: 100 as JSON, up to 1,000 as CSV (?limit= asks for fewer). When there are more, the answer has a next link (and a Link header); follow it until next is null.

{
  "dataset": "dam-levels",
  "version": "2026-10-26",
  "count": 227,
  "offset": 0,
  "returned": 100,
  "records": [ … ],
  "next": "https://api.unmapped.co.za/v1/datasets/dam-levels/records?cursor=…"
}

A next link stays on the release it started on, so a page-through never mixes two releases, even if new data arrives halfway. Time series come newest first.

Filters

Needs a key (not open yet)

Each dataset has its own filters (listed under datasets), and every one takes from and to dates. Combine them freely:

/v1/datasets/exchange-rates/records?currency=USD&from=2026-01-01
/v1/datasets/dam-levels/records?province=ZA-WC
/v1/datasets/electricity-tariffs/records?distributor=CPT&category=residential

An unknown parameter is an error that names the filters that work, so a typo never quietly returns everything.

Latest values

Needs a key (not open yet)

GET /v1/datasets/{id}/latest returns the newest record for each currency, rate, dam or tariff: often all a website, dashboard or quote needs. It takes the same filters, and ?limit= (up to 500).

CSV and Excel

Needs a key (not open yet)

Add .csv to records or latest (or ?format=csv). Nested values become columns: source.publisher, source.url. Where a record holds a list, such as a tariff’s charges, there’s one row per item, with the record’s own fields repeated. Files are UTF-8 with a byte-order mark, so Excel opens them correctly.

Add .xlsx instead for an Excel workbook with the same columns: dates are real dates, numbers are numbers, and the header row is frozen with filters on. Its Read me sheet says what the dataset is, which release and filters the file holds, where the data comes from, the terms, and what every field means. Both come up to 1,000 rows at a time; in the portal, a dataset’s Explore and download makes either with a click.

Spreadsheets and Power BI

Needs a key (not open yet)

Build the CSV address with your filters and a key, for example:

https://api.unmapped.co.za/v1/datasets/exchange-rates/records.csv?currency=USD&from=2026-01-01&key=um_live_your_spreadsheet_key

The portal’s explorer makes these links for you. Every refresh counts towards your daily records, so filter to what you need.

Releases and changes

Needs a key (not open yet)

Data arrives in releases. GET /v1/datasets/{id}/releases lists them, newest first, with how many records each added, changed and removed. The rand is kept current by a daily job and releases when the European Central Bank publishes; dam levels release with each weekly report; the Gazette-based datasets release when a new notice has been read and checked.

To fetch only what a release added or changed, add changed_in with its version: /v1/datasets/dam-levels/records?changed_in=2026-11-02. It reads that release as it was, so it works for any release still listed. Webhooks send you this address when a release lands.

Our promise for v1: changes only add things. We don’t rename or remove a field, endpoint or filter without telling key holders by email at least 90 days before and listing it here. The API is also described for tools at /v1/openapi.json (OpenAPI 3.1), which Postman and code generators read.

Check a release’s fingerprint

Needs a key (not open yet)

Every release has a fingerprint: one SHA-256 over every record in it. /releases gives it for each release as sha256, with sealed_at, the time it was taken, and a dataset’s own address gives the live release’s. The same fingerprints are public on the change log. A release that still gives its fingerprint has not changed by one character since it was taken. A fingerprint is not encryption, and it does not show that a figure was read correctly: what it proves and what it does not.

The sum. Take the release’s records in the order the API returns them. Each record is one line: its id, a tab, the record as compact JSON with its keys in the order served (what JSON.stringify gives in JavaScript), then a newline. The fingerprint is the SHA-256 of all the lines, as UTF-8. In Node 18 or later:

import { createHash } from 'node:crypto';
const [dataset, asAt] = process.argv.slice(2);          // fuel-zone-lookup [2026-11-09]
const h = createHash('sha256');
let url = `https://api.unmapped.co.za/v1/datasets/${dataset}/records?limit=100${asAt ? `&as_at=${asAt}` : ''}`, version;
while (url) {
  const page = await (await fetch(url, { headers: { Authorization: `Bearer ${process.env.UNMAPPED_KEY}` } })).json();
  if (page.error) throw new Error(page.message);
  for (const r of page.records) h.update(`${r.id}\t${JSON.stringify(r)}\n`);
  version = page.version; url = page.next;
}
console.log(version, h.digest('hex'));                  // compare with sha256 in /releases, or on /changes

Without a date it reads the live release; with as_at it reads the release that was live then, and paging stays on that release. Every record read counts towards your daily records, so a large release may take a higher limit than the default: write to us. Source files have fingerprints of their own, and those need no key: each notice page prints its file’s SHA-256 beside the download.

As at a date

Needs a key (not open yet)

Add as_at to records or latest (and their CSV, Excel and GeoJSON forms) to get what we published on a past date: /v1/datasets/dam-levels/latest?as_at=2026-11-07. A date means the end of that day, South African time; a time such as 2026-11-08T18:00 (South African time, or add Z or an offset) means that moment. The answer comes from the release that was live then, with its version, its released_at and the as_at moment you asked for; paging stays on that release. It combines with the filters, including from and to, which pick records by the dates their figures applied. A date before a dataset’s first release answers 404 and names that release’s date. It doesn’t go with changed_in, which names a release itself. Older releases keep to the same terms as the live one, so a figure we may no longer publish isn’t served from them either.

Fact addresses

Needs a key (not open yet)

Every figure behind a published record is a fact with a permanent address, um:fact:{uid}, that stays the same for good. GET /v1/facts/{uid} (or /v1/facts/um:fact:{uid}) answers with the figure and where it comes from. It needs a key and the fact’s dataset switched on, and counts as one record.

{
  "id": "um:fact:4a39fcfb-1a98-4074-9d82-fc9e9771689e",
  "dataset": "exchange-rates",
  "instrument": { "id": "um:inst:fx:zar:gbp", "name": "Rand per British pound", "unit": "ZAR/GBP", … },
  "value": 20.5438, "unit": "ZAR/GBP",
  "effective_from": "2021-10-06", "effective_to": "2021-10-07",
  "known_from": "2026-11-09T18:46:26.762Z", "known_to": null, "current": true,
  "document": { "id": "um:doc:ecb-eurofxref-hist-2026-11-09", "title": "Euro foreign exchange reference rates, full history to 2026-11-09 (eurofxref-hist.zip)", "publisher": "European Central Bank", "page": null, "file_sha256": "ea8f699d…", … },
  "method": "derived", "formula": "round(ZAR per EUR / GBP per EUR, 4)",
  "inputs": [ { "role": "currency_per_eur", "fact": "um:fact:4c273869-…" }, { "role": "zar_per_eur", "fact": "um:fact:7c48ad5d-…" } ],
  "flags": [], "note": null, "event": null, "replaced_by": []
}

A record’s facts

Add include=facts to records or latest (JSON only) and each record gains _facts: every fact behind it, with its address, figure, unit and both dates. It works with the filters, paging, changed_in and as_at, and the next page keeps it. Without it, records are exactly as before. A record with no figures (a boundary, a fuel zone) has []. A few older releases were made before facts existed; their records have _facts: null and the answer says so in facts_note. /v1/datasets/fuel-prices/latest?zone=9C&product=petrol_95_ulp&include=facts:

{
  "id": "9C-petrol-95-ulp-2026-11-04", "zone": "9C", "effective_from": "2026-11-04", "price": 3024, "previous": 3025, …,
  "_facts": [
    { "fact": "um:fact:7edba515-86d9-44d7-8cf7-d173ba2d87cc", "url": "https://api.unmapped.co.za/v1/facts/7edba515-86d9-44d7-8cf7-d173ba2d87cc",
      "instrument": "um:inst:fuel:9c:petrol-95-ulp:retail", "value": 3025, "value_text": null, "unit": "ZAc/l",
      "effective_from": "2026-10-07", "effective_to": "2026-11-04", "known_from": "2026-11-09T17:46:21.177Z", "known_to": null },
    { "fact": "um:fact:6381e2bb-3d41-41ba-8790-5a8276741702", …, "value": 3024, "unit": "ZAc/l", "effective_from": "2026-11-04", "effective_to": null, … }
  ]
}

Follow a fact’s url for its document, page and method. Reading the facts doesn’t count extra against your daily records.

Location lookup

Needs a key (not open yet)

GET /v1/lookup?lat=-33.9249&lon=18.4241 says which ward, local or metropolitan municipality, district and province a point is in, from the Municipal Demarcation Board’s boundaries. Switch on Ward and municipal boundaries to use it; each lookup counts as one record (two while new wards are waiting to take effect).

{
  "point": { "lat": -23.9045, "lon": 29.4689 },
  "date": "2026-10-30",
  "cycle": "2021",
  "in_force_from": "2021-11-01",
  "in_force_to": "2026-11-03",
  "ward": { "id": "ward-2021-93504022", "code": "93504022", "number": 22, "name": "Ward 22, Polokwane", "area_km2": 16.97 },
  "municipality": { "code": "LIM354", "name": "Polokwane", "category": "B", "category_name": "Local municipality" },
  "district": { "code": "DC35", "name": "Capricorn", "category": "C" },
  "province": { "code": "ZA-LP", "name": "Limpopo" },
  "next": { "cycle": "2026", "in_force_from": "2026-11-04", "ward": { "code": "93504039", "number": 39, … }, … },
  "attribution": "Boundaries: Municipal Demarcation Board (MDB), simplified by Unmapped Dataworks."
}

It answers for the boundaries in force today. Add date=2025-06-01 for the boundaries in force on another day, or cycle=2026 for one demarcation cycle. Before an election brings in new wards, next gives the ward the point will be in. A point at sea or outside South Africa answers 404. The shapes are simplified to 5 metres, so a point within a few metres of a boundary may fall either side of it; checked against the MDB’s own service, sample points agree.

The lookup needs coordinates; it doesn’t turn addresses into coordinates. To start from an address, use the site’s search, which answers with the ward and municipality. The free ward lookup on this site uses the same boundaries.

Everything at a point

Needs a key (not open yet)

GET /v1/here?lat=-33.9249&lon=18.4241 (or ?address=12 Plattekloof Road, Plattekloof) answers with what the site’s Here page shows for that spot, in one call: the ward and municipality under the boundaries in force (and the ward before they changed), the weather of the ward (with how coarse its 9 km grid is there), the fuel zone and petrol prices, who sells electricity and a typical bill, the tap water systems, the nearest dams and toll plazas, what changes next, and the municipality’s other figures. Each part carries its source and the release it comes from; releases lists every live release. It needs Ward boundaries switched on and counts as one record.

curl "https://api.unmapped.co.za/v1/here?address=12%20Plattekloof%20Road%2C%20Plattekloof" \
  -H "Authorization: Bearer um_live_…"

{
  "address": { "asked": "12 Plattekloof Road, Plattekloof", "matched_by": "context", "unsure": null, "other_matches": 0 },
  "point": { "lat": -33.87, "lon": 18.56, "title": "12 Plattekloof Road", "how": "house", "placed": "OpenStreetMap has number 12 on Plattekloof Road: the pin is on the house.", … },
  "ward": { "cycle": "2026", "code": "19100…", "number": …, "municipality": { "code": "CPT", "name": "City of Cape Town" }, …, "dataset": "ward-boundaries", "release": "…" },
  "weather": { "month": { "rain_mm": …, "hottest_c": … }, "grid": { "cells": 2, "note": "Weather here is averaged over a 9 km grid; …" }, "dataset": "weather-by-ward", "release": "…" },
  "fuel": { "zone": "…", "petrol_95": { "from": …, "to": …, "unit": "R/litre" }, "source": { "text": "Government Gazette …" }, "releases": [ … ] },
  "electricity": [ { "label": "…", "value": "R…", "source": { … }, "release": "…" } ],
  "water": { … }, "dams": [ … ], "tolls": [ … ], "next": [ … ], "facts": [ … ],
  "land": { … }, "greenness": { … }, "drought": { "level": "ward", "code": "19100…", "years": [ { "year": 2026, "index": [ … ], "class": [ … ], "spi3": [ … ], "spi12": [ … ], … } ] }, "fire": { "level": "ward", "code": "19100…", "years": [ { "year": 2026, "fires": [ … ], "fires_nh": [ … ], "fires_n20": [ … ], "burnt_ha": [ … ], "burnt_pct": [ … ], "seen": [ … ] } ] }, "air": { "level": "municipality", "code": "JHB", "years": [ { "year": 2025, "no2": [ … ], "o3": [ … ], "no2_col": [ … ], "so2_col": [ … ], "annual_no2": …, "who_no2_times": …, "modelled": true } ] }, "conditions": { "month": "…", "line": "…", "fields": [ … ] },
  "releases": { "fuel-prices": "…", "ward-boundaries": "…", … }
}

A point outside South Africa answers 400; one at sea, or an address we can’t find, 404. Where the page can’t tell which of several applies at the point (a town with several fuel zones, a municipality with Eskom areas) the answer says so, as the page does, and gives the range. Street names and positions come from OpenStreetMap (ODbL 1.0, © OpenStreetMap contributors).

A place’s conditions

Needs a key (not open yet)

GET /v1/conditions?level=ward&code=19100057 (or a municipality, district or province, and optionally month=2026-10) answers with one month of the place’s conditions, built only from our live releases: rain and the hottest day against normal, greenness against normal, the drought class, fire detections against the usual for the month and hectares burnt. Every figure carries its record (dataset, record id, release and its API address). What we do not hold is a gap with the reason ("gap": { "label": "Not out yet", "why": "…" }), never left out or filled in: surface water, the dams that supply the place, and air are gaps for now. The month defaults to the newest whole month of rain. Needs ward-boundaries switched on; one record.

{
  "place": { "level": "ward", "code": "19100057", "name": "Ward 57, City of Cape Town" }, "month": "2026-10",
  "line": "October 2026: … mm of rain (normal … mm), hottest day … °C, greenness …, no drought, … fire detections.",
  "fields": [ { "key": "rain", "value": "… mm", "normal": "… mm", "against": "…% of normal", "record": { "dataset": "weather-by-ward", "id": "ward-19100057-2026", "release": "…", "url": "https://api.unmapped.co.za/v1/datasets/weather-by-ward/records?level=ward&code=19100057&year=2026" }, "gap": null }, …,
    { "key": "air", "value": null, "gap": { "label": "Not in our data yet", "why": "…" } } ]
}

Electricity bills

Needs a key (not open yet)

GET /v1/bill?tariff=za-cpt-residential-domestic-2026-27&kwh=800&month=2026-11 prices a month’s electricity on a tariff from the Municipal electricity tariffs dataset (switch it on to use this), every charge line by line: inclining blocks, time-of-use periods in the right season, fixed charges per day or month, demand charges, and VAT where the decision says its tariffs exclude it. Each tariff priced counts as one record.

{
  "tariff": { "id": "za-cpt-residential-domestic-2026-27", "distributor": "City of Cape Town", "tariff": "Residential: Domestic", … },
  "month": "2026-11",
  "bill": {
    "season": "low_demand",
    "lines": [
      { "charge": "service_and_wires", "quantity": 30, "quantity_unit": "days", "rate": 2.14, "unit": "R/day", "amount": 64.2 },
      { "charge": "energy_block_1", "quantity": 600, "quantity_unit": "kWh", "rate": 348.37, "unit": "c/kWh", "amount": 2090.22 },
      { "charge": "energy_block_2", "quantity": 200, "quantity_unit": "kWh", "rate": 418.03, "unit": "c/kWh", "amount": 836.06 }
    ],
    "skipped": [],
    "total_excl_vat": 2990.48, "vat": 448.57, "total": 3439.05,
    "vat_note": "The decision doesn’t say whether its tariffs include VAT; 15% added, as municipal tariffs are usually quoted without it.",
    "vat_basis": { "asked": "source", "stated": null, "treated_as": "excluded", "assumed": true, "rate": 0.15 },
    "vat_flag": "VAT not stated by the source: assumed excluded and added at 15%"
  }
}

Hourly profiles and solar. POST /v1/bill takes the same fields as JSON, and a usage.profile: 24 hourly kWh figures for a typical day, or {"weekday": […], "saturday": […], "sunday": […]}. The hours are split into Eskom’s time-of-use periods for the month. Add a solar profile in the same shape, and the answer is the bill before and after solar, hour by hour: solar is used on site first, the rest is exported and credited at the tariff’s feed-in rates. Where a distributor keeps customers with solar on their usual tariff and credits exports at a separate embedded generation tariff, name that one as export_tariff.

curl -X POST https://api.unmapped.co.za/v1/bill \
  -H "Authorization: Bearer um_live_…" -H "Content-Type: application/json" \
  -d '{"tariff": "za-cpt-residential-domestic-2026-27",
       "export_tariff": "za-cpt-residential-small-scale-embedded-generation-2026-27",
       "month": "2026-12",
       "usage": {"profile": [0.3,0.3,0.3,0.3,0.3,0.4,0.8,1.2,0.9,0.7,0.6,0.6,0.7,0.7,0.8,1,1.4,1.8,1.9,1.6,1.2,0.9,0.6,0.4]},
       "solar": [0,0,0,0,0,0,0.1,0.5,1.2,1.9,2.4,2.7,2.8,2.6,2.2,1.6,0.9,0.3,0,0,0,0,0,0]}'

{ "solar": { "generated_kwh": 595.2, "used_on_site_kwh": 241.8, "exported_kwh": 353.4 },
  "before": { "total": 2531.48, … }, "after": { "total": 1013.76, … },
  "saving": { "total_excl_vat": 1319.76, "total": 1517.72 }, … }

A bill is as good as the tariff record behind it: check its flags and notes (an inferred unit, a figure the decision itself seems to misprint), which the answer carries with the tariff. Public holidays count as the day they fall on.

Shapes (GeoJSON)

Needs a key (not open yet)

For datasets with shapes (ward and municipal boundaries), add .geojson to records for a GeoJSON FeatureCollection, 25 features a page, with the same filters and next link:

/v1/datasets/ward-boundaries/records.geojson?level=municipality&cycle=2026&province=ZA-WC

Like CSV, a GeoJSON address can carry ?key=, so web maps that load an address can use it.

The boundaries’ licence: the Municipal Demarcation Board makes them free to use with acknowledgement, including in applications, but not to sell. So this dataset is free on every plan; credit the MDB wherever you show them, and don’t sell them on.

Bulk downloads (Parquet)

Needs a key (not open yet)

Each live dataset’s full history is also one file you can download at once: every record any release held, with the releases it ran between (from_version, and to_version, empty while it is current), and, in a second file, every fact those records cite (its value as exact text, its document, the sha256 of our stored copy and the page). The files are Parquet, which pandas, Polars, DuckDB, R and Power BI read directly. A new file is built with each release. The record column holds the record as JSON, exactly as /records serves it. Boundary shapes are not in them: use GeoJSON.

curl -OJ "https://api.unmapped.co.za/v1/datasets/fuel-prices/snapshots" -H "Authorization: Bearer um_live_your_key"   # the list
curl -OJ "https://api.unmapped.co.za/v1/datasets/fuel-prices/snapshots/<file>" -H "Authorization: Bearer um_live_your_key"
curl -O "https://unmapped.co.za/open/snapshots/<file>.sha256" && shasum -a 256 -c <file>.sha256

A download takes a key, like the records, and counts as one call, not as records. The list of files, with their sizes, licences and fingerprints, is open to everyone:

14 datasets, 23 files, 54.5 MB in all. Each file holds every record any release held (with the releases it ran between) or the facts they cite, as Parquet. Download one with your API key: curl -OJ -H "Authorization: Bearer um_live_…" https://api.unmapped.co.za/v1/datasets/<id>/snapshots/<file>. Then check it: shasum -a 256 -c <file>.sha256. The list as JSON: /open/snapshots.json.

Every file, with its size and fingerprint (23)
DatasetFilesLicence
Dam levels with full history
Release 2026-11-09
dam-levels-2026-11-09-records.parquet
681 record rows, 55 kB
sha256 f1c70ed2a3e4fe98…
dam-levels-2026-11-09-facts.parquet
3,405 facts, 127 kB
sha256 746279ef1561a99a…
From the Department of Water and Sanitation, a state work with no published reuse terms; kept fully open and free by the owner's decision of 10 November 2026 while permission is asked for. The file itself is under our terms.
Dams from space
Release 2026-11-10.3
dam-surface-from-space-2026-11-10.3-records.parquet
536 record rows, 1.0 MB
sha256 4acd12d313b56ea3…
dam-surface-from-space-2026-11-10.3-facts.parquet
52,353 facts, 1.7 MB
sha256 d28d85bf808198a3…
Contains modified Copernicus Sentinel data and Digital Earth Africa products (free and open); dam outlines from OpenStreetMap (ODbL 1.0, offered at https://unmapped.co.za/open#dam-outlines). The file itself is under our terms.
Municipal electricity tariffs
Release 2026-11-12
electricity-tariffs-2026-11-12-records.parquet
2,229 record rows, 370 kB
sha256 5eabbd20e31e83ab…
electricity-tariffs-2026-11-12-facts.parquet
16,423 facts, 753 kB
sha256 a6a790340572da28…
Figures from official texts (Government Gazette notices, regulator decisions): no copyright in them under the Copyright Act, s12(8)(a). The file itself is under our terms.
Rand exchange rates
Release 2026-11-11
exchange-rates-2026-11-11-records.parquet
28,536 record rows, 1.7 MB
sha256 542812d79297803f…
exchange-rates-2026-11-11-facts.parquet
57,072 facts, 1.6 MB
sha256 813cc8b9f1f6f411…
From the European Central Bank's euro reference rates, reproduced under the ECB's copyright notice (free use with the ECB cited and changes stated; the rand cross rates are worked out by us). The file itself is under our terms.
Fuel prices by zone and grade
Release 2026-11-04
fuel-prices-2026-11-04-records.parquet
16,641 record rows, 951 kB
sha256 89c78776da565267…
fuel-prices-2026-11-04-facts.parquet
16,641 facts, 416 kB
sha256 071a7b90c2dabafd…
Figures from official texts (Government Gazette notices, regulator decisions): no copyright in them under the Copyright Act, s12(8)(a). The file itself is under our terms.
Town to fuel-pricing zone lookup
Release 2026-10-07
fuel-zone-lookup-2026-10-07-records.parquet
385 record rows, 35 kB
sha256 741a7d192e06fa55…
The town list comes from National Treasury, whose copyright notice allows non-commercial informational use only; published free on that basis. The file itself is under our terms.
Greenness by ward
Release 2026-11-12
greenness-by-ward-2026-11-12-records.parquet
52,349 record rows, 6.2 MB
sha256 0a738fedef89049b…
From Digital Earth Africa's NDVI anomaly (CC BY 4.0) and the Copernicus Land Monitoring Service's NDVI (full, open and free access; source: Copernicus Land Monitoring Service). The file itself is under our terms.
Land cover, built-up area and population by ward
Release 2026-11-12
land-and-population-by-ward-2026-11-12-records.parquet
14,277 record rows, 1.7 MB
sha256 676161300e37f8b5…
From ESA WorldCover, the JRC's Global Human Settlement Layer and WorldPop, each under CC BY 4.0. The file itself is under our terms.
Reference interest rates and legal limits
Release 2026-11-09.3
reference-rates-2026-11-09.3-records.parquet
233 record rows, 26 kB
sha256 b8c0a6811b3efa87…
reference-rates-2026-11-09.3-facts.parquet
233 facts, 15 kB
sha256 6b28b50054b4b7e4…
Figures from official texts (Government Gazette notices, regulator decisions): no copyright in them under the Copyright Act, s12(8)(a). The file itself is under our terms.
Toll plazas and tariffs
Release 2026-11-09.2
toll-tariffs-2026-11-09.2-records.parquet
1,164 record rows, 93 kB
sha256 7c148e668469875a…
toll-tariffs-2026-11-09.2-facts.parquet
2,392 facts, 70 kB
sha256 77f6b6a0dc546a6b…
Figures from official texts (Government Gazette notices, regulator decisions): no copyright in them under the Copyright Act, s12(8)(a). Plaza positions from OpenStreetMap (ODbL 1.0, offered at https://unmapped.co.za/open#toll-plaza-locations). The file itself is under our terms.
Bargaining-council minimum wages
Release 2026-11-10
wage-tables-2026-11-10-records.parquet
1,132 record rows, 105 kB
sha256 2800503ce03e01dd…
wage-tables-2026-11-10-facts.parquet
1,047 facts, 52 kB
sha256 f3a913dfcb020716…
Figures from official texts (Government Gazette notices, regulator decisions): no copyright in them under the Copyright Act, s12(8)(a). The file itself is under our terms.
Ward and municipal boundaries with history
Release 2026-11-07
ward-boundaries-2026-11-07-records.parquet
9,483 record rows, 675 kB
sha256 ccf279783c69b18c…
Municipal Demarcation Board boundaries, shapes simplified by us, used under the MDB's licence (acknowledge the MDB; not to be sold or passed on as the MDB's product). The file itself is under our terms.
Water quality by treatment works
Release 2026-11-03
water-quality-2026-11-03-records.parquet
3,103 record rows, 440 kB
sha256 eab13822f5f02c8e…
water-quality-2026-11-03-facts.parquet
54,168 facts, 2.1 MB
sha256 8ee693433ca32c59…
From the Department of Water and Sanitation, a state work with no published reuse terms; kept fully open and free by the owner's decision of 10 November 2026 while permission is asked for. The file itself is under our terms.
Rainfall and temperature by ward
Release 2026-11-05
weather-by-ward-2026-11-05-records.parquet
309,980 record rows, 34.2 MB
sha256 9c0e2957119e2337…
Contains modified Copernicus Climate Change Service information (ERA5-Land, CC BY 4.0). The file itself is under our terms.

Limits

Needs a key (not open yet)

Errors

Needs a key (not open yet)

StatusErrorWhat to do
400bad_parameter, bad_limit, bad_cursorFix the parameter the message names.
401unauthorizedSend a valid key.
402payment_requiredThe dataset is paid; buy it in the portal.
403not_enabled, terms_requiredSwitch the dataset on, or accept the terms, in the portal.
409not_availableThe dataset is still being built.
429rate_limited, daily_limitSlow down, or wait for the daily reset.

Every error is JSON: {"error": "…", "message": "…"}.

Code examples

Needs a key (not open yet)

Python, every US dollar rate this year:

import requests

url = "https://api.unmapped.co.za/v1/datasets/exchange-rates/records"
params = {"currency": "USD", "from": "2026-01-01"}
headers = {"Authorization": "Bearer um_live_your_key"}
rows = []
while url:
    page = requests.get(url, params=params, headers=headers, timeout=30).json()
    rows += page["records"]
    url, params = page["next"], None  # the next link carries the filters

JavaScript, the latest level of every Western Cape dam:

const res = await fetch(
  "https://api.unmapped.co.za/v1/datasets/dam-levels/latest?province=ZA-WC",
  { headers: { Authorization: "Bearer um_live_your_key" } }
);
const { records } = await res.json();
for (const d of records) console.log(d.dam, d.percent_full);

Data catalogue (DCAT)

/catalog.json describes every live dataset in DCAT, the W3C vocabulary that data portals and harvesters (data.europa.eu, CKAN, Google Dataset Search) read, as JSON-LD: its title, description, publisher, licence and rights, the area and years it covers, its current release and when it changed, its page on this site, and each distribution (the API as JSON and CSV, and the Parquet snapshots with their sizes and sha256). It is checked against the DCAT-AP 3 rules before each deploy. Each dataset’s own page (/datasets/<id>) carries the same description.

Measured accuracy

Each month a script draws a fixed-size random sample of records from every live dataset (the seed is published, so the draw can be repeated), finds each figure’s source document and page, and checks the figure is printed there, using the text of our stored copy page by page. The result, with every failure listed, is on the accuracy page and at /accuracy.json. Datasets worked out from grids or images (weather, land, dams from space) have no printed page to compare with, and the page says so.

Unmapped Dataworks