Every number on this site comes out of one read-only interface, and that interface is open. Point a browser, a shell, a notebook or a spreadsheet at it and you have the same archive we do — every station, every reading, back to the beginning of the record. This page is the whole manual.
One base URL, four routes, two formats. If you can make an HTTP request, you already have access.
This is a read-only interface onto an aggregated archive of Bali air-quality measurements — PM2.5 and its companion values — collected from public sensor networks since 2025. It exists so that nobody has to scrape this site. The archive runs to roughly 140,000 rows in total, which means a patient client can hold all of it locally in a few minutes.
Eight networks are aggregated here: Nafas, IQAir, PurpleAir, AQICN, OpenAQ, AirGradient, Smart Citizen and Airly. Each row names the network it came from, so a result can always be traced back to its origin and checked against that network's own feed.
Every route is GET only and returns permissive CORS headers, so browser code may call it directly from any origin. Responses are edge-cached for between five and sixty minutes depending on how fast the underlying data moves; one served out of that cache carries X-Cache: HIT. There is no key, no account and no rate limit beyond fair use — the row caps below are the only enforcement.
What this is not. Low-cost sensors are not reference-grade instruments. Values are published as they were recorded, without warranty of accuracy or fitness for any purpose. Chapter E is the part to read before you average anything.
Paste this into a terminal. It returns the most recent reading held for every station in the archive.
curl -s "https://baliair.pages.dev/api/v1/latest"
The base URL is https://baliair.pages.dev/api/v1. Requesting it with no path returns a machine-readable index of everything documented on this page — routes, parameters, intervals, caveats and licence — which is the version to trust if this page and the interface ever disagree.
| Call | What it returns |
|---|---|
| GET /api/v1 | Index. Version, routes, parameters, interval definitions, caveats and licence. |
| GET /api/v1/stations | The catalogue. Every station, with coordinates, network, date coverage and provenance flags. |
| GET /api/v1/latest | A snapshot. The most recent reading held for each station, all networks at once. |
| GET /api/v1/measurements | The time series. Raw, hourly or daily; one station or all of them; JSON or CSV; paged. |
Nothing requested yet.
Each route, its parameters, a URL you can open right now, and the shape that comes back.
The index. Returns the version, the route list with their parameters, the interval definitions, the semantic notes reproduced in Chapter E, and the licence. It takes no parameters and does not touch the archive, so it answers even if the database is briefly unreachable.
Examplehttps://baliair.pages.dev/api/v1
Response · trimmed
{
"name": "Bali Air Dispatch API",
"version": "1.0.0",
"description": "Read-only access to an aggregated archive of Bali air-quality …",
"endpoints": {
"stations": {
"path": "/api/v1/stations",
"description": "Every station in the archive, with coordinates, source network …",
"params": { "source": "filter by network", "format": "json|csv" }
},
"latest": { … }, "measurements": { … }
},
"intervals": {
"raw": "15-minute snapshots as fetched. Not available for iqs-* …",
"hourly": "Hourly means. Native for nafas-* and iqs-*; bucketed …",
"daily": "Daily means, with min/max/sample count where available."
},
"notes": {
"timezone": …, "samples": …, "units": …, "corrected": …,
"suspected_indoor": …, "gaps": …, "staleness": …
},
"licence": { "archive": …, "attribution": …, "upstream": …, "no_warranty": … },
"documentation": "https://baliair.pages.dev/api"
}
Every station in the archive. This is the catalogue you join everything else against: it is where station_id values come from, and where you learn which stations are flagged indoor, from which date their network's readings are humidity-corrected, and how many days of record each one holds. Duplicate and placeholder catalogue rows that the site does not publish are excluded here and from every bulk scan.
| Parameter | Meaning | Default |
|---|---|---|
| source | Return only stations from one network. Matched case-insensitively against the source field — for example nafas, purpleair, iqair, aqicn, openaq, airgradient, smart citizen, airly. | all |
| format | Response format: json or csv. | json |
https://baliair.pages.dev/api/v1/stations?source=purpleair
Response · trimmed
{
"version": "1.0.0",
"count": 2,
"licence": { … },
"stations": [
{
"station_id": "pa-46949",
"name": "Klungkung by Lumi Clinic",
"source": "PurpleAir",
"latitude": -8.533623,
"longitude": 115.39973,
"type": "Community sensor",
"first_seen": "2026-04-26T11:00:53.000Z",
"last_seen": "2026-08-01T20:15:11.000Z",
"first_date": "2026-04-26",
"last_date": "2026-08-02",
"days_of_data": 94,
"suspected_indoor": false,
"pm25_correction_applied_since": "2026-07-21",
"interval_source": "universal"
}
// … one further PurpleAir station elided
]
}
pm25_correction_applied_since is a network policy date, not a claim about any particular row. It says: from this date onward, readings from this station's network are humidity-corrected before publication. It is null for networks we never correct. Rows archived before that date are uncorrected even for a corrected network — the per-row truth is pm25_corrected on a /latest or interval=raw row, which is true exactly when pm25_raw is present.
The most recent reading held for every station — one row per station, all networks in a single call. This is what the live map on the front page is built from. Stations that have never reported a usable value are absent rather than present-and-empty. Every row carries age_hours and stale, because "latest" is not the same thing as "now".
| Parameter | Meaning | Default |
|---|---|---|
| source | Return only readings from one network, matched case-insensitively against the source field. | all |
| format | Response format: json or csv. | json |
https://baliair.pages.dev/api/v1/latest?source=purpleair
Response · trimmed
{
"version": "1.0.0",
"generated_at": "2026-08-01T20:28:48.267Z",
"count": 2,
"licence": { … },
"readings": [
{
"station_id": "pa-46949",
"name": "Klungkung by Lumi Clinic",
"source": "PurpleAir",
"latitude": -8.533623,
"longitude": 115.39973,
"observed_at": "2026-08-01T20:15:11.000Z",
"age_hours": 0.2,
"stale": false,
"upstream_timestamp": "2026-08-01T20:14:10.000Z",
"pm25": 36.6,
"pm25_raw": 47.8,
"pm10": null,
"pm1": null,
"aqi": null,
"temperature": null,
"humidity": 62,
"suspected_indoor": false,
"pm25_corrected": true
}
// … one further PurpleAir station elided
]
}
This route returns the newest reading the archive holds for each station. For a sensor that went dark, that reading can be weeks or months old, and it is returned anyway — a station's last known value is legitimately useful. It is simply not current conditions.
age_hours is how old the reading is, in hours, at the moment the response was generated. stale is true when that exceeds 24 hours. A real row from this route, captured while writing this page:
{
"station_id": "aq-A416893",
"name": "Denpasar Lumintang",
"source": "AQICN",
"observed_at": "2026-06-03T08:45:39.000Z",
"age_hours": 1427.7,
"stale": true,
"pm25": 168,
"pm25_corrected": false,
…
}
That is a fifty-nine-day-old figure of 168 µg/m³. Average it into an island-wide "right now" number and you have published a fiction. Filter on stale, or set your own threshold from age_hours, before you treat any of these rows as live.
The time series, and the route you will spend your time in. Name a station or omit it to scan every station at once; choose raw, hourly or daily; bound it by date; take it as JSON or CSV; page through it with a cursor.
| Parameter | Meaning | Default |
|---|---|---|
| station | A station_id taken from /stations. Omit it entirely to pull every station in one paged scan. | all stations |
| interval | One of raw, hourly, daily. See Chapter C for what each one actually contains. hourly without a station additionally requires a bounded window — see below. | daily |
| from | Start of the window, inclusive. Accepts YYYY-MM-DD, a full ISO-8601 instant, or unix seconds. A bare date is read as UTC midnight. | start of record |
| to | End of the window, inclusive. Same three formats as from. A bare YYYY-MM-DD is extended to 23:59:59 of that day, so to covers the whole named day at every interval — to=2026-07-30 with interval=raw returns that day's last snapshot, not nothing. The query block echoes the instant actually used. | now |
| limit | Rows per page. Anything above the maximum is silently clamped to it; anything unparseable falls back to the default. | 1000 · max 10000 |
| cursor | The next_cursor value from the previous response, passed back unmodified. Keyset pagination, so page one thousand costs what page one costs, and rows are never skipped or repeated when new data lands mid-pull. | first page |
| format | Response format: json or csv. | json |
https://baliair.pages.dev/api/v1/measurements?station=pa-46949&interval=hourly&from=2026-07-30&limit=2
Response · trimmed
{
"version": "1.0.0",
"query": {
"station": "pa-46949",
"interval": "hourly",
"from": "2026-07-30T00:00:00.000Z",
"to": null,
"limit": 2
},
"source_table": "station_snapshots (hourly buckets)",
"date_basis": "UTC instant",
"count": 2,
"next_cursor": "1785373200",
"licence": { … },
"measurements": [
{
"station_id": "pa-46949",
"source": "PurpleAir",
"suspected_indoor": false,
"observed_at": "2026-07-30T00:00:00.000Z",
"pm25": 47.42,
"pm25_min": 15.1,
"pm25_max": 67.3,
"aqi_max": null,
"samples": 4
},
{
"station_id": "pa-46949",
"source": "PurpleAir",
"suspected_indoor": false,
"observed_at": "2026-07-30T01:00:00.000Z",
"pm25": 60.95,
"pm25_min": 17.4,
"pm25_max": 115.9,
"aqi_max": null,
"samples": 4
}
]
}
Paging. A next_cursor appears only when the page filled to the limit — when it comes back null, you have reached the end. Naming a station gives a one-part cursor; omitting it gives a two-part cursor, because a bulk scan sorts by station and then by time. Treat the token as opaque and hand it back exactly as received.
Bulk scans. Omitting station scans every station in the archive, across all three storage families — the universal store, the Nafas tables and the scraped IQAir tables are unioned, so a bulk daily or hourly pull returns each station's real series rather than a shallow rollup. Duplicate and placeholder catalogue rows are excluded so a bulk aggregate cannot double-count. A station you name explicitly is always served in full, whether or not it is one the site publishes.
Bulk raw is the one asymmetry. interval=raw reads the sub-hourly snapshot store, which is the only place sub-hourly data exists. Scraped IQAir stations therefore never appear in a raw pull — they have no raw series at any level, named or not.
interval=hourly without a station has to aggregate before it can page, so limit bounds the response but not the work. That combination therefore requires both from and to, spanning at most 31 days. Omit either and you get window_required; exceed the span and you get window_too_large. Both are HTTP 400. Name a single station and neither limit applies; raw and daily bulk scans are unbounded.
Add format=csv to any route and the same rows arrive flattened into columns, with a Content-Disposition filename attached so a browser saves rather than displays them. CSV is a first-class citizen here because it is what people actually load into pandas, R and Excel.
Paging a CSV. The continuation token cannot live in the body of a CSV, so it lives in the response headers instead: X-Next-Cursor carries the next cursor when one exists, and X-Row-Count carries the number of data rows. You can therefore page a bulk download without parsing a byte of the file. The header value is already URL-encoded — pass it straight back.
Where CSV and JSON differ. The columns track the JSON fields with two exceptions, both of them omissions: first_seen and last_seen are omitted from the /stations CSV, and upstream_timestamp is omitted from the /latest and raw-measurement CSVs. Everything else is column-for-field identical, pm25_correction_applied_since included: it carries the same date string the JSON does, and an empty cell is simply how CSV spells null — a network we never correct, not a correction date we failed to record.
One CSV quirk worth knowing. Spreadsheets execute a cell that begins with =, +, - or @, and station names arrive from external feeds, so any such value is prefixed with an apostrophe to render it inert. In practice this affects southern latitudes, which appear as '-8.533623. Strip a leading apostrophe before parsing coordinates numerically.
$ curl -s "https://baliair.pages.dev/api/v1/measurements\
?station=iqs-bali-umalas-villa-fusion&interval=daily&from=2026-07-01&to=2026-07-05&format=csv"
station_id,source,date,pm25,pm25_min,pm25_max,aqi_max,samples,suspected_indoor
iqs-bali-umalas-villa-fusion,IQAir,2026-07-01,34,,,97,,false
iqs-bali-umalas-villa-fusion,IQAir,2026-07-02,32.8,,,95,,false
iqs-bali-umalas-villa-fusion,IQAir,2026-07-03,38.3,,,108,,false
iqs-bali-umalas-villa-fusion,IQAir,2026-07-04,38.5,,,108,,false
iqs-bali-umalas-villa-fusion,IQAir,2026-07-05,28.1,,,86,,false
Four headers carry information the body does not. Note that only X-Next-Cursor and X-Row-Count are listed in Access-Control-Expose-Headers, so those two are the ones browser JavaScript can actually read cross-origin; X-Cache is visible to curl and to servers, not to fetch().
| Header | Meaning |
|---|---|
| X-Next-Cursor | On a CSV /measurements response, the continuation token — the CSV equivalent of next_cursor. Absent when there is no further page. Already URL-encoded; pass it straight back. Not set on JSON responses, which carry next_cursor in the body instead. |
| X-Row-Count | On a CSV /measurements response, the number of data rows written, so you can detect a full page without parsing the file. |
| X-Cache | HIT when the response came out of the edge cache rather than the database. The request that actually ran the query carries no X-Cache header at all, so treat "absent" as the miss case. |
| Content-Disposition | On CSV responses only: attachment with a generated filename, so a browser saves the file rather than rendering it. |
Errors come back as JSON with a stable machine-readable error code and a human sentence in message. Branch on the code, show the message. Some errors carry extra keys — a bad interval for a scraped IQAir station also returns available_intervals, and an unknown route returns the list of routes that do exist.
{
"error": "no_raw_for_source",
"message": "IQAir publishes hourly, so scraped IQAir stations (iqs-*) have no raw sub-hourly series. Use interval=hourly or interval=daily.",
"station": "iqs-bali-umalas-villa-fusion",
"available_intervals": ["hourly", "daily"]
}
| Code | HTTP | What caused it |
|---|---|---|
| bad_interval | 400 | interval was not one of raw, hourly, daily. |
| bad_station | 400 | station is not a well-formed station_id. Take the value from /stations rather than constructing it. |
| bad_from | 400 | from could not be read as a date, an ISO-8601 instant, or unix seconds. |
| bad_to | 400 | to could not be read as a date, an ISO-8601 instant, or unix seconds. |
| bad_range | 400 | to is earlier than from. |
| bad_cursor | 400 | The cursor has the wrong number of components, an empty component, or an invalid station component. A single-station scan expects a one-part cursor and a bulk scan a two-part one, so a token cannot be moved between the two. Pass back next_cursor unmodified. |
| window_required | 400 | interval=hourly was requested without a station and without both from and to. Bound the window to at most 31 days, or name one station. |
| window_too_large | 400 | interval=hourly without a station, over a window longer than 31 days. Split the pull into 31-day requests, or name one station. |
| no_raw_for_source | 400 | You asked for interval=raw on a scraped IQAir station. IQAir publishes hourly, so no sub-hourly series exists. Use hourly or daily. |
| not_found | 404 | Unknown route. The response lists the routes that do exist. |
| method_not_allowed | 405 | Anything other than GET, HEAD or a CORS preflight. The interface is read-only. |
| archive_unavailable | 503 | The archive is temporarily unreachable. Retry shortly; the index route keeps answering meanwhile. |
| internal_error | 500 | Something failed on our side. No internal detail is echoed back — if it persists, tell us. |
The archive is assembled from networks that publish at different cadences. The interval you choose decides which underlying store answers — and the answer says so, in every response.
| Interval | What you get | Time field |
|---|---|---|
| raw | Fifteen-minute snapshots exactly as they were fetched, with every companion value the sensor supplied — PM10, PM1, AQI, temperature, humidity, and the upstream timestamp. This is the unaggregated record. Not available for scraped IQAir stations. | observed_at |
| hourly | Hourly means. Native for Nafas and scraped IQAir stations, which publish hourly upstream; bucketed here from the raw snapshots for every other network. Only the bucketed-here case carries the hour's minimum, maximum and sample count — the native ones return null for all three, because upstream gives one number per hour, not a distribution. | observed_at |
| daily | Daily means, with minimum, maximum and sample count where the underlying store holds them. The default, and the right choice for trend work. The date is a WITA calendar day, not a UTC one — see Chapter E. | date |
IQAir publishes its city pages hourly, so the stations we scrape from it — every station_id beginning iqs- — have no sub-hourly series to hand you. Asking for interval=raw on one of them returns HTTP 400 with the code no_raw_for_source and the intervals that will work. This is not a gap we can fill by trying harder; the data does not exist at that resolution upstream.
Everything else about those stations behaves normally, and they carry some of the longest records in the archive — the Umalas station reaches back to November 2024. Use interval=hourly or interval=daily and they behave like any other station.
Knowing which store answered. Every /measurements response carries source_table, naming the store the rows came out of. Naming a station gives you a single store — station_daily, station_snapshots, station_snapshots (hourly buckets), nafas_daily, nafas_hourly, iq_scrape_daily or iq_scrape_hourly. A bulk scan unions several, and says so in the same field: station_daily + nafas_daily + iq_scrape_daily for bulk daily, and station_snapshots (hourly buckets) + nafas_hourly + iq_scrape_hourly for bulk hourly. Bulk raw is the single-store case, station_snapshots. You never have to know this to use the interface — the row shape is identical either way — but it means any result you publish can be traced back to specific tables.
Where min, max and samples are null. Networks that publish pre-aggregated hourly or daily figures give us one number per bucket, not a distribution, so pm25_min, pm25_max and samples come back null for those rows: pm25_min and pm25_max are null on every Nafas and scraped-IQAir row, and samples is null on every Nafas row and on scraped-IQAir hourly rows, though scraped-IQAir daily rows do carry a count where the scrape recorded one. Where the bucket was computed here from raw snapshots, all three are populated. A null is an honest absence, never a zero; and a populated samples of 1 is not the same thing as a null — see Chapter E.
Which basis the dates are on. Every /measurements response also carries date_basis, which is "WITA (UTC+8) calendar day" when the rows are daily and "UTC instant" when they are hourly or raw. It is there so a script never has to infer the boundary from the interval it asked for.
What each field means, and what it is measured in. Units are constant across the whole interface: PM in micrograms per cubic metre, temperature in Celsius, humidity in percent. Every instant is UTC; daily dates are the one exception, and they are WITA.
| Field | Meaning | Type |
|---|---|---|
| version | Interface version. Present on every successful response. | string |
| count | Number of rows in this response — this page, not the whole result set. | integer |
| generated_at | When this snapshot was assembled. /latest only. | ISO-8601 Z |
| query | Your request as the interface understood it, with dates normalised. Check this first when a result surprises you. /measurements only. | object |
| source_table | Which underlying store answered, or which stores were unioned on a bulk scan. /measurements only. | string |
| date_basis | Which calendar the row keys are on: "WITA (UTC+8) calendar day" for interval=daily, "UTC instant" for hourly and raw. /measurements only. | string |
| next_cursor | Opaque continuation token, or null when this is the last page. /measurements JSON only — the CSV form puts it in X-Next-Cursor. | string · null |
| licence | The four licence statements, repeated on every response so a saved file always carries its own terms. | object |
| error | Machine-readable failure code. Present only on errors, alongside message. | string |
| message | A human sentence explaining the failure. Present only on errors. Show it; branch on error. | string |
| Field | Meaning | Type |
|---|---|---|
| station_id | Stable identifier, prefixed by network. Use it verbatim; do not construct it. Some contain spaces. | string |
| name | Station name as the originating network publishes it. | string |
| source | The network the reading came from — Nafas, IQAir, PurpleAir, AQICN, OpenAQ, AirGradient, Smart Citizen or Airly. | string |
| latitude | Decimal degrees, WGS 84. Negative in Bali. | number |
| longitude | Decimal degrees, WGS 84. | number |
| type | Free-text device or ownership description from the network — government station, community sensor, and so on. Not a controlled vocabulary. | string · null |
| first_seen | When this archive first collected from the station. JSON only. | ISO-8601 Z |
| last_seen | When this archive last collected from the station. JSON only. | ISO-8601 Z |
| first_date | Earliest day in the station's daily series. Can predate first_seen where history was backfilled. | YYYY-MM-DD |
| last_date | Most recent day in the station's daily series. | YYYY-MM-DD |
| days_of_data | Count of days present in the daily series. Zero means no daily rollup exists yet, not that the station is silent. | integer |
| suspected_indoor | True where the sensor appears to be measuring a room rather than ambient air. Read Chapter E before averaging anything. | boolean |
| pm25_correction_applied_since | The date from which this station's network has its PM2.5 humidity-corrected before publication, or null for a network we never correct. A statement about network policy, not about any individual row: readings archived before this date are uncorrected even here. For per-row truth use pm25_corrected on a /latest or interval=raw row. | YYYY-MM-DD · null |
| interval_source | Which storage family serves this station: universal, nafas or iqair_scrape. The last of these has no raw interval. | string |
| Field | Meaning | Type |
|---|---|---|
| observed_at | When this archive recorded the reading. This is the field to sort and join on. | ISO-8601 Z |
| source | The network the reading originated from. Carried on every /latest row and every /measurements row at every interval, so attribution never needs a join back to /stations. This is the name to credit. | string · null |
| age_hours | How old the reading was, in hours to one decimal, at the moment the response was generated. /latest only. | number · null |
| stale | true when age_hours exceeds 24, and also when the station has no usable timestamp at all. /latest only. Read it before treating a row as current conditions. | boolean |
| upstream_timestamp | The timestamp the originating network attached to the same reading, where it gave one. Usually a minute or two before observed_at. JSON only. | ISO-8601 Z · null |
| pm25 | Fine particulate matter, 2.5 micrometres and below. The published value — humidity-corrected for AirGradient and PurpleAir, as-supplied for everyone else. | µg/m³ · null |
| pm25_raw | The uncorrected sensor figure, stored alongside every corrected reading so the correction is auditable and reversible. null where no correction was applied — which includes every row archived before the correction began, on corrected networks too. | µg/m³ · null |
| pm10 | Particulate matter, 10 micrometres and below. Null on networks that do not publish it. | µg/m³ · null |
| pm1 | Particulate matter, 1 micrometre and below. Null on networks that do not publish it. | µg/m³ · null |
| aqi | Air Quality Index as the originating network reported it. Networks differ in which scale they use, so do not compare AQI across sources — compare PM2.5. | integer · null |
| temperature | Air temperature at the sensor. | °C · null |
| humidity | Relative humidity at the sensor. This is the input to the correction described in Chapter E. | % · null |
| suspected_indoor | True where the sensor appears to be measuring a room rather than ambient air. Carried on every row so a filter never needs a join. | boolean |
| pm25_corrected | True where this reading was humidity-corrected — which is exactly the rows carrying a pm25_raw. A per-row fact, not a network-wide one: most archived PurpleAir and AirGradient rows predate the correction and are false. Present on /latest and on interval=raw rows; aggregate rows do not carry it, because a bucket can straddle the changeover. | boolean |
| Field | Meaning | Type |
|---|---|---|
| date | The day the bucket covers, as a WITA (UTC+8) calendar day — the local day, not the UTC one. Daily rows only. The response repeats this in date_basis. | YYYY-MM-DD |
| observed_at | Start of the hour the bucket covers, in UTC. Hourly rows only. | ISO-8601 Z |
| source | The network the bucket's readings came from. Carried on every aggregate row; this is the name to credit. | string · null |
| pm25 | Mean PM2.5 across the bucket. | µg/m³ · null |
| pm25_min | Lowest PM2.5 seen in the bucket. Null where the store holds no distribution. | µg/m³ · null |
| pm25_max | Highest PM2.5 seen in the bucket. This is where short pollution events show up that a mean hides. | µg/m³ · null |
| aqi_max | Highest AQI seen in the bucket, on the originating network's scale. | integer · null |
| samples | How many observations went into the mean. A low count is a thin average — weight it accordingly. 1 is common on older backfilled daily rows, where the mean, the minimum and the maximum are all the same single reading. null where the store holds no count at all, which is not the same as a count of zero. | integer · null |
| suspected_indoor | True where the sensor appears to be measuring a room rather than ambient air. | boolean |
Seven things about this archive that will change your numbers. None of them are hidden, but all of them are easy to miss — and the first three are the ones that most often end up inside a published figure that is simply wrong.
A monitor sitting in a clinic waiting room or a purified bedroom measures that room, not the island's air, and it will read far cleaner than the street outside. Those stations carry suspected_indoor: true on the station record and on every single measurement row, so a filter never needs a join.
This site excludes them from every island-wide statistic it publishes. If you are averaging across stations, you should too. They are published rather than deleted because a suspected classification should be yours to disagree with — but an ambient analysis that silently includes them will understate Bali's air quality problem, and it will do so invisibly.
There is no freshness filter on /latest. Every station the archive holds a usable value for gets a row, including sensors that stopped reporting months ago, and their last known value is returned as-is. Read literally as "conditions now", that route will hand you readings that are weeks old alongside readings that are minutes old, in the same array, indistinguishable by value.
Which is why every row carries age_hours and stale. stale is true past 24 hours; a large minority of the stations in a typical response are over it, and the oldest run to well past a thousand hours. Filter on stale, or set your own threshold from age_hours, before you average anything from this route. For a defensible current-conditions figure, prefer a bounded /measurements window over /latest altogether.
samples is how many observations went into an aggregate. On older backfilled daily rows it is frequently 1 — and a large share of the daily series is in that state. Where the row was backfilled from a single snapshot, the minimum, the maximum and the mean are then all the same number:
{ "station_id": "pa-46949", "source": "PurpleAir", "date": "2026-07-28",
"pm25": 12.6, "pm25_min": 12.6, "pm25_max": 12.6, "samples": 1 }
That is not a daily average of Bali's air; it is one instant, presented in the shape of a daily average. Where the row was backfilled from an upstream daily summary instead, samples is still 1 while the min and max do differ — the count means one record ingested, not one sensor reading. Either way, check samples before you plot a daily series, and where precision matters prefer interval=raw or interval=hourly, which are computed from the snapshots themselves. A null is a different case again: that store holds no count, so you cannot tell how thin the row is.
Instants are UTC. Daily dates are WITA. This is the one place the interface is deliberately not uniform, and it will bite an analysis that assumes otherwise. Every instant — observed_at, first_seen, last_seen, generated_at, and the normalised from and to in the query block — is ISO-8601 UTC with a trailing Z. But a daily date is a WITA calendar day, WITA being UTC+8 all year with no daylight saving. Daily buckets are aggregated on the local day because that is the meaningful one for a Bali reader: household waste burning has a sharp morning peak and a second climb after dark, and a UTC day would cut that evening in half. Every /measurements response states which basis applies in date_basis, so you never have to guess. Convert instants to WITA before analysing time of day, and do not silently re-bucket the daily series onto UTC.
Two networks are corrected, the rest are not, and only from a date. Low-cost optical sensors size particles with a laser, so in humid air water-swollen particles scatter more light and the sensor over-reads. AirGradient and PurpleAir publish that uncorrected figure on the open feeds we read, so we apply the standard US-EPA 2021 humidity correction before publication; every other network — Nafas, IQAir, AQICN, OpenAQ, Smart Citizen, Airly — is passed through exactly as supplied. The correction began on a specific date, which /stations reports per network as pm25_correction_applied_since, and it was not applied retroactively. The great majority of archived PurpleAir and AirGradient rows therefore predate it and carry pm25_corrected: false with a null pm25_raw — so a series that crosses that date is not corrected uniformly along its length. Nothing is lost either way: pm25_raw carries the original figure on every corrected reading, so you can reverse or re-derive the correction yourself. The formula and its behaviour at the bottom of its range are set out in the methodology chapter of the appendix.
Gaps are real. A sensor that went silent has no rows for that period — not a repeated last value, not a zero, not an interpolation. If your chart has a hole in it, that hole is the truth: nobody was measuring. This matters for Bali specifically, because several of the longest-running stations in the record went dark and stayed dark. Do not fill forward without saying so, and never treat a missing row as a clean-air row.
These are not reference instruments. Low-cost sensors drift, disagree with each other, and are affected by humidity and by their own siting. Values are published as recorded, without warranty of accuracy or fitness for any purpose. They are good enough to establish patterns, trends and magnitudes, and they are what exists — Bali has almost no regulatory monitoring. Treat them as evidence of what the air is doing, not as calibrated measurement, and say so in whatever you publish.
Four things people actually want to do on the first afternoon. Each one runs as written.
The whole daily record for the Klungkung sensor, saved where a spreadsheet can open it.
curl -s -o klungkung-daily.csv \
"https://baliair.pages.dev/api/v1/measurements?station=pa-46949&interval=daily&format=csv"
Pandas reads the CSV straight off the URL. The one line that normalises suspected_indoor is worth keeping: it makes the filter correct whether pandas parsed the column as a boolean or left it as text.
import pandas as pd
url = ("https://baliair.pages.dev/api/v1/measurements"
"?interval=daily&from=2026-01-01&format=csv&limit=10000")
df = pd.read_csv(url, parse_dates=["date"])
df["suspected_indoor"] = df["suspected_indoor"].astype(str).str.lower() == "true"
ambient = df[~df["suspected_indoor"]]
print(ambient.groupby("date")["pm25"].median().tail(10))
limit=10000 is the ceiling for a single page. If X-Row-Count comes back equal to your limit there is more behind it — page it with recipe 3. Two things to add before you publish anything off this frame: date here is a WITA calendar day, not a UTC one, and the CSV also carries a samples column worth filtering on — ambient[ambient["samples"] > 1] drops the single-observation daily rows described in Chapter E.
Roughly 140,000 rows, in pages of ten thousand. The cursor arrives in the X-Next-Cursor header already URL-encoded, so it goes straight back into the query string; when the header stops appearing, you are done.
#!/bin/sh
base="https://baliair.pages.dev/api/v1/measurements?interval=raw&limit=10000&format=csv"
cursor=""
first=1
while : ; do
url="$base"
[ -n "$cursor" ] && url="$base&cursor=$cursor"
curl -s -D headers.txt "$url" > page.csv
if [ "$first" = 1 ]; then cp page.csv archive.csv; first=0
else tail -n +2 page.csv >> archive.csv; fi
cursor=$(awk 'tolower($1)=="x-next-cursor:"{print $2}' headers.txt | tr -d '\r')
[ -z "$cursor" ] && break
done
wc -l archive.csv
Every measurement row carries its own suspected_indoor flag, so most of the time you filter in place. When you need the list itself — to document what you excluded, or to filter a dataset you joined from elsewhere — take it from the catalogue.
import pandas as pd
st = pd.read_csv("https://baliair.pages.dev/api/v1/stations?format=csv")
st["suspected_indoor"] = st["suspected_indoor"].astype(str).str.lower() == "true"
indoor = sorted(st.loc[st["suspected_indoor"], "station_id"])
print(len(indoor), "excluded:", indoor)
ambient_stations = st.loc[~st["suspected_indoor"], ["station_id", "name", "source"]]
Use it, redistribute it, build on it, sell nothing back to us. Credit the networks that did the measuring.
source field. The networks did the measuring; we only kept the record.If you make something with this — a study, a chart, a bot, a school project, a better map than ours — we would genuinely like to know. Not for credit; because knowing what the data is used for tells us what to collect next, and because someone else in the community is probably trying to do the same thing.
The WhatsApp community is the fastest way to reach the people using this data, and the address in the footer works for anything longer. Bug reports on the interface itself are especially welcome: if a field is wrong, a cursor misbehaves or this page contradicts what the API actually does, that is a defect and we will fix it.
If you would rather add to the record than read it, the gaps on the map are simply places nobody has installed a sensor yet — the setup guide is here.