openaqi docs

TypeScript SDK

Zero-dependency client for Node, browsers, Deno and edge runtimes.

npm install openaqi

No 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 OpenaqiError with a machine-readable code, so you branch on no_coverage versus rate_limited without 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-After honoured 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-locked

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