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
- Google Sheets: in a cell,
=IMPORTDATA("the address"). It refreshes about every hour. - Excel: Data, then From Web, then paste the address. Refresh with Data, Refresh All. To keep the key out of the address, choose Advanced and add a header
AuthorizationwithBearer um_live_…, and leavekeyoff. - Power BI: Get data, then Web, then the same address (or Advanced with the header).
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": []
}
- Two dates.
effective_fromtoeffective_tois when the figure applied (the end is the first day it no longer did;nullwhile nothing has replaced it).known_fromtoknown_tois when we published it as true. - Corrections and newer notices. When a later document replaces what we knew, the fact keeps its address with
known_toset andcurrent: false, andreplaced_bygives the facts that took its place. Nothing is overwritten. - Where it comes from.
documentnames the Gazette notice, decision, report or data file, with the page and, where we hold a copy, the file’s SHA-256 fingerprint.methodsays how the figure was read (parsed,capture_sheet,person_checked…); a worked-out figure (derived) gives itsformulaand the facts it was worked out from ininputs. - An address that isn’t a fact answers
404. A fact we no longer publish (a source withdrew its permission, say) answers410with the reason.
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%"
}
}
- Usage:
kwhfor the month; for time-of-use tariffs,peak,standardandoff_peak(kWh in each period);demand_kva,nmd_kva,amps(per phase) andphases(1 or 3), orkvarhwhere a tariff charges for them;export_kwhfor energy fed back.meter=prepaidwhere a tariff charges prepaid meters differently. - VAT:
vat=source(the default) follows the decision or gazette: no VAT added where it states its rates include VAT, 15% added where it states they exclude it. Where it doesn’t say, the bill assumes the rates exclude VAT, adds 15%, and says so:vat_basis.assumedistrueandvat_flagreads “VAT not stated by the source: assumed excluded and added at 15%”.vat=includedorvat=excludedtreats the printed rates that way whatever the source says (the flag says when that goes against the source, or the source is silent);vat=falseleaves VAT out. Most NERSA decisions don’t say: of the 2026/27 tariffs, about four in five carry the flag. - Compare tariffs:
distributor=CPTinstead oftariffprices every tariff the distributor has in acategory(residential unless you say), cheapest complete bill first. Household tariffs only some households qualify for (indigent, lifeline, free basic electricity, pensioners and the like, going by the tariff’s name) come after the rest and are markedqualifying;cheapestis the cheapest complete tariff open to every household. - Nothing guessed: a charge it can’t price from what you give it (time-of-use energy without the periods, a demand charge without the demand) is listed under
skippedwith the reason, and the bill isn’t called complete. Nor is a tariff with no energy charge, such as a vacant-stand availability charge.
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)
| Dataset | Files | Licence |
|---|---|---|
| Dam levels with full history Release 2026-11-09 | dam-levels-2026-11-09-records.parquet681 record rows, 55 kB sha256 f1c70ed2a3e4fe98…dam-levels-2026-11-09-facts.parquet3,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.parquet536 record rows, 1.0 MB sha256 4acd12d313b56ea3…dam-surface-from-space-2026-11-10.3-facts.parquet52,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.parquet2,229 record rows, 370 kB sha256 5eabbd20e31e83ab…electricity-tariffs-2026-11-12-facts.parquet16,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.parquet28,536 record rows, 1.7 MB sha256 542812d79297803f…exchange-rates-2026-11-11-facts.parquet57,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.parquet16,641 record rows, 951 kB sha256 89c78776da565267…fuel-prices-2026-11-04-facts.parquet16,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.parquet385 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.parquet52,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.parquet14,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.parquet233 record rows, 26 kB sha256 b8c0a6811b3efa87…reference-rates-2026-11-09.3-facts.parquet233 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.parquet1,164 record rows, 93 kB sha256 7c148e668469875a…toll-tariffs-2026-11-09.2-facts.parquet2,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.parquet1,132 record rows, 105 kB sha256 2800503ce03e01dd…wage-tables-2026-11-10-facts.parquet1,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.parquet9,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.parquet3,103 record rows, 440 kB sha256 eab13822f5f02c8e…water-quality-2026-11-03-facts.parquet54,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.parquet309,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)
- Records a day: each account can read a set number of records a day across all its keys and the portal’s downloads. The headers
X-Daily-Limit,X-Daily-RemainingandX-Daily-Resetsay where you stand; it resets at 02:00 South African time. - Requests a minute: up to 120 per key. Ask us if you need more.
- Use: the data is licensed for your own business and products, as the terms of use set out.
Errors
Needs a key (not open yet)
| Status | Error | What to do |
|---|---|---|
| 400 | bad_parameter, bad_limit, bad_cursor | Fix the parameter the message names. |
| 401 | unauthorized | Send a valid key. |
| 402 | payment_required | The dataset is paid; buy it in the portal. |
| 403 | not_enabled, terms_required | Switch the dataset on, or accept the terms, in the portal. |
| 409 | not_available | The dataset is still being built. |
| 429 | rate_limited, daily_limit | Slow 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.