UK fuel price tracker

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

Sections

Price data Reference Admin / lookups

Price data

GET /health
Basic health check. No authentication required.

Returns: { "status": "ok" }

GET /api/summary
Dashboard headline numbers: average/min/max prices per fuel type, station count, country breakdown, last scrape time.
{
  "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"
}
GET /api/prices/by-region
Average price by ONS region.
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code

Returns: [ { region, avg_price, min_price, max_price, station_count } ]

GET /api/prices/by-brand
Average price by canonical brand name (minimum 3 stations).
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
limitint20Max brands (1–100)

Returns: [ { brand_name, forecourt_type, avg_price, min_price, max_price, station_count } ]

GET /api/prices/by-category
Average price by forecourt category (Supermarket, Major Oil, Motorway Operator, etc.).
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code

Returns: [ { forecourt_type, avg_price, min_price, max_price, station_count } ]

GET /api/prices/by-district
Average price by local authority district (minimum 3 stations).
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
limitint30Max districts (1–500)

Returns: [ { admin_district, avg_price, min_price, max_price, station_count } ]

GET /api/prices/by-constituency
Average price by parliamentary constituency (minimum 2 stations).
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
limitint30Max constituencies (1–650)

Returns: [ { parliamentary_constituency, avg_price, min_price, max_price, station_count } ]

GET /api/prices/by-rural-urban
Average price by rural/urban classification. England/Wales ONS RUC and Scottish Government classifications unified into common labels.
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code

Returns: [ { unified_label, avg_price, min_price, max_price, station_count, rural_urban_values } ]

GET /api/prices/station/{node_id}/history
Price history for a single station. Click a station name in Search results to view its trend.
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
daysint30Days back (1–365; readonly capped at 90)
start_datestringStart date (YYYY-MM-DD)
end_datestringEnd date (YYYY-MM-DD)
granularitystringhourlyhourly or daily. Defaults to hourly

Returns: { granularity, station: { trading_name, brand_name, city, postcode }, data: [ { bucket, avg_price } ] }

GET /api/prices/history
Average price over time. Uses hourly granularity for <30 days, daily for ≥30 days. Daily queries use the pre-aggregated daily_prices table. Supports location, brand, and search-style filters for aggregate trends.
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
daysint30Days back (1–365; readonly capped at 90)
start_datestringStart date (YYYY-MM-DD)
end_datestringEnd date (YYYY-MM-DD)
granularitystringautohourly or daily. Auto: hourly for <30 days, daily for ≥30 days
regionstringRegion filter (comma-separated)
countrystringCountry filter (comma-separated)
rural_urbanstringRural/urban classification (comma-separated)
node_idsstringComma-separated station node IDs
brandstringBrand name substring
categorystringForecourt type (comma-separated)
postcodestringPostcode prefix
citystringCity substring
districtstringLocal authority district
constituencystringParliamentary constituency
supermarket_onlyboolfalseOnly supermarket stations
motorway_onlyboolfalseOnly motorway stations
exclude_outliersboolfalseExclude statistical outliers

Returns: { granularity: "hourly"|"daily", data: [ { bucket, avg_price, stations } ] }

GET /api/prices/history/export
Export raw individual price records matching the trend filters as a streaming download. Requires editor or admin role. Accepts the same filter parameters as /api/prices/history.
ParameterTypeDefaultDescription
Same filters as /api/prices/history above
formatstringcsvcsv or json

Returns: Streaming file download with full station & postcode enrichment for every historical observation.

GET /api/prices/map
Current prices with coordinates for map display.
ParameterTypeDefaultDescription
fuel_typestringE10Fuel type code
regionstringRegion filter
brandstringBrand name substring
categorystringForecourt type filter
exclude_outliersboolfalseExclude 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

GET /api/prices/search
Flexible search/filter with pagination.
ParameterTypeDefaultDescription
fuel_typestringFuel type code. Omit to search across all fuel types
postcodestringPostcode prefix (e.g. SW1)
stationstringTrading name substring
brandstringBrand substring
citystringCity substring
min_pricefloatMin price (pence)
max_pricefloatMax price (pence)
categorystringForecourt type filter
districtstringLocal authority district
constituencystringParliamentary constituency
rural_urbanstringRural/urban classification
regionstringRegion filter
countrystringCountry filter
supermarket_onlyboolfalseOnly supermarket stations
motorway_onlyboolfalseOnly motorway stations
exclude_outliersboolfalseExclude statistical outliers
sortstringpriceprice, brand, city, postcode, district
limitint50Results per page (min 1; readonly capped at 200)
offsetint0Pagination offset

Returns: { results: [...], total, limit, offset }

GET /api/prices/search/export
Export all historical price records matching search filters as a streaming CSV or JSON download. Requires editor or admin role. Accepts the same filter parameters as /api/prices/search (except sort, limit, offset).
ParameterTypeDefaultDescription
Same filters as /api/prices/search above
formatstringcsvcsv or json

Returns: Streaming file download with full station & postcode enrichment for every historical observation.

POST /api/stations/lookup
Batch lookup for station/location fields by node ID, including postcode enrichment data. Useful when you already have a list of node IDs and only need location metadata.
Body fieldTypeRequiredDescription
node_idsarray[string]YesOrdered 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).

GET /api/anomalies
Price records flagged by anomaly detection, with previous price context. Records that have already been corrected are excluded.
ParameterTypeDefaultDescription
limitint50Max 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

GET /api/outliers
Prices excluded as statistical outliers from current snapshot, with IQR bounds for transparency.
ParameterTypeDefaultDescription
fuel_typestringOptional fuel type filter
limitint100Max 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.

GET /api/prices/station/{node_id}/records
Raw individual price records for a station, with any corrections and computed effective flags. Used by the price editor.
ParameterTypeDefaultDescription
fuel_typestringOptional fuel type filter
limitint500Max 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.

All current-snapshot aggregation endpoints (summary, by-region, by-brand, by-category, by-district, by-constituency, by-rural-urban) exclude IQR statistical outliers and anomaly-flagged prices. The history endpoint excludes anomaly-flagged prices and applies a Hampel filter (rolling median ± 3×MAD) to smooth remaining outlier averages. See outlier methodology for details.

Reference

GET /api/fuel-types
List fuel types with human names and categories.

Returns: [ { fuel_type_code, fuel_name, fuel_category } ]

GET /api/regions
List available regions.

Returns: [ "London", "North West", "Scotland", ... ]

GET /api/districts
List available local authority districts.

Returns: [ "Birmingham", "Leeds", "Manchester", ... ]

GET /api/constituencies
List available parliamentary constituencies.

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.

Changes to lookup tables are not reflected in the dashboard until you call POST /api/admin/refresh-view to rebuild the materialised view.

Scrape history

GET /api/admin/scrape-runs
Recent scrape runs with timing and record counts.
ParameterTypeDefaultDescription
limitint50Max runs (1–500)
offsetint0Pagination 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").

GET /api/admin/brand-aliases
List all alias mappings.
POST /api/admin/brand-aliases
Create or update an alias. Upserts on raw_brand_name.
{ "raw_brand_name": "TESCO PFS", "canonical_brand": "Tesco" }
DEL /api/admin/brand-aliases/{raw_brand_name}
Delete an alias mapping.

Brand categories

Map canonical brands to forecourt types for category-level price comparison.

GET /api/admin/brand-categories
List all category mappings.
POST /api/admin/brand-categories
Create or update a category. Upserts on canonical_brand.
{ "canonical_brand": "Tesco", "forecourt_type": "Supermarket" }

Allowed types: Supermarket, Major Oil, Motorway Operator, Fuel Group, Convenience, Independent

DEL /api/admin/brand-categories/{canonical_brand}
Delete a category mapping (brand defaults to Independent).

Station overrides

Per-station brand overrides for edge cases (takes priority over aliases).

GET /api/admin/station-overrides
List all overrides with station details.
POST /api/admin/station-overrides
Create or update an override. Upserts on node_id.
{ "node_id": "abc123...", "canonical_brand": "Shell", "notes": "Branded Shell but API says independent" }
DEL /api/admin/station-overrides/{node_id}
Delete a station override.
POST /api/admin/station-overrides/batch
Create or update overrides for multiple stations in one transaction. Max 500 per request.
{ "canonical_brand": "Tesco", "node_ids": ["abc123", "def456", "ghi789"], "notes": "Bulk reclassification" }

Returns: { "saved": 3, "canonical_brand": "Tesco" }

Normalisation report

GET /api/admin/normalisation-report
Shows how each brand resolves through the normalisation pipeline.
ParameterTypeDefaultDescription
limitint100Max rows (1–1000)
typestringaliased, overridden, or unmapped
brandstringBrand substring filter

Returns: [ { raw_brand, alias_resolved, override_resolved, final_brand, forecourt_type, resolution_method, station_count } ]

Postcode issues

GET /api/admin/postcode-issues
Stations whose postcodes were not recognised by postcodes.io. Includes override status when a corrected postcode has been set.

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 } ]

PATCH /api/admin/postcode-lookups/{postcode}
Manually set coordinates for a postcode that postcodes.io didn't recognise.
{ "latitude": 51.5, "longitude": -0.1 }

Returns: { postcode, latitude, longitude }

POST /api/admin/postcode-lookups/retry-failed
Re-check all previously unrecognised postcodes against postcodes.io (batched). Updates looked_up_at on all retried postcodes. Requires editor or admin.
{}

Returns: { retried, resolved, still_failed }

GET /api/admin/postcode-issues/stats
Count of currently-failed postcode lookups and timestamp of the most recent lookup attempt among them.

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.

GET /api/admin/postcode-overrides
List all per-station postcode overrides.

Returns: [ { node_id, trading_name, brand_name, original_postcode, corrected_postcode, notes, created_at, lookup_succeeded } ]

POST /api/admin/postcode-overrides
Create or update a postcode override. Upserts on 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).

DEL /api/admin/postcode-overrides/{node_id}
Delete a postcode override (reverts to original postcode).

User management (Cognito)

GET /api/admin/users
List all Cognito users with group memberships.

Returns: [ { username, email, status, enabled, groups, created } ]

POST /api/admin/users
Create/invite a new user (sends invitation email).
{ "email": "user@example.com", "role": "editor" }

role accepts "admin", "editor", or "readonly" (default: no group = readonly).

POST /api/admin/users/{username}/groups/{group}
Add a user to a Cognito group.
DEL /api/admin/users/{username}/groups/{group}
Remove a user from a Cognito group.
POST /api/admin/users/{username}/disable
Disable a user account.
POST /api/admin/users/{username}/enable
Re-enable a disabled user account.
DEL /api/admin/users/{username}
Permanently delete a user account.

Refresh view

POST /api/admin/refresh-view
Rebuilds the 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.

GET /api/admin/corrections
List correction history with station and fuel context.
ParameterTypeDefaultDescription
limitint50Max records (1–1000)
offsetint0Pagination 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 }

POST /api/corrections
Create or update a price correction. Automatically refreshes the materialised view.
{ "fuel_price_id": 12345, "corrected_price": 139.9 }
POST /api/corrections/batch
Create or update multiple price corrections in one request. Automatically refreshes the materialised view. Max 200 per batch.
{ "corrections": [ { "fuel_price_id": 12345, "corrected_price": 139.9 }, ... ] }

Returns: { saved: 3, corrections: [ { id, fuel_price_id, corrected_price } ] }

DEL /api/corrections/{fuel_price_id}
Revert a correction (restore original price). Automatically refreshes the materialised view.