The Breathe API
This is the same API that powers the Breathe apps on Android, iOS and the web, so everything you see in them (live AQI, every ground sensor, history going back to the first reading, weather and seasonal normals) comes from the endpoints on this page. It's free, there's no key or sign-up, and you're welcome to build whatever you want on top of it.
api.breatheoss.appGetting started
Every endpoint is a plain GET on https://api.breatheoss.app, and nothing needs a header, a token or a body, so the quickest way to see what the API gives you is to open a URL in your browser or run it through curl. The request below asks for the current air in Srinagar.
curl https://api.breatheoss.app/aqi/zone/srinagar
You'll get back one JSON object with the current AQI on both the US EPA and the Indian CPCB scales, the raw pollutant concentrations behind them, an hourly history of the last 24 hours, every sensor in the zone with its own reading and the current weather. The full shape is documented under /aqi/zone/{zone_id}.
If you don't know the zone IDs yet, start with /zones, which lists all of them along with their coordinates and whether they're backed by ground sensors or by satellite estimates. For anything more than a couple of zones, use /aqi/zones instead of calling each one separately (it's one request instead of twenty five, and it's what our own apps do now).
The interactive reference at api.breatheoss.app/docs is generated by FastAPI straight from the code, so it's always in sync with what's deployed, but it only knows the parameter names and not what the numbers mean. This page is the long version.
Concepts
Zones and sensors
The API is organised around zones, and a zone is a town or a city (Srinagar, Jammu, Leh, Kargil and so on) with an ID like srinagar or jammu_city. Some zones have one or more physical sensors inside them, and the API calls each of those sensors a node. A zone with six nodes reports the average of whichever ones are healthy at that moment, and every node is also listed on its own so you can see how much the air differs from one neighbourhood to the next (which, in a city like Srinagar, is quite a lot).
Zone IDs are meant to stay the same once a zone is live, but new zones do get added as people host sensors, so read the list from /zones instead of hardcoding it.
Ground sensors and satellite estimates
Every zone has a provider, which is either airgradient or openmeteo. An airgradient zone has real AirGradient sensors on rooftops and balconies measuring PM2.5 and PM10 every few minutes, and these are what we'd recommend trusting first. An openmeteo zone has no sensor yet, so its numbers come from the Open-Meteo air quality API, which is a model built on satellite data and not a measurement of the street you're standing on.
The sensors only measure particulates, so the gases (NO2, SO2, CO and methane) always come from Open-Meteo, even in a sensor zone. That's why a sensor zone's source reads something like airgradient (5/6 sensors) + openmeteo.
If every sensor in a zone goes offline, or the readings look broken (more on that under spike protection), the zone quietly falls back to Open-Meteo for that refresh, its source changes to openmeteo air pollution api and warning says why. So provider tells you what a zone normally uses, and source tells you what this particular response actually used.
Two AQI scales
Every AQI value comes in two versions, us_aqi on the US EPA scale and aqi on the Indian CPCB scale. Both run from 0 to 500, but they cut the range up very differently, and the same air can easily read 141 on the US scale and 84 on the Indian one. The Breathe apps lean on the US scale because its PM2.5 bands are much stricter (and in our opinion, much closer to what the health research actually says), but both are always there and you can pick whichever suits you. The bands for each are listed under AQI scales.
Keep in mind that aqi on its own means the Indian index everywhere in this API. That's a leftover from when it was the only one, and it can't change now without breaking older app releases.
Time and units
Timestamps are Unix seconds in UTC (ts, timestamp_unix), except for the calendar windows on the history endpoints, which are read as dates in IST because every zone sits in IST. Concentrations are in µg/m³, apart from CO and methane, which are in mg/m³. Temperature is in °C, humidity is relative humidity in percent and precipitation is in millimetres.
How fresh the data is
A background job refreshes every zone every 15 minutes, which lines up with how often the sensors report and keeps us well inside Open-Meteo's rate limits. Live requests are served from that cache, so calling /aqi/zone/srinagar every few seconds won't give you anything newer, and polling more often than every 5 to 15 minutes is just wasted traffic. A sensor reading older than an hour is treated as the sensor being down, not as the current state of the air.
History, weather and seasonal responses are cached for longer (an hour for history and weather, six hours for seasonal), so the newest bucket in a history response can lag the live reading by up to an hour.
Browsers and limits
The API sends CORS headers only for our own sites (breatheoss.app, about.breatheoss.app and localhost on ports 3000 and 8080 for development). Server side code, scripts, bots, notebooks and mobile apps aren't affected by this at all, but a web page on another domain calling the API straight from the browser will be blocked. If that's what you're building, put a small proxy in front of it (a Cloudflare Worker is enough) or tell us your domain and we'll look at adding it.
There's no rate limit or quota on our side right now. That said, the API runs on a single small Heroku dyno, so please cache what you can, use /aqi/zones instead of looping over zones, and don't hammer /historical-data with year long ranges at 15 minute intervals in a loop. If you're planning something heavy (a public dashboard with a lot of traffic, a research project pulling the full archive), drop us a message first, we're happy to help.
Responses over about 1 KB are gzipped if your client asks for it, which every browser and curl --compressed does.
Endpoints
List zones
Every zone the API knows about, with enough detail to put it on a map. It takes no parameters and changes only when a zone is added or moves from satellite to sensors.
| Field | Type | Description |
|---|---|---|
id | string | The zone ID used by every other endpoint. |
name | string | Display name, like Srinagar. |
provider | string | airgradient for ground sensors, openmeteo for satellite estimates. |
lat, lon | number | The point the zone is centred on, used for the Open-Meteo lookups. |
zone_type | string | hills, urban or industrial. It's informational for now and doesn't change any calculation. |
{
"zones": [
{
"id": "jammu_city",
"name": "Jammu",
"provider": "airgradient",
"lat": 32.7169,
"lon": 74.8609,
"zone_type": "urban"
},
{
"id": "anantnag_city",
"name": "Anantnag",
"provider": "openmeteo",
"lat": 33.7295,
"lon": 75.15,
"zone_type": "hills"
}
]
}
Current air in one zone
This is the main endpoint, and it's what every Breathe app opens with. It returns the latest reading for a zone on both AQI scales, the concentrations behind it, the last 24 hours, every node in the zone and the current weather. An unknown zone_id returns 404.
Top level
| Field | Type | Description |
|---|---|---|
zone_id | string | The ID you asked for. |
zone_name | string | Display name. |
source | string | Where this response's numbers came from. airgradient (5/6 sensors) + openmeteo means five of six sensors were healthy, airgradient + openmeteo is a single sensor zone and openmeteo air pollution api means satellite estimates only (either by design or as a fallback). |
timestamp_unix | number | When the server built this reading, in Unix seconds with a fractional part. |
coordinates | object | {"lat", "lon"} of the zone. |
us_aqi | integer | Current AQI on the US EPA scale. |
us_main_pollutant | string | The pollutant that set us_aqi, like pm2_5. |
aqi | integer | Current AQI on the Indian CPCB scale. |
main_pollutant | string | The pollutant that set aqi. |
aqi_breakdown | object | The Indian sub index for each pollutant (pm2_5, pm10, no2, so2, co). The largest of these is aqi. |
concentrations_us_units | object | The raw concentrations behind the index, keyed by pollutant, with ch4 as well. Despite the name, these are in CPCB units (µg/m³, with CO and methane in mg/m³), so they line up with the Indian sub indices next to them. The name stuck around for the same reason as aqi. |
averages_24h | object | 24 hour means of pm2_5, pm10, temp and humidity, plus the aqi and us_aqi worked out from those means. Satellite zones have no temp or humidity here. |
history | array | Hourly points for the last 24 hours, oldest first. See below. |
nodes | object | null | Every configured sensor in the zone, keyed by its name, offline ones included. null for satellite zones. |
weather | object | Current weather and a short advisory. See below. |
warning | string | null | A human readable note when something about this reading needs a caveat, like a sensor being offline or an unnaturally fast spike. Show it to your users if it's there. See warnings. |
zone_applied | string | The zone's zone_type, echoed back. |
History points
Each item in history (and in each node's history) is one hour.
| Field | Type | Description |
|---|---|---|
ts | integer | Start of the hour, Unix seconds. |
pm2_5, pm10 | number | Mean concentration for the hour, µg/m³. |
aqi, us_aqi | integer | The index for that hour on each scale. |
temp, humidity | number | Sensor zones only. |
Nodes
Each value in nodes carries the same reading fields as the zone (pm2_5, pm10, temp, humidity, aqi, us_aqi), its own 24 hour history and a status. A node that isn't active has null readings, but its history is still filled in from what it reported before it dropped out. The statuses are explained under node statuses.
Weather
| Field | Type | Description |
|---|---|---|
condition | string | One of clear, cloudy, fog, smog, rain, snow or thunderstorm. Fog with high PM2.5 is reported as smog. |
weather_code | integer | The raw WMO weather code from Open-Meteo, if you want finer detail than condition. |
precipitation | number | Current precipitation, mm. |
season | string | winter (Dec to Feb), spring (Mar, Apr), summer (May, Jun), monsoon (Jul to Sep) or autumn (Oct, Nov). |
text | string | One or two sentences on what the weather means for the air right now. Written for people, so it's fine to show as is. |
Current air in many zones
The same payload as /aqi/zone/{zone_id} for several zones at once, wrapped as {"zones": [...]}. The apps used to ask for every zone one by one, which came to about twenty five requests on every refresh, so if you're showing more than a couple of zones, this is the one to use.
| Query | Type | Description |
|---|---|---|
ids | string, optional | Comma separated zone IDs, like srinagar,leh,jammu_city. Leave it out to get every zone. Duplicates are dropped and the order you asked in is kept. |
If any ID in ids doesn't exist, the whole request fails with 404 and detail lists the unknown ones (zone not found: nope). A known zone that fails to load on the server is left out of the list instead of failing the request, so check which IDs came back if you need all of them.
curl "https://api.breatheoss.app/aqi/zones?ids=srinagar,leh"
Legacy zone paths
Every zone also has its own flat path (/aqi/srinagar, /aqi/leh and so on) that returns exactly the same thing as /aqi/zone/{zone_id}. Older app releases still call these, so they aren't going anywhere, but new code should use /aqi/zone/{zone_id}, which gives a clean 404 for an unknown zone.
Sensor hardware
Details for every physical sensor in the network, as {"sensors": [...]}. This is what the maps in the apps use to place each sensor, and it's the place to look if you want to know who hosts what and since when.
| Field | Type | Description |
|---|---|---|
name | string | The sensor's name, which is also its key in a zone's nodes. |
zone | string | The zone's display name (not its ID), like Srinagar. |
location_id | integer | The AirGradient location ID. |
provider | string | Hardware vendor, sometimes with a credit for whoever donated it. |
model | string | AirGradient model code, like O-1PST. |
is_kit | boolean | Whether it was assembled from a kit. |
latitude, longitude | number | Where the sensor actually is. |
installation_date | string | When it went up. This is free text written by hand, so the format varies (Oct 4, 2025, 23 Jul, 2026). Parse it loosely or just display it. |
Historical readings
The full archive of ground sensor readings, averaged into buckets of whatever size you ask for. The response is streamed row by row, so even a long range doesn't blow up memory on either side, and it comes as JSON by default or as a CSV file with ?format=csv.
| Parameter | Description |
|---|---|
location | A zone ID, a single sensor as <zone_id>_<sensor name> (like srinagar_Kanipora, URL encoded if the name has spaces or commas), or all for every zone and sensor at once. |
time_range | How far back to go, either rolling (24h, 7d, 1mo) or a fixed calendar window (2026-10, 2026-W42). See time ranges. |
interval | Bucket size, like 15m, 1h or 1d. |
metrics | Comma separated, any of pm2.5, pm10, temp, humidity. Unknown names are dropped, and if none are left you get pm2.5,pm10. |
format (query) | json (default) or csv. |
curl "https://api.breatheoss.app/historical-data/jammu_city/6h/1h/pm2.5,pm10"
{
"data": [
{ "zone_id": "jammu_city", "ts": 1794409200, "pm2_5": 213.96, "pm10": 230.83 },
{ "zone_id": "jammu_city", "ts": 1794412800, "pm2_5": 181.29, "pm10": 196.83 },
{ "zone_id": "jammu_city", "ts": 1794430800, "pm2_5": 111.26, "pm10": 122.64 }
],
"stats": {
"max_pm2_5": 213.96,
"min_pm2_5": 111.26,
"avg_pm2_5": 148.72,
"max_pm10": 230.83,
"min_pm10": 122.64,
"avg_pm10": 161.56
}
}
Each row's ts is the start of its bucket. The metric keys come back as pm2_5 (with an underscore) even though you ask for pm2.5. stats sits at the end of the document because it can only be worked out once every row has gone past, and it only covers PM2.5 and PM10 (it's an empty object if there were no rows). The CSV has a header row of zone_id,ts followed by the metrics, and it's sent as a download named historical_<location>.csv.
A few things that are worth knowing before you build on this:
- Only sensor zones have archived history. A satellite zone returns
{"data": [], "stats": {}}(its last 24 hours are still in/aqi/zone/{zone_id}, they just aren't archived). - An unknown
locationisn't an error either, it's an empty result, so check for that instead of waiting for a404. - For a zone with more than one sensor, the series is rebuilt from the individual sensors with a per sensor offset applied, so the line doesn't jump up or down whenever a sensor joins or drops out. This means a zone's history can differ slightly from a plain average of its sensors' histories, and that's on purpose.
- Intervals that are a whole number of 15 minute steps (
15m,1h,1d) are served from a pre-aggregated table and are fast. Anything finer, like5m, goes to the raw readings and is noticeably slower on long ranges. - With
all, the rows for every zone and sensor at the sametsare next to each other, so group byzone_idon your side.
Weather history
Past weather for a zone, cut into the same buckets as /historical-data (as long as you pass the same time_range and interval), so you can line it up with a readings chart and answer things like "how much cleaner is the air on rainy days". This is what the weather filter on the history screen in the apps uses.
{
"interval": 21600,
"anchor": 0,
"points": [
{ "ts": 1794333600, "condition": "cloudy", "precipitation": 0.0 },
{ "ts": 1794355200, "condition": "rain", "precipitation": 0.9 },
{ "ts": 1794376800, "condition": "clear", "precipitation": 0.0 },
{ "ts": 1794398400, "condition": "rain", "precipitation": 0.1 },
{ "ts": 1794420000, "condition": "fog", "precipitation": 0.0 }
],
"zone_id": "srinagar"
}
interval is the bucket size in seconds. Buckets start at anchor + n * interval, where anchor is 0 for rolling ranges and the start of the window for calendar ones, which is how you match a weather point to a history row. condition uses the same values as the live weather object (except smog, which needs a PM2.5 reading), and precipitation is the total for the bucket in mm. An unknown zone returns 404.
Seasonal normals
What a typical month looks like in a zone, built from Open-Meteo reanalysis data going back to 2022 and refreshed every 30 days. It's twelve entries (one per calendar month) and it's useful for putting today in context, since 80 µg/m³ of PM2.5 means something very different in July than it does in January.
| Field | Type | Description |
|---|---|---|
current_month | integer | 1 to 12. |
current_season | string | Same values as the live weather season. |
months[].month, name, season | int, string | Which month this entry is. |
months[].pm2_5, pm10 | number | Average concentration for that month, µg/m³, from reanalysis. |
months[].aqi, us_aqi | integer | Those averages on each AQI scale. |
months[].precipitation | number | Typical total for the month, mm. |
months[].temp | number | Average temperature, °C. |
months[].sensor_pm2_5, sensor_pm10 | number, optional | Only present when our sensors logged at least 200 readings in that month. These are real measurements, kept next to the reanalysis values instead of blended into them, and they're often a lot higher in winter (the satellite model tends to underestimate wood and coal smoke at street level). |
source | string | Where the normals came from. |
Health check
Reports whether the database and the Redis cache are both reachable. It returns 200 with "status": "ok" when they are, and 503 with "status": "degraded" when either one is down (in which case the history, weather and seasonal endpoints will likely fail too, while live readings usually keep working).
{ "status": "ok", "postgres": "ok", "redis": "ok" }
Time ranges and intervals
/historical-data and /weather-history take the same time_range and interval syntax. A rolling range ends now and is written as a number followed by a unit.
| Unit | Meaning | Example |
|---|---|---|
m | minutes | 15m |
h | hours | 6h |
d | days | 7d |
w | weeks | 2w |
mo | months, counted as 30 days | 1mo |
y | years, counted as 365 days | 1y |
Watch out for m against mo, because 1m is one minute and 1mo is a month. A unit the API doesn't recognise falls back to one day without telling you, so a typo won't error out, it'll just give you the wrong window.
Calendar windows
time_range can also be a fixed window, which is handy for "show me last October" or for comparing the same week across years. Dates are read in IST and buckets are counted from the start of the window, so a 1d interval on a calendar window gives you buckets that start at local midnight.
| Window | Format | Example |
|---|---|---|
| Month | YYYY-MM | 2026-10 |
| ISO week, Monday to Sunday | YYYY-Www | 2026-W42 |
| Day | YYYY-MM-DD | 2026-10-15 |
| Span of days, both ends included | YYYY-MM-DD_YYYY-MM-DD | 2026-10-01_2026-10-07 |
A window that doesn't parse, ends before it starts or begins before the year 2000 returns 400 with a detail that shows the accepted formats. Our archive starts in late 2025, when the first sensor went up, so anything earlier is simply empty.
curl "https://api.breatheoss.app/historical-data/jammu_city/2026-W42/1h/pm2.5,pm10?format=csv" -o jammu-w42.csv
How the numbers are made
You don't need any of this to use the API, but if you're doing research or comparing our numbers against another source, this is what happens between the sensor and the JSON. All of it is in the open in the API repository if you'd rather read the code.
From concentration to AQI
Each pollutant gets its own sub index, and that comes from finding which band of the official breakpoint table its concentration falls into, then interpolating in a straight line inside that band.
Here C is the concentration, Clo and Chi are the edges of the band it falls in, and Ilo and Ihi are the index values at those edges. As a worked example, take 85.6 µg/m³ of PM2.5 on the US scale. That lands in the 55.5 to 125.4 band, which maps to 151 to 200, so the sub index is (200 - 151) / (125.4 - 55.5) × (85.6 - 55.5) + 151, which comes to about 172.
The overall AQI is the highest sub index, not an average, and the pollutant behind it is reported as us_main_pollutant (or main_pollutant for the Indian scale). A concentration above the top band is capped at 500. For the US scale, gases are converted from µg/m³ to ppb (or ppm for CO) at 25 °C first, and PM2.5 is truncated to one decimal before the lookup, the way the EPA does it. For the Indian scale, CO and methane are converted to mg/m³. Methane is reported but isn't part of either index.
The top level aqi and us_aqi are worked out from the current concentrations, so they react to a spike right away. The official definitions use a 24 hour average for particulates instead, and that version is in averages_24h, which is the one to use if you're comparing against government stations.
Spike protection
Low cost sensors on someone's balcony get things blown on them, sprayed near them and occasionally set on fire next to them, and one bad reading can drag a whole zone into "hazardous". So before a sensor's reading is used, it's checked against a few limits.
- A reading above 600 µg/m³ of PM2.5 or 900 of PM10 is thrown out, since real air in these zones doesn't get there.
- A PM2.5 jump of more than 200 µg/m³ inside an hour is treated as local interference and thrown out. From October to February the limit is 300, because cold nights with heating smoke and temperature inversions really do push the air up that fast.
- A sensor that trips either check is left out of its zone for an hour (its
statusisgrace_periodduring that time) so it can't flicker back in on the next refresh. - During Diwali (the dates are kept in the API's
festivals.json), the jump check is turned off and the ceilings go up to the sensor's hardware limit of 999, because firecracker spikes on those nights are real and filtering them out would hide exactly what people want to see.
If every sensor in a zone is filtered or offline, the zone falls back to Open-Meteo for that refresh, as described in ground sensors and satellite estimates.
Node statuses
| Status | Meaning |
|---|---|
active | Reporting normally and counted in the zone average. |
stale | Still answering, but its latest reading is more than an hour old. |
offline | AirGradient didn't return the sensor at all. |
no_data | The sensor answered without a PM2.5 value. |
spike_detected | The reading tripped spike protection on this refresh. |
grace_period | Tripped spike protection recently and is sitting out for the rest of the hour. |
error | The request to AirGradient failed (a timeout or similar) on our end. |
Warnings
warning is null most of the time. When it isn't, it's plain text meant to be shown to people as is, and it comes from one or both of two places. The first is the sensors, when one or more of them were dropped and the zone is running on fewer sensors or on satellite data (like Physical sensor temporarily offline. Using satellite-based estimates from Open-Meteo.). The second is the zone as a whole, when its reading went past the spike ceilings or its AQI rose by more than 150 in the last hour (200 from October to February), which gets a general note that the reading may not reflect the whole region. If both apply, the sensor note comes first and the two are separated by a blank line.
The exact wording isn't part of the contract and may change, so check whether warning is null instead of matching on the text.
AQI scales
These are the bands each scale uses. The colours match the Breathe apps, so feel free to use them if you'd like your project to look consistent with ours.
US EPA (us_aqi)
| AQI | Category | PM2.5, µg/m³ |
|---|---|---|
| 0 to 50 | Good | 0 to 9.0 |
| 51 to 100 | Moderate | 9.1 to 35.4 |
| 101 to 150 | Unhealthy for sensitive groups | 35.5 to 55.4 |
| 151 to 200 | Unhealthy | 55.5 to 125.4 |
| 201 to 300 | Very unhealthy | 125.5 to 225.4 |
| 301 to 500 | Hazardous | 225.5 and up |
The PM2.5 bands are the 2024 revision, where "good" tops out at 9.0 instead of the older 12.0.
Indian CPCB (aqi)
| AQI | Category | PM2.5, µg/m³ |
|---|---|---|
| 0 to 50 | Good | 0 to 30 |
| 51 to 100 | Satisfactory | 31 to 60 |
| 101 to 200 | Moderate | 61 to 90 |
| 201 to 300 | Poor | 91 to 120 |
| 301 to 400 | Very poor | 121 to 250 |
| 401 to 500 | Severe | 251 and up |
Put the two tables side by side and the difference is pretty obvious, since 55 µg/m³ of PM2.5 is "unhealthy for sensitive groups" on the US scale but still "satisfactory" on the Indian one. The full tables for every pollutant are in aqi_breakpoints.json (Indian) and conversions.py (US).
Errors
Errors come back as JSON with a detail string, which is FastAPI's standard shape.
{ "detail": "zone not found" }
| Code | When |
|---|---|
400 | A calendar window in time_range that doesn't parse, runs backwards or starts before 2000. |
404 | An unknown zone on /aqi/zone, /aqi/zones, /seasonal or /weather-history, or a path that doesn't exist. |
422 | A missing or badly typed parameter, caught by FastAPI before our code runs. |
500 | Something broke on our side. A malformed rolling range like xh can also land here, which we know isn't ideal. |
503 | /health only, when the database or cache is down. |
When an upstream source (AirGradient or Open-Meteo) has a bad moment, the live endpoints serve the last good reading for the zone instead of an error, so an older timestamp_unix is the thing to look out for there, not a status code.
Code examples
JavaScript
Current US AQI for every zone, worst first. This runs as is in Node 18 or later, or anywhere else with fetch (just not from a browser on another domain, because of CORS).
const res = await fetch('https://api.breatheoss.app/aqi/zones');
const { zones } = await res.json();
zones
.sort((a, b) => b.us_aqi - a.us_aqi)
.forEach((z) => {
console.log(`${z.zone_name.padEnd(12)} ${z.us_aqi} (${z.source})`);
});
Python
A month of hourly PM2.5 for Jammu as a pandas DataFrame, using the CSV output.
import pandas as pd
url = "https://api.breatheoss.app/historical-data/jammu_city/2026-10/1h/pm2.5,pm10?format=csv"
df = pd.read_csv(url)
df["time"] = pd.to_datetime(df["ts"], unit="s", utc=True).dt.tz_convert("Asia/Kolkata")
print(df.set_index("time")["pm2_5"].resample("1D").mean())
Shell
Just the number, for a status bar or a cron job.
curl -s https://api.breatheoss.app/aqi/zone/srinagar | jq '.us_aqi'
Source and help
The API is MIT licensed and lives at breathe-OSS/api, and its README covers running it yourself (you'll need Python 3.10 or later and a Redis server). If something on this page doesn't match what the API actually does, that's a bug in one of the two, so please open an issue there.
For questions, or to tell us what you've built (we'd genuinely love to see it), the quickest place is our Discord, or you can write to [email protected]. If you use the data in research or in something public, a link back to breatheoss.app is appreciated, and it helps more people find the network and maybe host a sensor of their own.