API Reference
All endpoints return JSON. Base URL: /api/
Authentication (checked in order): 1. API key (X-Api-Key header) — grants admin access. 2. Cognito JWT (Authorization: Bearer <id_token>) — role determined by group membership. 3. No auth when neither is configured (local dev default).
admin group) — everything including user management. Editor (editor group) — data mutations, exports, view refresh. Read-only (no group) — read access with caps: search limited to 200 results, history limited to 90 days, no exports.
Price data
Returns: { "status": "ok" }
{
"by_fuel_type": [
{ "fuel_type": "E10", "fuel_name": "Unleaded (E10)",
"avg_price": 149.3, "min_price": 135.9, "max_price": 199.9,
"station_count": 7277, "outliers_excluded": 12 }
],
"by_country": [
{ "country_name": "England", "station_count": 5854 },
{ "country_name": "Scotland", "station_count": 742 }
],
"total_stations": 7466,
"total_prices": 24551,
"last_scrape": "2026-03-25T14:00:00Z"
}
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
Returns: [ { region, avg_price, min_price, max_price, station_count } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
limit | int | 20 | Max brands (1–100) |
Returns: [ { brand_name, forecourt_type, avg_price, min_price, max_price, station_count } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
Returns: [ { forecourt_type, avg_price, min_price, max_price, station_count } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
limit | int | 30 | Max districts (1–500) |
Returns: [ { admin_district, avg_price, min_price, max_price, station_count } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
limit | int | 30 | Max constituencies (1–650) |
Returns: [ { parliamentary_constituency, avg_price, min_price, max_price, station_count } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
Returns: [ { unified_label, avg_price, min_price, max_price, station_count, rural_urban_values } ]
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
days | int | 30 | Days back (1–365; readonly capped at 90) |
start_date | string | — | Start date (YYYY-MM-DD) |
end_date | string | — | End date (YYYY-MM-DD) |
granularity | string | hourly | hourly or daily. Defaults to hourly |
Returns: { granularity, station: { trading_name, brand_name, city, postcode }, data: [ { bucket, avg_price } ] }
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
days | int | 30 | Days back (1–365; readonly capped at 90) |
start_date | string | — | Start date (YYYY-MM-DD) |
end_date | string | — | End date (YYYY-MM-DD) |
granularity | string | auto | hourly or daily. Auto: hourly for <30 days, daily for ≥30 days |
region | string | — | Region filter (comma-separated) |
country | string | — | Country filter (comma-separated) |
rural_urban | string | — | Rural/urban classification (comma-separated) |
node_ids | string | — | Comma-separated station node IDs |
brand | string | — | Brand name substring |
category | string | — | Forecourt type (comma-separated) |
postcode | string | — | Postcode prefix |
city | string | — | City substring |
district | string | — | Local authority district |
constituency | string | — | Parliamentary constituency |
supermarket_only | bool | false | Only supermarket stations |
motorway_only | bool | false | Only motorway stations |
exclude_outliers | bool | false | Exclude statistical outliers |
Returns: { granularity: "hourly"|"daily", data: [ { bucket, avg_price, stations } ] }
/api/prices/history.| Parameter | Type | Default | Description |
|---|---|---|---|
Same filters as /api/prices/history above | |||
format | string | csv | csv or json |
Returns: Streaming file download with full station & postcode enrichment for every historical observation.
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | E10 | Fuel type code |
region | string | — | Region filter |
brand | string | — | Brand name substring |
category | string | — | Forecourt type filter |
exclude_outliers | bool | false | Exclude statistical outliers |
Returns: array with node_id, trading_name, brand_name, city, postcode, price, fuel_name, forecourt_type, admin_district, rural_urban, parliamentary_constituency, latitude, longitude, is_motorway_service_station, is_supermarket_service_station
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | — | Fuel type code. Omit to search across all fuel types |
postcode | string | — | Postcode prefix (e.g. SW1) |
station | string | — | Trading name substring |
brand | string | — | Brand substring |
city | string | — | City substring |
min_price | float | — | Min price (pence) |
max_price | float | — | Max price (pence) |
category | string | — | Forecourt type filter |
district | string | — | Local authority district |
constituency | string | — | Parliamentary constituency |
rural_urban | string | — | Rural/urban classification |
region | string | — | Region filter |
country | string | — | Country filter |
supermarket_only | bool | false | Only supermarket stations |
motorway_only | bool | false | Only motorway stations |
exclude_outliers | bool | false | Exclude statistical outliers |
sort | string | price | price, brand, city, postcode, district |
limit | int | 50 | Results per page (min 1; readonly capped at 200) |
offset | int | 0 | Pagination offset |
Returns: { results: [...], total, limit, offset }
/api/prices/search (except sort, limit, offset).| Parameter | Type | Default | Description |
|---|---|---|---|
Same filters as /api/prices/search above | |||
format | string | csv | csv or json |
Returns: Streaming file download with full station & postcode enrichment for every historical observation.
| Body field | Type | Required | Description |
|---|---|---|---|
node_ids | array[string] | Yes | Ordered list of station node IDs |
Access: any authenticated role. Caps: readonly max 200 IDs; editor/admin max 5000 IDs.
{
"node_ids": ["node-a", "node-b", "node-c"]
}
Returns: { results: [...], requested, found, missing }. Result order matches the request order. Missing IDs are returned with found: false and null location fields. Each result includes postcode (corrected if overridden) and original_postcode (from source data).
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Max records (1–500) |
Returns: [ { id, node_id, trading_name, city, brand_name, postcode, fuel_type, price, prev_price, anomaly_flags, observed_at, prev_observed_at } ]
prev_price reflects any correction applied to the previous record.
Flags: price_below_floor, price_above_ceiling, likely_decimal_error, large_price_jump
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | — | Optional fuel type filter |
limit | int | 100 | Max records (1–500) |
Returns: { bounds: { fuel_type: { q1, q3, iqr, lower_fence, upper_fence, total_stations } }, outliers: [...], total }
Each outlier has exclusion_reason: "anomaly_flagged" or "iqr_outlier" (Tukey IQR fence applied to the current price snapshot), plus original_price and corrected_price when a correction exists.
| Parameter | Type | Default | Description |
|---|---|---|---|
fuel_type | string | — | Optional fuel type filter |
limit | int | 500 | Max records (1–5000) |
Returns: { station: { trading_name, brand_name, city, postcode }, records: [...] }
Each record: { fuel_price_id, fuel_type, fuel_name, original_price, corrected_price, effective_price, anomaly_flags, effective_flags, observed_at, correction_reason, corrected_by, corrected_at, prev_effective_price }
effective_flags: re-evaluated anomaly flags based on the effective (corrected or original) price.
Reference
Returns: [ { fuel_type_code, fuel_name, fuel_category } ]
Returns: [ "London", "North West", "Scotland", ... ]
Returns: [ "Birmingham", "Leeds", "Manchester", ... ]
Returns: [ "Aldershot", "Aldridge-Brownhills", ... ]
Admin / lookup tables
Editor endpoints (mutations, exports, view refresh) require admin or editor group membership. Admin endpoints (user management) require admin group only.
POST /api/admin/refresh-view to rebuild the materialised view.Scrape history
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Max runs (1–500) |
offset | int | 0 | Pagination offset |
Returns: { rows: [...], total, limit, offset }
Each row: { id, started_at, finished_at, run_type, status, batches_fetched, stations_count, price_records_count, s3_key, error_message, duration_secs }
Brand aliases
Map raw API brand strings to canonical names (e.g. "TESCO PFS" → "Tesco").
raw_brand_name.{ "raw_brand_name": "TESCO PFS", "canonical_brand": "Tesco" }
Brand categories
Map canonical brands to forecourt types for category-level price comparison.
canonical_brand.{ "canonical_brand": "Tesco", "forecourt_type": "Supermarket" }
Allowed types: Supermarket, Major Oil, Motorway Operator, Fuel Group, Convenience, Independent
Station overrides
Per-station brand overrides for edge cases (takes priority over aliases).
node_id.{ "node_id": "abc123...", "canonical_brand": "Shell", "notes": "Branded Shell but API says independent" }
{ "canonical_brand": "Tesco", "node_ids": ["abc123", "def456", "ghi789"], "notes": "Bulk reclassification" }
Returns: { "saved": 3, "canonical_brand": "Tesco" }
Normalisation report
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 100 | Max rows (1–1000) |
type | string | — | aliased, overridden, or unmapped |
brand | string | — | Brand substring filter |
Returns: [ { raw_brand, alias_resolved, override_resolved, final_brand, forecourt_type, resolution_method, station_count } ]
Postcode issues
Returns: [ { node_id, trading_name, brand_name, postcode, api_latitude, api_longitude, city, county, coords_outside_uk, fixed_latitude, fixed_longitude, corrected_postcode, override_notes } ]
{ "latitude": 51.5, "longitude": -0.1 }
Returns: { postcode, latitude, longitude }
looked_up_at on all retried postcodes. Requires editor or admin.{}
Returns: { retried, resolved, still_failed }
Returns: { failed_count, last_checked_at }
Postcode overrides
Per-station postcode corrections for stations with mistyped or expired postcodes. The corrected postcode is used for geographic enrichment (region, constituency, district, etc.) while the original is preserved. On save, the corrected postcode is looked up via postcodes.io for full enrichment. Requires editor or admin role for mutations.
Returns: [ { node_id, trading_name, brand_name, original_postcode, corrected_postcode, notes, created_at, lookup_succeeded } ]
node_id. Triggers a postcodes.io lookup for the corrected postcode.{ "node_id": "abc123...", "corrected_postcode": "SW1A 1AA", "notes": "Typo in source data" }
Returns: { node_id, original_postcode, corrected_postcode, notes, lookup_status }
lookup_status: "enriched" (postcode recognised, full enrichment stored), "not_recognised" (postcodes.io didn't recognise it), or "lookup_failed" (network error, override still saved).
User management (Cognito)
Returns: [ { username, email, status, enabled, groups, created } ]
{ "email": "user@example.com", "role": "editor" }
role accepts "admin", "editor", or "readonly" (default: no group = readonly).
Refresh view
current_prices materialised view. Call after changing any lookup table.{ "status": "ok", "message": "current_prices view refreshed" }
Price corrections
Manual overrides for misreported prices. Original data is never modified — corrections are stored separately and applied in the materialised view. Requires editor or admin role.
| Parameter | Type | Default | Description |
|---|---|---|---|
limit | int | 50 | Max records (1–1000) |
offset | int | 0 | Pagination offset |
Returns: { rows: [...], total, limit, offset }
Each row: { corrected_at, original_price, corrected_price, reason, corrected_by, trading_name, city, fuel_type, fuel_name, observed_at }
{ "fuel_price_id": 12345, "corrected_price": 139.9 }
{ "corrections": [ { "fuel_price_id": 12345, "corrected_price": 139.9 }, ... ] }
Returns: { saved: 3, corrections: [ { id, fuel_price_id, corrected_price } ] }