Public interface
Bali Air Dispatch
Anonymous, non-commercial

The API

The whole archive, open to anyone who asks for it. No key, no account, no conditions beyond attribution.
Edition XVII · Interface
Public API · Version 1 · No key required

Take the data. All of it.

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.

A
Start here
No key · GET only · CORS open

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.

The one line that gets you started

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.

Four calls worth knowing

CallWhat it returns
GET /api/v1Index. Version, routes, parameters, interval definitions, caveats and licence.
GET /api/v1/stationsThe catalogue. Every station, with coordinates, network, date coverage and provenance flags.
GET /api/v1/latestA snapshot. The most recent reading held for each station, all networks at once.
GET /api/v1/measurementsThe time series. Raw, hourly or daily; one station or all of them; JSON or CSV; paged.
Same-origin request · nothing leaves this page
Nothing requested yet.
B
The endpoints
Four routes · JSON or CSV

Each route, its parameters, a URL you can open right now, and the shape that comes back.

GET/api/v1

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.

Example
https://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"
}

GET/api/v1/stations

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.

ParameterMeaningDefault
sourceReturn only stations from one network. Matched case-insensitively against the source field — for example nafas, purpleair, iqair, aqicn, openaq, airgradient, smart citizen, airly.all
formatResponse format: json or csv.json
Example
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
  ]
}
Read this field carefully

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.

GET/api/v1/latest

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".

ParameterMeaningDefault
sourceReturn only readings from one network, matched case-insensitively against the source field.all
formatResponse format: json or csv.json
Example
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
  ]
}
"Latest" means latest held, not latest measured

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.

GET/api/v1/measurements

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.

ParameterMeaningDefault
stationA station_id taken from /stations. Omit it entirely to pull every station in one paged scan.all stations
intervalOne 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
fromStart 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
toEnd 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
limitRows per page. Anything above the maximum is silently clamped to it; anything unparseable falls back to the default.1000 · max 10000
cursorThe 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
formatResponse format: json or csv.json
Example
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.

One bounded request

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.

JSON or CSV

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.

Example
$ 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

Response headers worth reading

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().

HeaderMeaning
X-Next-CursorOn 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-CountOn a CSV /measurements response, the number of data rows written, so you can detect a full page without parsing the file.
X-CacheHIT 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-DispositionOn CSV responses only: attachment with a generated filename, so a browser saves the file rather than rendering it.

When something is wrong

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"]
}
CodeHTTPWhat caused it
bad_interval400interval was not one of raw, hourly, daily.
bad_station400station is not a well-formed station_id. Take the value from /stations rather than constructing it.
bad_from400from could not be read as a date, an ISO-8601 instant, or unix seconds.
bad_to400to could not be read as a date, an ISO-8601 instant, or unix seconds.
bad_range400to is earlier than from.
bad_cursor400The 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_required400interval=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_large400interval=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_source400You asked for interval=raw on a scraped IQAir station. IQAir publishes hourly, so no sub-hourly series exists. Use hourly or daily.
not_found404Unknown route. The response lists the routes that do exist.
method_not_allowed405Anything other than GET, HEAD or a CORS preflight. The interface is read-only.
archive_unavailable503The archive is temporarily unreachable. Retry shortly; the index route keeps answering meanwhile.
internal_error500Something failed on our side. No internal detail is echoed back — if it persists, tell us.
C
The three intervals
Raw · Hourly · Daily

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.

IntervalWhat you getTime field
rawFifteen-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
hourlyHourly 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
dailyDaily 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
The one real limitation

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.

D
Field reference
Every field · Every unit

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.

The envelope

FieldMeaningType
versionInterface version. Present on every successful response.string
countNumber of rows in this response — this page, not the whole result set.integer
generated_atWhen this snapshot was assembled. /latest only.ISO-8601 Z
queryYour request as the interface understood it, with dates normalised. Check this first when a result surprises you. /measurements only.object
source_tableWhich underlying store answered, or which stores were unioned on a bulk scan. /measurements only.string
date_basisWhich calendar the row keys are on: "WITA (UTC+8) calendar day" for interval=daily, "UTC instant" for hourly and raw. /measurements only.string
next_cursorOpaque continuation token, or null when this is the last page. /measurements JSON only — the CSV form puts it in X-Next-Cursor.string · null
licenceThe four licence statements, repeated on every response so a saved file always carries its own terms.object
errorMachine-readable failure code. Present only on errors, alongside message.string
messageA human sentence explaining the failure. Present only on errors. Show it; branch on error.string

Station fields · /stations

FieldMeaningType
station_idStable identifier, prefixed by network. Use it verbatim; do not construct it. Some contain spaces.string
nameStation name as the originating network publishes it.string
sourceThe network the reading came from — Nafas, IQAir, PurpleAir, AQICN, OpenAQ, AirGradient, Smart Citizen or Airly.string
latitudeDecimal degrees, WGS 84. Negative in Bali.number
longitudeDecimal degrees, WGS 84.number
typeFree-text device or ownership description from the network — government station, community sensor, and so on. Not a controlled vocabulary.string · null
first_seenWhen this archive first collected from the station. JSON only.ISO-8601 Z
last_seenWhen this archive last collected from the station. JSON only.ISO-8601 Z
first_dateEarliest day in the station's daily series. Can predate first_seen where history was backfilled.YYYY-MM-DD
last_dateMost recent day in the station's daily series.YYYY-MM-DD
days_of_dataCount of days present in the daily series. Zero means no daily rollup exists yet, not that the station is silent.integer
suspected_indoorTrue where the sensor appears to be measuring a room rather than ambient air. Read Chapter E before averaging anything.boolean
pm25_correction_applied_sinceThe 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_sourceWhich storage family serves this station: universal, nafas or iqair_scrape. The last of these has no raw interval.string

Reading fields · /latest and interval=raw

FieldMeaningType
observed_atWhen this archive recorded the reading. This is the field to sort and join on.ISO-8601 Z
sourceThe 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_hoursHow old the reading was, in hours to one decimal, at the moment the response was generated. /latest only.number · null
staletrue 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_timestampThe 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
pm25Fine 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_rawThe 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
pm10Particulate matter, 10 micrometres and below. Null on networks that do not publish it.µg/m³ · null
pm1Particulate matter, 1 micrometre and below. Null on networks that do not publish it.µg/m³ · null
aqiAir 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
temperatureAir temperature at the sensor.°C · null
humidityRelative humidity at the sensor. This is the input to the correction described in Chapter E.% · null
suspected_indoorTrue 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_correctedTrue 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

Aggregate fields · interval=hourly and interval=daily

FieldMeaningType
dateThe 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_atStart of the hour the bucket covers, in UTC. Hourly rows only.ISO-8601 Z
sourceThe network the bucket's readings came from. Carried on every aggregate row; this is the name to credit.string · null
pm25Mean PM2.5 across the bucket.µg/m³ · null
pm25_minLowest PM2.5 seen in the bucket. Null where the store holds no distribution.µg/m³ · null
pm25_maxHighest PM2.5 seen in the bucket. This is where short pollution events show up that a mean hides.µg/m³ · null
aqi_maxHighest AQI seen in the bucket, on the originating network's scale.integer · null
samplesHow 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_indoorTrue where the sensor appears to be measuring a room rather than ambient air.boolean
E
Reading the data honestly
Read before averaging

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.

1 · Some of these sensors are indoors

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.

2 · /latest is the newest reading held, not the newest reading taken

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.

3 · A daily row can be one reading wearing a mean's clothes

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 instantobserved_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.

F
Recipes
Copy · Paste · Run

Four things people actually want to do on the first afternoon. Each one runs as written.

1 · One station, daily, straight to a CSV file

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"

2 · Load the daily series into pandas

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.

3 · Page the entire raw archive

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

4 · Build the indoor exclusion list

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"]]
G
Licence, attribution, and what you build
Free to use · Please credit

Use it, redistribute it, build on it, sell nothing back to us. Credit the networks that did the measuring.

The archive
Published for public-interest research. Free to use, redistribute and build on, with attribution. There is no separate commercial licence to buy and no permission to request — this exists to be used.
Attribution
Credit both this archive — Bali Air Dispatch, baliair.pages.dev — and the originating network named in each row's source field. The networks did the measuring; we only kept the record.
Upstream terms
Readings originate from independent networks — Nafas, IQAir, PurpleAir, AQICN, OpenAQ, AirGradient, Smart Citizen and Airly — and remain subject to their own terms. This project aggregates and preserves; it does not own the underlying measurements.
No warranty
Low-cost sensors are not reference-grade instruments. Values are provided as recorded, without warranty of accuracy or fitness for any purpose. Every response repeats these four statements, so a saved file always carries its own terms.
Tell us what you build

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.