Free weather API with air quality in one call
You're building a wildfire notification app, a running-route planner, or an e-commerce banner that reacts to local air quality. You need current temperature, rain conditions, and PM2.5 from the same API. Every popular weather provider gates air quality behind a separate product tier, a separate API key, or an enterprise contract. You shouldn't have to pay $40 a month to tell a user whether it's safe to jog outside.
The botoi API gives you /v1/weather/current and
/v1/air-quality/check on the same free key. Two POST endpoints, JSON in and JSON
out, and 1000 requests a day once you sign up.
Why AQI is hidden behind a paywall
OpenWeatherMap sells its Air Pollution API as an upgrade. The free plan covers current weather and a five-day forecast, but historical air quality and higher AQI call limits live on the Professional and Enterprise tiers that start at $40 a month. You also carry a separate API key per product family, so a hobby project ends up juggling two dashboards.
WeatherAPI.com advertises a million free calls a month, and they deliver on that for weather. Air quality is bundled into the Pro plan. AccuWeather keeps AQI on its Enterprise tier with a sales call gate. Tomorrow.io hands you a trial with rate caps low enough that a single page refresh burns through your budget.
| Provider | Free-tier calls | AQI free? | Auth | Combined endpoints? |
|---|---|---|---|---|
| Botoi | 5/min anonymous, 1000/day keyed | Yes | One key for both | Two POST endpoints, one key |
| OpenWeatherMap | 1000/day | No (paid upgrade) | Separate key per product | No, split across products |
| WeatherAPI.com | ~1M/month | No (Pro tier) | One key, Pro gate | Single endpoint, Pro only |
| AccuWeather | 50/day trial | No (Enterprise) | Sales-gated | No |
Get current weather in one curl
The /v1/weather/current endpoint accepts a city name, a zip code, or a lat/lon
pair. It returns temperature in both Celsius and Fahrenheit, humidity, wind speed, a text
condition, an icon key you can map to your own sprite sheet, and the resolved coordinates.
curl -X POST https://api.botoi.com/v1/weather/current \
-H "Content-Type: application/json" \
-d '{"city": "San Francisco"}' Response:
{
"success": true,
"data": {
"city": "San Francisco",
"temp_c": 16.3,
"temp_f": 61.3,
"humidity": 78,
"wind_kph": 12.5,
"condition": "Partly cloudy",
"icon": "partly_cloudy",
"lat": 37.7749,
"lon": -122.4194
}
}
Hold on to the lat and lon fields. You'll feed them straight into
the air quality endpoint so you don't burn a second geocoding call.
Add air quality in a second curl
The /v1/air-quality/check endpoint takes lat/lon and returns the US EPA Air
Quality Index, a category label, and the individual pollutant concentrations. PM2.5 and PM10
are the two numbers most health apps care about; the rest cover ozone, nitrogen dioxide,
sulfur dioxide, and carbon monoxide for stricter use cases.
curl -X POST https://api.botoi.com/v1/air-quality/check \
-H "Content-Type: application/json" \
-d '{"lat": 37.7749, "lon": -122.4194}' Response:
{
"success": true,
"data": {
"aqi": 62,
"aqi_category": "Moderate",
"pm25": 18.4,
"pm10": 25.1,
"o3": 0.041,
"no2": 0.012,
"so2": 0.003,
"co": 0.4
}
}
An AQI of 62 with a Moderate label means most people can go outside, but anyone with asthma
should take it easy. The aqi_category field saves you from bucketing the number
yourself and keeps your UI copy consistent with official EPA language.
Combine both in a single Node.js function
Most apps want one number per city, not two separate round-trips scattered across the codebase. This 40-line function wraps both endpoints, handles timeouts, and returns a merged object you can drop into any dashboard.
const BASE_URL = "https://api.botoi.com/v1";
const TIMEOUT_MS = 5000;
async function postJson(path, body) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(`\${BASE_URL}\${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Api-Key": process.env.BOTOI_API_KEY ?? "",
},
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) {
throw new Error(`\${path} returned \${res.status}`);
}
const { data } = await res.json();
return data;
} finally {
clearTimeout(timer);
}
}
export async function getConditions(city) {
// Step 1: get weather (also gives us lat/lon)
const weather = await postJson("/weather/current", { city });
// Step 2: use those coordinates for AQI
const air = await postJson("/air-quality/check", {
lat: weather.lat,
lon: weather.lon,
});
return {
city: weather.city,
temp_c: weather.temp_c,
condition: weather.condition,
aqi: air.aqi,
aqi_category: air.aqi_category,
pm25: air.pm25,
};
}
// Example
const report = await getConditions("Tokyo");
console.log(report);
// { city: "Tokyo", temp_c: 14.1, condition: "Clear", aqi: 48, aqi_category: "Good", pm25: 11.2 }
The function does two sequential calls because the second needs coordinates from the first.
If you already have lat/lon cached from a previous request, run both calls in parallel with
Promise.all and cut the latency in half. Node 20+ has native fetch
and AbortController, so you don't need axios or node-fetch.
Three real-world uses with code
Running-route planner
A fitness app suggests outdoor routes. When PM2.5 spikes or a thunderstorm is rolling in, route suggestions should pause and show an indoor alternative. This function returns one of three states: go, skip, or advisory.
import { getConditions } from "./conditions.js";
const BAD_WEATHER = ["Thunder", "Heavy rain", "Rain", "Snow", "Hail"];
export async function shouldRunOutside(city) {
const { condition, aqi, aqi_category, pm25 } = await getConditions(city);
const wetOrStormy = BAD_WEATHER.some((w) =>
condition.toLowerCase().includes(w.toLowerCase())
);
if (aqi >= 150) {
return {
ok: false,
reason: `Air quality is \${aqi_category} (AQI \${aqi}, PM2.5 \${pm25}). Skip the outdoor run.`,
};
}
if (wetOrStormy) {
return {
ok: false,
reason: `\${condition} in your area. Try the treadmill today.`,
};
}
if (aqi >= 100) {
return {
ok: "advisory",
reason: `AQI \${aqi} (\${aqi_category}). Shorten the run or pick a route away from traffic.`,
};
}
return { ok: true, reason: `Clear skies and AQI \${aqi}. Go for it.` };
}
// Example
console.log(await shouldRunOutside("Delhi"));
// { ok: false, reason: "Air quality is Unhealthy (AQI 168, PM2.5 92.3). Skip the outdoor run." } The three-state return is important. A binary yes/no hides the middle ground where the run is fine but shorter is smarter. Sensitive users appreciate the nuance, and you avoid frustrating experienced runners with over-cautious alerts.
E-commerce banner for air purifiers
A home goods store sells air purifiers. When a visitor lands from a city with AQI above 100, the product is genuinely useful, so surface it. When the air is clean, hide the banner so it doesn't feel like fear marketing.
import { useEffect, useState } from "preact/hooks";
export function AirQualityBanner({ city }) {
const [conditions, setConditions] = useState(null);
useEffect(() => {
fetch("/api/conditions?city=" + encodeURIComponent(city))
.then((res) => res.json())
.then(setConditions);
}, [city]);
if (!conditions || conditions.aqi <= 100) {
return null;
}
const { aqi, aqi_category } = conditions;
return (
<aside class="rounded-lg bg-amber-50 border border-amber-200 p-4 text-amber-900">
<p class="font-semibold">
Air quality alert in {city}: AQI {aqi} ({aqi_category}).
</p>
<a href="/collections/air-purifiers" class="underline">
Shop air purifiers
</a>
</aside>
);
}
Fetch the conditions from your own /api/conditions proxy so your API key stays
on the server. Cache the result per city for 30 minutes on the edge; AQI moves slowly enough
that half-hour freshness is fine for a storefront banner.
Delivery ETA with weather buffer
A delivery app shows a 25-minute ETA. Heavy rain in the rider's city means 10 more minutes. Thunderstorms mean 20. Adding the buffer before you display the ETA prevents disappointed customers tracking a late rider.
import { getConditions } from "./conditions.js";
const WET_CONDITIONS = ["Rain", "Thunder", "Snow", "Sleet"];
export async function adjustEtaForWeather(city, baseEtaMinutes) {
const { condition } = await getConditions(city);
const isWet = WET_CONDITIONS.some((w) =>
condition.toLowerCase().includes(w.toLowerCase())
);
if (!isWet) {
return { eta: baseEtaMinutes, buffer: 0, condition };
}
// Add 10 minutes for rain, 20 for thunderstorms
const buffer = condition.toLowerCase().includes("thunder") ? 20 : 10;
return {
eta: baseEtaMinutes + buffer,
buffer,
condition,
note: `\${condition} in \${city}. Added \${buffer} min buffer.`,
};
} Keep the buffer rules simple. Drivers and customers both trust ETAs that land within the window, and padding 10 to 20 minutes for wet weather is honest rather than defensive.
Rate limits and keys
Anonymous access allows 5 requests per minute and 100 per day, IP-based. That's enough to build a demo or wire up a personal project without signing up.
Sign up for a free key at botoi.com/api to raise the cap to 1000 requests
per day. The same key works for both weather and air quality, plus 190+ other endpoints on
the platform. Hit a 429 and the API returns a Retry-After header in seconds so
your client can back off cleanly.
Key points
-
/v1/weather/currenttakes a city, zip, or lat/lon and returns temperature, humidity, wind, a text condition, and coordinates. -
/v1/air-quality/checktakes lat/lon and returns US EPA AQI, a category label, and PM2.5, PM10, O3, NO2, SO2, and CO readings. - Both endpoints share one API key. Every other provider charges extra for AQI or hides it behind an enterprise gate.
- Free tier covers 5 requests per minute anonymous, 1000 per day with a key. Responses are edge-cached on Cloudflare Workers for sub-100ms latency.
- Cache weather for 15 minutes and AQI for 30 minutes in your app. Neither moves fast enough to need real-time polling, and caching keeps you inside the free tier even for heavy dashboards.
Frequently asked questions
- What AQI scale does the API use?
- The /v1/air-quality/check endpoint returns the US EPA Air Quality Index, a 0-500 scale where 0-50 is Good, 51-100 Moderate, 101-150 Unhealthy for Sensitive Groups, 151-200 Unhealthy, 201-300 Very Unhealthy, and 301+ Hazardous. The aqi_category field carries the label so you don't need to bucket the number yourself.
- How fresh is the weather data?
- Current conditions refresh hourly from national meteorological services. For most dashboards, notification apps, and e-commerce use cases, hourly observations are accurate enough. If you need minute-level updates for aviation or severe weather alerts, cache less aggressively and call on demand.
- Does it work by zip code or only lat/lon?
- Both. Send {"city": "San Francisco"} or {"city": "94107"} to /v1/weather/current and the API geocodes it for you. The response includes lat and lon fields so you can feed them straight into /v1/air-quality/check without a separate geocoding call.
- Is there a batch endpoint for multiple cities?
- The weather endpoint handles one city per request. For multiple cities, fan out with Promise.all on the client side; the free tier allows 5 requests per minute, so up to 5 cities per burst. For heavier batch jobs, a free API key raises the cap to 1000 requests per day.
- What happens when I hit the rate limit?
- The API returns HTTP 429 with a Retry-After header in seconds. Back off for the value shown and retry; the window is a rolling minute on anonymous access and a rolling day on keyed access. Handle 429 explicitly so you don't silently drop weather updates from your dashboard.
Try this API
Weather API — interactive playground and code examples
More tutorial posts
Start building with botoi
150+ API endpoints for lookup, text processing, image generation, and developer utilities. Free tier, no credit card.