TypeScript SDK
Zero-dependency client for Node, browsers, Deno and edge runtimes.
npm install openaqiNo dependencies. It runs on fetch, which exists in Node 18+, every browser,
Deno, Bun and every edge runtime — a client library that drags in an HTTP stack
is one nobody wants in their bundle.
import { createClient } from "openaqi";
const openaqi = createClient({ apiKey: process.env.OPENAQI_KEY! });
const { data, meta } = await openaqi.air({ lat: 37.3352, lon: -121.8811 });
for (const m of data.metrics) {
console.log(`${m.label}: ${m.value} ${m.unit} (${m.severity ?? "ungraded"})`);
}Why use it rather than fetch
- Typed errors. Every failure is an
OpenaqiErrorwith a machine-readablecode, so you branch onno_coverageversusrate_limitedwithout matching message text. - Rate limit on every result. No header parsing to find out how much allowance you have left.
- Retries that behave. Exponential backoff with jitter,
Retry-Afterhonoured exactly, and no retry on an error that will not fix itself.
Methods
air({ lat, lon })
Current conditions. Also takes cell, window, maxRings, metrics, signal.
const { data, rateLimit } = await openaqi.air({
lat, lon,
window: 180,
metrics: ["pm2p5", "co2"],
});
console.log(`${rateLimit?.remaining} requests left this minute`);reading(metric, { lat, lon })
The single number, or null if it is not reported nearby. Saves writing the
.find() everyone writes.
const pm = await openaqi.reading("pm2p5", { lat, lon });
if (pm && pm.severity !== "good") warnTheUser(pm.value, pm.unit);history({ lat, lon, hours })
const { data } = await openaqi.history({
lat, lon,
from: new Date(Date.now() - 7 * 864e5),
interval: "1h",
metrics: ["pm2p5"],
});
// [{ t: "2026-08-01T00:00:00.000Z", pm2p5: 8.4 }, …]cells({ bbox }), meta(), status()
As the endpoints. meta() and status() need no key but go through the same
client.
watch({ lat, lon }, onData)
Polls and calls back; returns a stop function.
const stop = openaqi.watch({ lat, lon, intervalMs: 120_000 }, (data) => {
render(data);
});Backs off on failure and waits exactly as long as a rate limit tells it to. Readings arrive every minute or two at best, so polling faster only spends quota.
Errors
import { OpenaqiError } from "openaqi";
try {
await openaqi.air({ lat, lon, maxRings: 0 });
} catch (err) {
if (err instanceof OpenaqiError) {
switch (err.code) {
case "no_coverage": return showEmptyState();
case "rate_limited": return retryIn(err.retryAfterSeconds!);
case "unauthorized": return fixYourKey(err.hint);
default: return report(err.requestId);
}
}
throw err;
}In a browser
const openaqi = createClient({ apiKey: PUBLIC_KEY }); // origin-lockedOrigin-lock the key in your account first. See authentication.
Cancellation
const controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
await openaqi.air({ lat, lon, signal: controller.signal });Requests time out after 15 seconds by default (timeoutMs).
Node 16 and older
fetch is not global there, so pass one:
import fetch from "node-fetch";
const openaqi = createClient({ apiKey, fetch: fetch as unknown as typeof globalThis.fetch });Source
MIT, and open to contributions: github.com/bsidio/openaqi-js.