Code Examples
Complete code examples for interacting with the QAirbon STAC API in various programming languages.
Quick Start
Get up and running with the QAirbon API in just a few minutes. All examples assume the API is running at https://${API_HOST}.
Authorization: Bearer TOKEN header. See the Authentication guide for details.
Setup & Authentication
Setup
# Install the requests library
pip install requests
# Requires: curl and jq
# Install jq: https://jqlang.github.io/jq/download/
Basic Configuration
import ssl
import socket
import requests
from requests.adapters import HTTPAdapter
# API endpoints
AUTH_HOST = "keycloak.qairbon.com"
API_HOST = "${API_HOST}"
TOKEN_URL = f"https://{AUTH_HOST}/realms/QairbonAPIDev/protocol/openid-connect/token"
API_URL = f"https://{API_HOST}/api/v1"
def _fetch_server_cert(hostname, port=443):
"""Fetch server certificate for pinning (trust-on-first-use).
Needed because the API uses a Traefik default cert with no matching SAN."""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
with socket.create_connection((hostname, port)) as sock:
with ctx.wrap_socket(sock, server_hostname=hostname) as ssock:
return ssl.DER_cert_to_PEM_cert(ssock.getpeercert(binary_form=True))
class _PinnedCertAdapter(HTTPAdapter):
"""HTTP adapter that verifies against a specific pinned certificate."""
def __init__(self, cert_pem, **kwargs):
self._cert_pem = cert_pem
super().__init__(**kwargs)
def init_poolmanager(self, *args, **kwargs):
ctx = ssl.create_default_context(cadata=self._cert_pem)
ctx.check_hostname = False
kwargs["ssl_context"] = ctx
kwargs["assert_hostname"] = False
super().init_poolmanager(*args, **kwargs)
# Build a shared session with pinned certs — avoids SSLError on first connection
_AUTH_CERT = _fetch_server_cert(AUTH_HOST)
_API_CERT = _fetch_server_cert(API_HOST)
_session = requests.Session()
_session.mount(f"https://{AUTH_HOST}", _PinnedCertAdapter(_AUTH_CERT))
_session.mount(f"https://{API_HOST}", _PinnedCertAdapter(_API_CERT))
class QAirbonClient:
"""QAirbon API client with Keycloak OIDC authentication."""
def __init__(self, username, password):
self.username = username
self.password = password
self.access_token = None
self.refresh_token = None
self._get_token()
def _get_token(self):
response = _session.post(
TOKEN_URL,
data={
"grant_type": "password",
"client_id": "auth",
"username": self.username,
"password": self.password,
}
)
response.raise_for_status()
data = response.json()
self.access_token = data["access_token"]
self.refresh_token = data.get("refresh_token")
def _refresh(self):
response = _session.post(
TOKEN_URL,
data={
"grant_type": "refresh_token",
"client_id": "auth",
"refresh_token": self.refresh_token,
}
)
response.raise_for_status()
self.access_token = response.json()["access_token"]
@property
def headers(self):
return {"Authorization": f"Bearer {self.access_token}"}
def get(self, endpoint, params=None):
response = _session.get(
f"{API_URL}{endpoint}",
headers=self.headers,
params=params
)
if response.status_code == 401:
self._refresh()
response = _session.get(
f"{API_URL}{endpoint}",
headers=self.headers,
params=params
)
response.raise_for_status()
return response.json()
def post(self, endpoint, data):
response = _session.post(
f"{API_URL}{endpoint}",
headers=self.headers,
json=data
)
if response.status_code == 401:
self._refresh()
response = _session.post(
f"{API_URL}{endpoint}",
headers=self.headers,
json=data
)
response.raise_for_status()
return response.json()
# Initialize client with your Keycloak credentials
client = QAirbonClient("your@email.com", "YOUR_PASSWORD")
# Get access token from Keycloak OIDC (password grant — no client secret needed)
TOKEN=$(curl -s -X POST "https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&client_id=auth&username=your@email.com&password=YOUR_PASSWORD" \
| jq -r '.access_token')
echo "Token: ${TOKEN:0:20}..."
Refresh Token
# Token refresh is handled automatically by QAirbonClient on 401 responses
# To refresh manually:
client._refresh()
# When access token expires, use refresh token to get a new one
TOKEN=$(curl -s -X POST "https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token&client_id=auth&refresh_token=YOUR_REFRESH_TOKEN" \
| jq -r '.access_token')
Sites
Get STAC Catalog
# Get the root STAC catalog
catalog = client.get("/stac")
print(f"Catalog: {catalog['title']}")
print(f"Description: {catalog['description']}")
print(f"STAC Version: {catalog['stac_version']}")
# List conformance classes
for uri in catalog.get('conformsTo', []):
print(f" - {uri}")
curl -s -X GET "https://${API_HOST}/api/v1/stac" \
-H "Authorization: Bearer $TOKEN" | jq -r '.id'
List All Sites
# Get first page of sites
items = client.get("/collections/sites/items")
print(f"Total sites: {items['context']['matched']}")
print(f"Returned: {items['context']['returned']}")
for site in items['features']:
props = site['properties']
print(f"- {props['title']} ({props['qairbon:industry']}, {props['qairbon:country']})")
curl -s "https://${API_HOST}/api/v1/collections/sites/items?limit=10" \
-H "Authorization: Bearer $TOKEN" | jq '.context.matched, [.features[].properties.title]'
Get Site by ID
site_id = "1d55e91b-65e5-4073-b3ba-d44c394318cc"
site = client.get(f"/collections/sites/items/{site_id}")
props = site['properties']
bbox = site['bbox'] # [west, south, east, north] — geometry may be Point or Polygon
lon = (bbox[0] + bbox[2]) / 2
lat = (bbox[1] + bbox[3]) / 2
print(f"Site: {props['title']}")
print(f"Industry: {props['qairbon:industry']}")
print(f"Status: {props['site:status']}")
print(f"Location: {lat:.5f}, {lon:.5f}")
print(f"Country: {props['qairbon:country']}")
curl -s "https://${API_HOST}/api/v1/collections/sites/items/1d55e91b-65e5-4073-b3ba-d44c394318cc" \
-H "Authorization: Bearer $TOKEN" | jq '{name: .properties.title, industry: .properties["qairbon:industry"], country: .properties["qairbon:country"]}'
Pagination
def get_all_sites(client, limit=50):
"""Fetch all sites using cursor-based pagination (token = integer offset)."""
all_sites = []
token = None
while True:
params = {"limit": limit}
if token is not None:
params["token"] = token
result = client.get("/collections/sites/items", params=params)
all_sites.extend(result["features"])
next_link = next((l for l in result.get("links", []) if l["rel"] == "next"), None)
if not next_link:
break
token = next_link["href"].split("token=")[1].split("&")[0]
print(f"Fetched {len(all_sites)} / {result['context']['matched']} sites...")
return all_sites
# Get all sites
sites = get_all_sites(client)
print(f"Total sites fetched: {len(sites)}")
#!/bin/bash
# Fetch all sites using cursor-based pagination
LIMIT=50
TOKEN_CURSOR=""
OUTPUT_FILE="all_sites.json"
echo "[]" > $OUTPUT_FILE
while true; do
URL="https://${API_HOST}/api/v1/collections/sites/items?limit=$LIMIT"
[ -n "$TOKEN_CURSOR" ] && URL="$URL&token=$TOKEN_CURSOR"
RESPONSE=$(curl -s "$URL" -H "Authorization: Bearer $TOKEN")
# Append features
jq -s '.[0] + .[1]' $OUTPUT_FILE <(echo $RESPONSE | jq '.features') > tmp.json && mv tmp.json $OUTPUT_FILE
# Extract next token from "next" link href, stop if none
NEXT_HREF=$(echo $RESPONSE | jq -r '[.links[] | select(.rel == "next")] | first | .href // empty')
[ -z "$NEXT_HREF" ] && break
TOKEN_CURSOR=$(echo $NEXT_HREF | grep -o 'token=[^&]*' | cut -d= -f2)
echo "Fetched $(jq length $OUTPUT_FILE) sites..."
done
TOTAL=$(jq 'length' $OUTPUT_FILE)
echo "Total sites fetched: $TOTAL"
Spatial Search - Bounding Box
# Search for sites near ArcelorMittal Méditerranée, Fos-sur-Mer, France
search_request = {
"collections": ["sites"],
"bbox": [4.4, 43.0, 5.4, 43.9], # [west, south, east, north]
"limit": 100
}
results = client.post("/search", search_request)
print(f"Found {results['context']['returned']} sites in bounding box")
for site in results['features']:
print(f" - {site['properties']['title']}")
curl -X POST "https://${API_HOST}/api/v1/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"collections": ["sites"],
"bbox": [4.4, 43.0, 5.4, 43.9],
"limit": 50
}'
Spatial Search - Polygon Intersection
# Search with a custom polygon (e.g., ArcelorMittal Méditerranée, Fos-sur-Mer, France)
search_request = {
"intersects": {
"type": "Polygon",
"coordinates": [[
[4.4, 43.0],
[5.4, 43.0],
[5.4, 43.9],
[4.4, 43.9],
[4.4, 43.0] # Close the polygon
]]
},
"limit": 50
}
results = client.post("/search", search_request)
print(f"Sites in polygon: {results['context']['returned']}")
curl -X POST "https://${API_HOST}/api/v1/search" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"intersects": {
"type": "Polygon",
"coordinates": [[
[4.4, 43.0],
[5.4, 43.0],
[5.4, 43.9],
[4.4, 43.9],
[4.4, 43.0]
]]
},
"limit": 100
}'
Filter by Industry
# Filter by industry using the server-side query parameter (values: see Endpoints › Industries)
steel_sites = client.get("/collections/sites/items", params={"industry": "steel_bf", "limit": 100})
print(f"Steel BF plants: {steel_sites['context']['matched']}")
copper_sites = client.get("/collections/sites/items", params={"industry": "copper_smelter_primary", "limit": 100})
print(f"Primary copper smelters: {copper_sites['context']['matched']}")
# Filter by industry (server-side) — values: see Endpoints › Industries
curl -s "https://${API_HOST}/api/v1/collections/sites/items?industry=steel_bf&limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '.context.matched, [.features[].properties.title]'
# Primary copper smelters
curl -s "https://${API_HOST}/api/v1/collections/sites/items?industry=copper_smelter_primary&limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '.context.matched, [.features[].properties.title]'
Error Handling
from requests.exceptions import HTTPError
try:
# Attempt to get a site
site = client.get("/collections/sites/items/invalid-id")
except HTTPError as e:
if e.response.status_code == 400:
print(f"Bad Request: {e.response.json().get('detail')}")
elif e.response.status_code == 401:
print("Authentication failed - token may be expired")
elif e.response.status_code == 404:
print("Resource not found")
else:
print(f"API Error: {e}")
# Check HTTP status code
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
"https://${API_HOST}/api/v1/collections/sites/items/invalid-id" \
-H "Authorization: Bearer $TOKEN")
echo "Status: $HTTP_STATUS"
SAI — Site Activity Index
Fetch and analyze Site Activity Index time series for a site.
List SAI Items
# List SAI time series for a site
site_id = "1d55e91b-65e5-4073-b3ba-d44c394318cc"
sai_data = client.get(
f"/collections/sites/items/{site_id}/sai/items",
params={"limit": 50}
)
print(f"Total SAI records: {sai_data['context']['matched']}")
for item in sai_data["features"]:
props = item["properties"]
print(f"{props['datetime'][:10]}: SAI = {props['sai:value']:.3f}")
SITE_ID="1d55e91b-65e5-4073-b3ba-d44c394318cc"
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/sai/items?limit=50" \
-H "Authorization: Bearer $TOKEN" | \
jq '.context.matched, [.features[] | {date: .properties.datetime[:10], sai: .properties["sai:value"]}]'
Filter by Date Range
# Filter SAI records by date range
sai_data = client.get(
f"/collections/sites/items/{site_id}/sai/items",
params={"datetime": "2024-01-01/2024-12-31", "limit": 100}
)
print(f"SAI records in 2024: {sai_data['context']['matched']}")
for item in sai_data["features"]:
props = item["properties"]
print(f"{props['datetime'][:10]}: SAI = {props['sai:value']:.3f}")
# Open-ended ranges also work:
# "2024-01-01/.." → from date onwards
# "../2024-12-31" → up to date
# Filter by date range (YYYY-MM-DD/YYYY-MM-DD)
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/sai/items?datetime=2024-01-01/2024-12-31&limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '.context.matched, [.features[].properties | {date: .datetime[:10], sai: .["sai:value"]}]'
Paginate All SAI Records
def get_all_sai(client, site_id):
"""Fetch all SAI records for a site, following pagination links."""
items, token = [], None
while True:
params = {"limit": 100}
if token:
params["token"] = token
data = client.get(f"/collections/sites/items/{site_id}/sai/items", params=params)
items.extend(data["features"])
next_link = next((l for l in data["links"] if l["rel"] == "next"), None)
if not next_link:
break
token = next_link["href"].split("token=")[1]
return items
sai_items = get_all_sai(client, site_id)
print(f"Total SAI records: {len(sai_items)}")
SITE_ID="1d55e91b-65e5-4073-b3ba-d44c394318cc"
TOKEN_CURSOR=""
ALL_ITEMS="[]"
while true; do
URL="https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/sai/items?limit=100"
[ -n "$TOKEN_CURSOR" ] && URL="$URL&token=$TOKEN_CURSOR"
RESPONSE=$(curl -s "$URL" -H "Authorization: Bearer $TOKEN")
ALL_ITEMS=$(echo "$ALL_ITEMS $(echo $RESPONSE | jq '.features')" | jq -s '.[0] + .[1]')
NEXT_HREF=$(echo $RESPONSE | jq -r '[.links[] | select(.rel == "next")] | first | .href // empty')
[ -z "$NEXT_HREF" ] && break
TOKEN_CURSOR=$(echo $NEXT_HREF | grep -o 'token=[^&]*' | cut -d= -f2)
done
echo "Total SAI records: $(echo $ALL_ITEMS | jq 'length')"
Production — Site Estimated Production
Monthly production in kt estimated from the SAI and the site's capacity: one item per SAI month, then the
real-time estimate as the last item (id <site_id>_current). Same filters and pagination as SAI.
List Production Items
# Monthly production (kt) of a site, with the capacity running each month
site_id = "1d55e91b-65e5-4073-b3ba-d44c394318cc"
production = client.get(
f"/collections/sites/items/{site_id}/production/items",
params={"datetime": "2025-01-01/..", "limit": 100}
)
for item in production["features"]:
props = item["properties"]
label = "now" if item["id"].endswith("_current") else props["datetime"][:7]
print(f"{label}: {props['production:value']:.1f} kt / {props['production:active_capacity']:.1f} kt capacity")
SITE_ID="1d55e91b-65e5-4073-b3ba-d44c394318cc"
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/production/items?datetime=2025-01-01/..&limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '[.features[] | {id, month: .properties.datetime[:7], kt: .properties["production:value"], capacity_kt: .properties["production:active_capacity"]}]'
Plume — CO₂ Inversions
Fetch CO₂ plume inversion data and rasters. Requires CO₂ access — contact Qairbon to enable it for your account.
List Plume Events
# Requires CO2 access
site_id = "1d55e91b-65e5-4073-b3ba-d44c394318cc"
plume_data = client.get(
f"/collections/sites/items/{site_id}/plume/items",
params={"limit": 20}
)
for item in plume_data["features"]:
props = item["properties"]
print(f"{props['datetime'][:10]}: {props['plume:quantification']:.1f} t/h ± {props['plume:uncertainty']:.1f} t/h")
raster_href = item["assets"]["raster"]["href"]
print(f" Raster: {raster_href}")
SITE_ID="1d55e91b-65e5-4073-b3ba-d44c394318cc"
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/plume/items?limit=20" \
-H "Authorization: Bearer $TOKEN" | \
jq '[.features[] | {date: .properties.datetime[:10], quantification: .properties["plume:quantification"], uncertainty: .properties["plume:uncertainty"]}]'
Download Plume Raster
# Download the color-mapped RGBA GeoTIFF for the first plume event
first_item = plume_data["features"][0]
plume_id = first_item["id"]
raster_url = (
f"https://${API_HOST}/api/v1/collections/sites/items/{site_id}"
f"/plume/items/{plume_id}/assets/raster"
)
import requests
r = requests.get(raster_url, headers={"Authorization": f"Bearer {client.access_token}"})
with open(f"plume_{plume_id}.tif", "wb") as f:
f.write(r.content)
# Open with rasterio to inspect
import rasterio
with rasterio.open(f"plume_{plume_id}.tif") as src:
print(f"CRS: {src.crs}")
print(f"Bounds: {src.bounds}")
print(f"Size: {src.width} x {src.height}")
print(f"Bands: {src.count} (RGBA)")
SITE_ID="1d55e91b-65e5-4073-b3ba-d44c394318cc"
# Get first plume item ID
PLUME_ID=$(curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/plume/items?limit=1" \
-H "Authorization: Bearer $TOKEN" | jq -r '.features[0].id')
# Download raster GeoTIFF
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/plume/items/$PLUME_ID/assets/raster" \
-H "Authorization: Bearer $TOKEN" \
-o "plume_${PLUME_ID}.tif"
echo "Downloaded: plume_${PLUME_ID}.tif"
QAI — Qairbon Activity Index
Fetch and analyze Qairbon Activity Index time series for a panel.
List Panels and QAI
# 1. List steel_bf panels
panels = client.get("/collections/panel/items", params={"industry": "steel_bf"})
for panel in panels["features"]:
print(f"Panel: {panel['properties']['title']} ({panel['id']})")
# 2. Get QAI for the first steel_bf panel
panel_id = panels["features"][0]["id"]
qai_data = client.get(
f"/collections/panel/items/{panel_id}/qai/items",
params={"limit": 100}
)
print(f"QAI records: {qai_data['context']['matched']}")
for item in qai_data["features"]:
props = item["properties"]
print(f"{props['datetime'][:7]}: QAI = {props['qai:value']:.3f}")
# 1. Get first steel_bf panel ID
PANEL_ID=$(curl -s "https://${API_HOST}/api/v1/collections/panel/items?industry=steel_bf" \
-H "Authorization: Bearer $TOKEN" | jq -r '.features[0].id')
echo "Panel: $PANEL_ID"
# 2. Get QAI records for that panel
curl -s "https://${API_HOST}/api/v1/collections/panel/items/$PANEL_ID/qai/items?limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '.context.matched, [.features[] | {month: .properties.datetime[:7], qai: .properties["qai:value"]}]'
Production — Panel Estimated Production
Monthly production in kt of a panel's sites, the real-time estimate last (id <panel_id>_current).
Each item's assets holds one entry per site of the panel: production:weight (its
capacity share, 0–1) for every site, and production:value (its kt) only for the sites you can
access — the visible sites' values plus the rest add up to the panel's production:value.
Panel Production and Per-Site Breakdown
# 1. Monthly production of a panel (panel_id from the QAI example above)
production = client.get(
f"/collections/panel/items/{panel_id}/production/items",
params={"limit": 100}
)
for item in production["features"]:
props = item["properties"]
label = "now" if item["id"].endswith("_current") else props["datetime"][:7]
print(f"{label}: {props['production:value']:.1f} kt over {props['production:site_count']} sites")
# 2. Who produced the last complete month: one asset per site
last_month = [f for f in production["features"] if not f["id"].endswith("_current")][-1]
for site_id, asset in sorted(last_month["assets"].items(), key=lambda kv: -kv[1]["production:weight"]):
kt = asset.get("production:value") # absent for a site you cannot access
shown = f"{kt:.1f} kt" if kt is not None else "—"
print(f" {asset['title']}: weight {asset['production:weight']:.1%}, {shown}")
# 1. Monthly production of a panel (PANEL_ID from the QAI example above)
curl -s "https://${API_HOST}/api/v1/collections/panel/items/$PANEL_ID/production/items?limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '[.features[] | {id, month: .properties.datetime[:7], kt: .properties["production:value"]}]'
# 2. Per-site breakdown of the last complete month
curl -s "https://${API_HOST}/api/v1/collections/panel/items/$PANEL_ID/production/items?limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '[.features[] | select(.id | endswith("_current") | not)] | last | .assets
| to_entries | map({site: .value.title, weight: .value["production:weight"], kt: .value["production:value"]})'
Alerts — Restart / Shutdown Events
List recent alerts, then drill into one site's blast furnaces.
Recent Alerts and Per-Site History
# 1. Confirmed alerts since July 2026
alerts = client.get("/collections/alerts/items", params={"datetime": "2026-07-01/..", "limit": 100})
for a in alerts["features"]:
p = a["properties"]
if p["alert:level"] == "confirmed":
print(p["datetime"][:10], p["title"], p["alert:event"])
# 2. Every alert on one site's blast furnaces
site_id = alerts["features"][0]["properties"]["qairbon:site_id"]
history = client.get(f"/collections/sites/items/{site_id}/alerts/items")
print(f"{history['context']['matched']} alerts on site {site_id}")
# 1. Confirmed alerts since July 2026
curl -s "https://${API_HOST}/api/v1/collections/alerts/items?datetime=2026-07-01/..&limit=100" \
-H "Authorization: Bearer $TOKEN" | \
jq '[.features[] | select(.properties["alert:level"] == "confirmed") | {date: .properties.datetime[:10], site: .properties.title, event: .properties["alert:event"]}]'
# 2. Every alert on one site's blast furnaces
curl -s "https://${API_HOST}/api/v1/collections/sites/items/$SITE_ID/alerts/items" \
-H "Authorization: Bearer $TOKEN" | jq '.context.matched'
News — Press Articles
Follow what the trade press writes about the sites you monitor.
This Week's Articles by Site
from collections import defaultdict
news = client.get("/collections/news/items", params={"datetime": "2026-09-01/..", "limit": 200})
by_site = defaultdict(list)
for item in news["features"]:
p = item["properties"]
by_site[p["news:site_name"]].append(p)
for site, articles in by_site.items():
print(f"{site} ({len(articles)} articles)")
for p in articles:
print(f" {p['datetime'][:10]} [{p['news:source']}] {p['title']}")
print(f" {p['description']} — {p['news:url']}")
curl -s "https://${API_HOST}/api/v1/collections/news/items?datetime=2026-09-01/..&limit=200" \
-H "Authorization: Bearer $TOKEN" | \
jq '.features | group_by(.properties["news:site_name"]) | map({site: .[0].properties["news:site_name"], articles: map(.properties.title)})'
Excel Export — SAI, QAI and Production
Download the monthly SAI of several sites, or the monthly QAI of several panels, with the matching
production, as one Excel workbook (.xlsx). POST /api/v1/export/sites takes
site_ids, POST /api/v1/export/panels takes panel_ids; both accept
optional start_date / end_date (ISO 8601). Without them the API takes the year
before today, which starts mid-month, so the first month comes out empty — pass a month-start
start_date.
The workbook has two sheets: SAI (or QAI) and Production (kt). Each
has one row per completed month and one column group per site or panel: the value, Unc. Min,
Unc. Max. The current month never gets a monthly row, as its value is not final until it ends;
instead each sheet ends with a Current row holding the real-time value, when it was measured
within the range. Every month of the range gets a row, empty when there is no value. Ids you cannot access
are skipped; when none is left — or the account has no activity access — the API answers
404.
SAI of Several Sites
# Binary response: call the session directly — client.post() parses JSON
site_ids = [s["id"] for s in client.get("/collections/sites/items", params={"industry": "steel_bf", "limit": 10})["features"]]
response = _session.post(
f"{API_URL}/export/sites",
json={"site_ids": site_ids, "start_date": "2025-01-01T00:00:00Z", "end_date": "2025-12-31T00:00:00Z"},
headers=client.headers,
)
response.raise_for_status()
with open("sites.xlsx", "wb") as f:
f.write(response.content)
# Read it back with pandas: two header rows (site, then metric)
import pandas as pd
sai = pd.read_excel("sites.xlsx", sheet_name="SAI", header=[0, 1], index_col=0)
production = pd.read_excel("sites.xlsx", sheet_name="Production", header=[0, 1], index_col=0)
curl -s -X POST "https://${API_HOST}/api/v1/export/sites" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"site_ids": ["1d55e91b-65e5-4073-b3ba-d44c394318cc"], "start_date": "2025-01-01T00:00:00Z", "end_date": "2025-12-31T00:00:00Z"}' \
-o sites.xlsx
QAI of Several Panels
# Same call with panel ids, over complete months
panel_ids = [p["id"] for p in client.get("/collections/panel/items", params={"industry": "steel_bf"})["features"]]
response = _session.post(f"{API_URL}/export/panels", json={"panel_ids": panel_ids, "start_date": "2025-10-01T00:00:00Z", "end_date": "2026-09-30T00:00:00Z"}, headers=client.headers)
response.raise_for_status()
with open("panels.xlsx", "wb") as f:
f.write(response.content)
curl -s -X POST "https://${API_HOST}/api/v1/export/panels" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"panel_ids\": [\"$PANEL_ID\"], \"start_date\": \"2025-10-01T00:00:00Z\", \"end_date\": \"2026-09-30T00:00:00Z\"}" \
-o panels.xlsx
Integration with GIS Tools
The API returns standard GeoJSON, making it compatible with popular GIS tools and libraries.
QGIS
# Get token from Keycloak
TOKEN=$(curl -s -X POST "https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=password&client_id=auth&username=your@email.com&password=YOUR_PASSWORD" | jq -r '.access_token')
# Save sites to GeoJSON file for QGIS
curl -s "https://${API_HOST}/api/v1/collections/sites/items?limit=1000" \
-H "Authorization: Bearer $TOKEN" \
-o sites.geojson
# Open in QGIS: Layer > Add Layer > Add Vector Layer > sites.geojson
Python with GeoPandas
import geopandas as gpd
import requests
# Get token from Keycloak OIDC
token_response = requests.post(
"https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "auth",
"username": "your@email.com",
"password": "YOUR_PASSWORD",
}
)
token = token_response.json()["access_token"]
# Fetch sites with JWT auth
response = requests.get(
"https://${API_HOST}/api/v1/collections/sites/items?limit=1000",
headers={"Authorization": f"Bearer {token}"}
)
for item in sai_data["features"]:
props = item["properties"]
print(f"{props['datetime'][:10]}: SAI = {props['sai:value']:.3f}")
Plot SAI Time Series
import matplotlib.pyplot as plt
from datetime import datetime
# Fetch all pages
def get_all_sai(client, site_id):
items, token = [], None
while True:
params = {"limit": 100}
if token:
params["token"] = token
data = client.get(f"/collections/sites/items/{site_id}/sai/items", params=params)
items.extend(data["features"])
next_link = next((l for l in data["links"] if l["rel"] == "next"), None)
if not next_link:
break
token = next_link["href"].split("token=")[1]
return items
site_id = "1d55e91b-65e5-4073-b3ba-d44c394318cc"
sai_items = get_all_sai(client, site_id)
dates = [datetime.fromisoformat(i["properties"]["datetime"].replace("Z", "")) for i in sai_items]
values = [i["properties"]["sai:value"] for i in sai_items]
plt.figure(figsize=(12, 4))
plt.plot(dates, values, marker="o", color="#F5C563")
plt.title("Site Activity Index")
plt.ylabel("SAI")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig("sai_timeseries.png")
OpenLayers Integration
Display sites and plume rasters on an interactive map using OpenLayers with the ol-stac library.
Sites Map
Load STAC site items as an OpenLayers vector layer.
// Initialize OpenLayers map with STAC sites
const BASE_URL = 'https://${API_HOST}';
const API_URL = `${BASE_URL}/api/v1`;
// Get token from Keycloak OIDC
const TOKEN_URL = 'https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token';
let accessToken = null;
async function getToken(username, password) {
const params = new URLSearchParams({
grant_type: 'password',
client_id: 'auth',
username: username,
password: password,
});
const response = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
});
const data = await res.json();
accessToken = data.access_token;
}
const COLORS = { steel_bf: '#F5C563', copper_smelter_primary: '#B87333' };
async function initMap() {
await login('your@email.com', 'your-password');
const response = await fetch(`${API_URL}/collections/sites/items?limit=1000`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const data = await response.json();
const features = new ol.format.GeoJSON().readFeatures(data, { featureProjection: 'EPSG:3857' });
const vectorSource = new ol.source.Vector({ features });
const map = new ol.Map({
target: 'map',
layers: [
new ol.layer.Tile({
source: new ol.source.OSM()
}),
vectorLayer
],
view: new ol.View({
center: ol.proj.fromLonLat([0, 30]),
zoom: 2
})
});
// Fit view to data extent
if (features.length > 0) {
map.getView().fit(vectorSource.getExtent(), {
padding: [50, 50, 50, 50],
maxZoom: 12
});
}
return map;
}
// Get token from Keycloak, then initialize map
getToken('your@email.com', 'YOUR_PASSWORD').then(() => initMap());
JavaScript - Interactive Popup
// Add popup overlay for site information
const popup = new ol.Overlay({
element: document.getElementById('popup'),
positioning: 'bottom-center',
stopEvent: false,
offset: [0, -10]
});
map.addOverlay(popup);
// Handle click events on features
map.on('click', function(evt) {
const feature = map.forEachFeatureAtPixel(evt.pixel, function(f) {
return f;
});
if (feature) {
const props = feature.getProperties();
const coords = feature.getGeometry().getCoordinates();
const content = `
<div class="popup-content">
<h4>${props['title']}</h4>
<p><strong>Industry:</strong> ${props['qairbon:industry']}</p>
<p><strong>Status:</strong> ${props['site:status']}</p>
<p><strong>Country:</strong> ${props['qairbon:country']}</p>
<p><strong>ID:</strong> <code>${feature.getId()}</code></p>
</div>
`;
document.getElementById('popup').innerHTML = content;
popup.setPosition(coords);
} else {
popup.setPosition(undefined);
}
});
// Change cursor on hover
map.on('pointermove', function(evt) {
const hit = map.hasFeatureAtPixel(evt.pixel);
map.getTargetElement().style.cursor = hit ? 'pointer' : '';
});
OpenLayers with STAC Search
Perform spatial searches using the map extent.
// Search sites within current map view
async function searchInView(map) {
const extent = map.getView().calculateExtent(map.getSize());
// Transform from Web Mercator to WGS84
const [minX, minY, maxX, maxY] = ol.proj.transformExtent(
extent, 'EPSG:3857', 'EPSG:4326'
);
const searchRequest = {
collections: ['sites'],
bbox: [minX, minY, maxX, maxY],
limit: 100
};
const response = await fetch(`${API_URL}/search`, {
method: 'POST',
headers: {
...getHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify(searchRequest)
});
const data = await response.json();
console.log(`Found ${data.context?.returned} sites in view`);
return data;
}
// Draw polygon and search
function addDrawInteraction(map, vectorSource) {
const draw = new ol.interaction.Draw({
source: vectorSource,
type: 'Polygon'
});
draw.on('drawend', async function(evt) {
const geometry = evt.feature.getGeometry();
const coords = geometry.clone()
.transform('EPSG:3857', 'EPSG:4326')
.getCoordinates();
const searchRequest = {
intersects: {
type: 'Polygon',
coordinates: coords
},
limit: 100
};
const response = await fetch(`${API_URL}/search`, {
method: 'POST',
headers: { ...getHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(searchRequest)
});
const results = await response.json();
console.log(`Found ${results.context?.returned} sites in polygon`);
});
map.addInteraction(draw);
return draw;
}
Python with pystac-client
Use the official STAC client library for Python.
# Install: pip install pystac-client requests
import requests
from pystac_client import Client
# Get token from Keycloak OIDC
token_response = requests.post(
"https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "auth",
"username": "your@email.com",
"password": "YOUR_PASSWORD",
}
)
access_token = token_response.json()["access_token"]
# Custom request modifier for JWT authentication
def add_jwt_auth(request):
request.headers["Authorization"] = f"Bearer {access_token}"
return request
# Open STAC catalog with JWT auth
catalog = Client.open(
"https://${API_HOST}/api/v1/stac",
request_modifier=add_jwt_auth
)
# Browse catalog
print(f"Catalog: {catalog.title}")
print(f"Description: {catalog.description}")
# List collections
for collection in catalog.get_collections():
print(f"Collection: {collection.id} - {collection.title}")
# Search items
search = catalog.search(
collections=["sites"],
bbox=[4.4, 43.0, 5.4, 43.9],
max_items=50
)
# Iterate through results
for item in search.items():
print(f"Site: {item.properties['title']}")
print(f" Industry: {item.properties['qairbon:industry']}")
bbox = item.bbox # [west, south, east, north]
print(f" Location: {(bbox[1]+bbox[3])/2:.5f}, {(bbox[0]+bbox[2])/2:.5f}")
Complete HTML Example
A full working example you can save and open in a browser.
<!DOCTYPE html>
<html>
<head>
<title>QAirbon STAC Viewer</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/ol@v8.2.0/ol.css">
<style>
#map { width: 100%; height: 600px; }
.popup-content {
background: white;
padding: 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
max-width: 300px;
}
.popup-content h4 { margin: 0 0 10px; color: #2D2D2D; }
.popup-content p { margin: 5px 0; font-size: 14px; }
.legend { position: absolute; bottom: 20px; left: 20px; background: white;
padding: 15px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.legend-item { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
.legend-color { width: 16px; height: 16px; border-radius: 50%; }
</style>
</head>
<body>
<div id="map"></div>
<div id="popup"></div>
<div class="legend">
<strong>Industries</strong>
<div class="legend-item"><span class="legend-color" style="background:#F5C563"></span> Steel</div>
<div class="legend-item"><span class="legend-color" style="background:#B87333"></span> Copper</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/ol@v8.2.0/dist/ol.js"></script>
<script>
const BASE_URL = 'https://${API_HOST}';
const API_URL = `${BASE_URL}/api/v1`;
let accessToken = null;
const COLORS = {
steel_bf: '#F5C563', copper_smelter_primary: '#B87333'
};
// Get token from Keycloak OIDC
async function getToken(username, password) {
const params = new URLSearchParams({
grant_type: 'password',
client_id: 'auth',
username: username,
password: password,
});
const res = await fetch(
'https://keycloak.qairbon.com/realms/QairbonAPIDev/protocol/openid-connect/token',
{
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params
}
);
const data = await res.json();
accessToken = data.access_token;
}
async function init() {
// Get token from Keycloak (replace with your credentials)
await getToken('your@email.com', 'YOUR_PASSWORD');
const response = await fetch(`${API_URL}/collections/sites/items?limit=1000`, {
headers: { 'Authorization': `Bearer ${accessToken}` }
});
const data = await response.json();
const features = new ol.format.GeoJSON().readFeatures(data, {
featureProjection: 'EPSG:3857'
});
const vectorSource = new ol.source.Vector({ features });
const vectorLayer = new ol.layer.Vector({
source: vectorSource,
style: (f) => new ol.style.Style({
image: new ol.style.Circle({
radius: 8,
fill: new ol.style.Fill({ color: COLORS[f.get('qairbon:industry')] || '#2D2D2D' }),
stroke: new ol.style.Stroke({ color: '#fff', width: 2 })
})
})
})
],
view: new ol.View({ center: [0, 0], zoom: 2 })
});
if (features.length) map.getView().fit(vectorSource.getExtent(), { padding: [50,50,50,50] });
return map;
}
initMap();
Plume Raster Overlay with ol-stac
Use ol-stac to render a plume STAC item's raster asset directly on the map. The raster is a 4-band RGBA GeoTIFF served in EPSG:4326 with a pre-applied concentration color ramp.
// npm install ol ol-stac stac-js
import STAC from 'ol-stac';
import { SourceType } from 'ol-stac';
import Map from 'ol/Map';
import View from 'ol/View';
import TileLayer from 'ol/layer/Tile';
import OSM from 'ol/source/OSM';
import { fromLonLat } from 'ol/proj';
const ACCESS_TOKEN = 'YOUR_JWT_TOKEN';
const headers = { 'Authorization': `Bearer ${ACCESS_TOKEN}` };
// httpRequestFn injects auth headers into all STAC JSON fetches
const httpRequestFn = async (url, responseType = 'json') => {
const res = await fetch(url, { headers });
if (!res.ok) throw new Error(`Unexpected response from ${url}: ${res.status}`);
if (responseType === 'json') return res.json();
if (responseType === 'text') return res.text();
return null;
};
// getSourceOptions injects auth headers into GeoTIFF tile requests
const sourceOptions = (type, options) => {
if (type === SourceType.GeoTIFF) {
return { ...options, interpolate: false, normalize: false, sourceOptions: { headers } };
}
return options;
};
const site_id = '1d55e91b-65e5-4073-b3ba-d44c394318cc';
// Fetch the plume ItemCollection URL for a site
const plumeUrl = `https://${API_HOST}/api/v1/collections/sites/items/${site_id}/plume/items?limit=1`;
// Create an ol-stac layer from the plume FeatureCollection
const plumeLayer = new STAC({
url: plumeUrl,
httpRequestFn,
displayPreview: false,
displayGeoTiffByDefault: true,
displayWebMapLink: false,
getSourceOptions: sourceOptions,
childrenOptions: { getSourceOptions: sourceOptions },
});
const map = new Map({
target: 'map',
layers: [
new TileLayer({ source: new OSM() }),
plumeLayer
],
view: new View({ center: fromLonLat([127.75, 34.92]), zoom: 12 })
});
// Zoom to raster extent once loaded
plumeLayer.on('change:data', () => {
const extent = plumeLayer.getExtent();
if (extent) map.getView().fit(extent, { padding: [40, 40, 40, 40] });
});