Skip to content

omnilab/openaq

Omnilab OpenAQ: air quality measurements of the OpenAQ platform (API v3) - the hourly or daily averages of a pollutant (PM2.5, PM10, NO2, O3, SO2, CO, black carbon... by OpenAQ's parameter names) at the nearest location of a point measuring it, in the sensor's unit. An OpenAQ API key is required (the user's own, sent as X-API-Key); not tested live.

Floor
Provider · Omnilab
Version
1.x
Conformance suite
not wired
Verified for real
Its README does not say
Licence
MIT
composer require omnilab/openaq:1.x-dev

OpenAQ's API v3 (https://api.openaq.org/v3/) for omnisense/omnilab: the hourly and daily averages of a pollutant (or a temperature, a humidity) at the nearest location of a point that measures it, as series. A key is required. Not tested live with a key.

Installation

composer require omnilab/openaq
use Omnilab\OpenAq\OpenAqSourceFactory;

$openaq = (new OpenAqSourceFactory($httpClient))->create([
    'api_key' => getenv('OPENAQ_API_KEY'), // required by OpenAQ: the user's own key
    'radius_km' => 10,                     // optional: how far from a Point (25 at most)
]);

With the Symfony bundle, the factory is registered as openaq when the bundle knows it; otherwise register OpenAqSourceFactory as a service.

Credentials

Every call needs an OpenAQ API key: its user registers at explore.openaq.org/register, keeps the key secret, and gives it as api_key (sent as X-API-Key). The key's limit is 60 calls a minute and 2,000 an hour; repeated excesses can suspend it. Without a key the source is built and its calls are made: OpenAQ answers each with a 401.

Options

The factory's options, with the defaults it applies (OpenAqSourceFactory):

Option Type Default Required Effect
api_key ?string null no the user's key, sent as X-API-Key; null: no header, and OpenAQ's 401 on every call
radius_km float 10 no how far from a Point a location is looked for, in kilometres: 25 at most (more is an \InvalidArgumentException at create())
mailto ?string null no a contact address, sent in the User-Agent
user_agent ?string null no the whole User-Agent, in place of the one made from mailto
throttle float 1 no seconds left between two calls: 60 a minute per key
base_uri string https://api.openaq.org/v3/ no the API the source calls

Capabilities

capabilities() answers series, historical; an operation it does not answer throws NotSupportedException before any call.

Capability Supported
entity no locations are not given as entities: the series' label carries their ids
search no
series yes at a Point: for each variable (OpenAQ's parameter: pm25, no2, o3...), the nearest location within radius_km measuring it, then its sensor's hourly or daily means; the period defaults to the last seven days
products no
occurrences no
datasets no
jobs no
open no
structure no
cross_references no
forecast no
historical yes the averages of the past; no forecast
area no a BBox is refused: the nearest location of a Point answers
station no a station's identifier is refused (the core has no scheme for OpenAQ's locations)
cloud_cover no

Which API

OpenAQ's API v3 (https://api.openaq.org/v3/, OpenAPI 3.0.0 on 2026-10-16), the key in the X-API-Key header:

Call For
locations?coordinates=lat,lon&radius=&limit=100 the locations within the radius (in metres, 25,000 at most), each with its sensors and the parameter each measures
sensors/{id}/hours?datetime_from=&datetime_to=&limit=1000&page= the hourly averages ("the hourly average (mean) value of measurements")
sensors/{id}/days?date_from=&date_to=&limit=1000&page= the daily averages ("computed from the hourly average values from 01:00 to 0:00 in local time")

Pages of 1,000, followed until one is shorter (twenty at most for one series). Errors: a 401 without a key or with a wrong one, a 429 past the rate (a RateLimitedException), a 422 for a bad parameter; their message (or detail) is said.

Its shape

A series (Capability::SERIES, HISTORICAL): at a Point, for each variable asked, the nearest location within radius_km that has a sensor of that parameter (by its distance, or the great circle when the API gives none), then that sensor's averages. A variable no location near measures gives a series without points, at the point asked: one series per variable asked, in the order asked. The variables are OpenAQ's parameter names: pm25, pm10, pm1, no2, no, nox, o3, so2, co, bc, temperature, relativehumidity... and the unit the sensor's (µg/m³, ppm...: OpenAQ keeps each provider's). SeriesQuery::HOURLY gives a point at each hour's start (the period's datetimeFrom), DAILY each local day; both are means (Series::MEAN; any other aggregation, another step, a box or a station are refused before any call). The period defaults to the last seven days. The label names the location, its OpenAQ ids and its provider.

OpenAQ's locations are not given as entities: the core has no scheme for them, and the series' label carries their ids.

The Collector

No path.

What the API does not give here

The raw measurements (sensors/{id}/measurements), the monthly, yearly and periodic aggregates (hour of day, day of week...), the latest values, the flags, the locations as entities and their search by country, provider or bounding box, the parameters, providers, owners, instruments, manufacturers and licences lists are not read.

Example

<?php

require __DIR__.'/vendor/autoload.php';

use Omnilab\Exception\ProviderException;
use Omnilab\Model\Point;
use Omnilab\OpenAq\OpenAqSourceFactory;
use Omnilab\Source\SeriesQuery;
use Symfony\Component\HttpClient\HttpClient;

$openaq = (new OpenAqSourceFactory(HttpClient::create()))->create([
    'api_key' => getenv('OPENAQ_API_KEY') ?: null,   // required by OpenAQ: the user's own key
    'mailto' => 'you@example.org',
]);

$day = new DateTimeImmutable('2026-10-26');
try {
    foreach ($openaq->series(new SeriesQuery(['pm25', 'no2'], new Point(48.5734, 7.7521), $day, $day, SeriesQuery::DAILY)) as $series) {
        printf("%s %s at %s: %s\n", $series->variable, $series->unit, $series->label, implode(', ', $series->values()));
    }
} catch (ProviderException $e) {
    echo $e->status, ' ', $e->getMessage(), "\n";
}

Without a key (run on 2026-10-27: one call, locations):

401 [openaq] the service answered 401: Unauthorized. A valid API key must be provided in the X-API-Key header.

With a key it prints the daily means of PM2.5 and NO2 at the nearest locations of Strasbourg: not run here (no key).

# config/packages/omnilab.yaml
omnilab:
    sources:
        openaq: { factory: openaq, options: { api_key: '%env(OPENAQ_API_KEY)%', mailto: '%env(OMNILAB_MAILTO)%', radius_km: 10 } }

Errors

Every call goes through HttpSource: a 404 or a 410 (and a 204) is null - an empty page for a list -; a 429 is a RateLimitedException (retryAfter from Retry-After); a 5xx, a time-out or a host that does not answer an UnavailableException; any other 4xx a ProviderException with its status and what the service said; an answer that is not JSON a ProviderException, a web page answered for JSON an UnavailableException.

Before any call (NotSupportedException): a box, a station, a step other than hourly or daily, an aggregation other than the mean. A 401 (no key, a wrong one) and a 422 (a bad parameter) are ProviderExceptions with OpenAQ's message or detail; a 429 past the key's rate a RateLimitedException. A series that would take more than twenty pages of 1,000 values is a ProviderException ("narrow the period").

Verified, and not

Not tested live: the API requires a key and none was created for this package (its user obtains one). Met live on 2026-10-15: every call without a key answers 401 "Unauthorized. A valid API key must be provided in the X-API-Key header." (locations, parameters); read on 2026-10-16: the OpenAPI schema (api.openaq.org/openapi.json), whose paths and fields the source follows, and the documentation's example payloads. The fixtures are: the documentation's example of location 2178 (Del Norte, Albuquerque, AirNow; its eight sensors) as the answer to locations; an hourly answer built after the schema's HourlyData around the value the documentation's latest example gives for that location's PM2.5 sensor 3920 (4.4 µg/m³ at 2024-09-25 22:00 UTC, taken here as the hour from 21:00); the real 401. What OpenAQ answers with a key - the distance field, the hours' periods, the paging past 1,000, a daily answer - is not verified.

On 2026-10-27 the example below ran without a key: OpenAQ's 401 again.

Responsibility

This package is an independent, unofficial client: it is not affiliated with, endorsed by or sponsored by OpenAQ. Its terms of use apply, "including source attribution": credit OpenAQ and the data's provider and owner under each location's licence, keep the key secret and within its rate, as the README's Terms of use says. Whoever runs the package holds the key and is responsible for complying with those terms and for any use made of what it returns: see the README's Responsibility section.

Propose a change docs/index.md