A comprehensive, enriched-data, JSON:API-compliant API for FuelWatch data
fuelwatch-api is a high-performance REST-based API for consuming Western Australian fuel price data from FuelWatch. The API uses the JSON:API v1.1 specification and provides additional functionality, including automatic data conditioning, standardised objects and response models, and service station data enrichment.
π Documentation β’ π Getting started β’ Data freshness β’ Self-hosting β’ Issues β’ Pull Requests
- Fully typed TypeScript API.
- No account or API keys required.
- Read grouped product prices in Australian cents per litre, with explicit AWST price periods.
- Stable brand, feature, and restriction codes. Expand the fields you need into named objects.
- Service methods return
{ data, error }results for straightforward script ergonomics. - Hourly source refreshes and bounded caching reduce repeated requests to FuelWatch.
- JSON:API resources, conditional requests, and a downloadable OpenAPI specification.
fuelwatch-api supports all of the same parameters as the FuelWatch RSS Feed. By default, calling the v1 API with no parameters will return the current-day prices for all product (fuel) types for all service stations in Western Australia.
All fuel pricing is returned in AUD cents per litre (eg. 185.9 means AUD $1.859 per litre).
See the full API specification docs for information about query parameters and their response models. The following url parameters can be supplied to the v1 endpoint:
| Parameter | Accepted values | Default |
|---|---|---|
product |
One or more of 1, 2, 4, 5, 6, 10, 11, comma-separated |
All seven on /v1; 1 on /legacy |
day |
yesterday, today, tomorrow, or DD/MM/YYYY within those three Perth calendar dates |
today |
suburb |
Comma-separated suburb names; each nonempty, at most 100 characters and without control characters | All suburbs |
region |
Comma-separated region codes exported in src/fuelwatch.ts |
All regions |
brand |
Comma-separated brand codes exported in src/fuelwatch.ts |
All brands |
surrounding |
yes or no |
Origin default |
expand |
all, OR one or more of brand, siteFeatures, restrictions, product, comma-separated |
None |
For example, you can expand all of the reference codes using the ?expand=all parameter:
curl 'https://fuelwatch.oss.bhodges.me/v1?expand=all' \
-H 'Accept: application/vnd.api+json'which would provide a response similar to this:
{
"jsonapi": {
"version": "1.1"
},
"meta": {
"source": "fuelwatch.wa.gov.au",
"products": [1, 2, 4, 5, 6, 10, 11],
"sourceDate": "2026-09-30",
"fetchedAt": "2026-09-30T10:01:27.209+08:00",
"validFrom": "2026-09-30T06:00:00.000+08:00",
"validUntil": "2026-10-01T06:00:00.000+08:00",
"publicationStatus": "available",
"copyright": "Copyright 2025 Department of Local Government, Industry Regulation and Safety (source data); Copyright 2026 Bradley Hodges (api). All rights reserved.",
"documentation": "https://docs.fuelwatch.oss.bhodges.me",
"issues": "https://github.com/bradleyhodges/hacs-fuelwatch/issues"
},
"data": [
{
"type": "serviceStation",
"id": "2026-09-30:294ed847a15de8a8305c548cece119542e625a89897cd82290470f7df5ec0a8e",
"attributes": {
"name": "Meekatharra Corner Store",
"tradingName": "Meekatharra Corner Store",
"brand": {
"code": 5,
"name": "BP",
"logo": "/static/image/brand/bp.svg"
},
"price": {
"asAt": "2026-09-30T06:00:00.000+08:00",
"products": {
"1": {
"name": "Unleaded Petrol",
"amount": 212.9
},
"4": {
"name": "Diesel",
"amount": 266.9
}
}
},
"address": {
"street": "16 Main St",
"suburb": "MEEKATHARRA",
"state": "WA",
"postcode": "6642"
},
"is24Hours": null,
"phone": "+61899811151",
"latitude": -26.591283,
"longitude": 118.496475,
"siteFeatures": [
{
"code": 1,
"name": "Credit Cards"
},
{
"code": 2,
"name": "Debit Cards"
}
],
"restrictions": null,
"enrichment": {
"provider": "Google Maps",
"placeId": "ChIJoc_kt3GawCsRwS1ae7Uraak",
"fetchedAt": "2026-09-29T18:36:33.091+08:00",
"stale": false,
"fields": [
"address.postcode",
"siteFeatures"
],
"googleMapsUri": "https://www.google.com/maps/search/?api=1&query=Meekatharra%20Corner%20Store&query_place_id=ChIJoc_kt3GawCsRwS1ae7Uraak",
"attributions": []
}
}
},
...
]
}List values are OR alternatives; different filters combine with AND. Whitespace is trimmed and duplicate list values are removed. Casing, list/query ordering, suburb whitespace and date aliases normalize into shared cache keys. Numeric filters use FuelWatch codes, not display names. Day and Surrounding each take one value. Unknown or repeated parameter names (including case variants), empty list entries, invalid codes and unsupported dates return HTTP 400 before origin access. Absolute dates translate to upstream relative days.
curl -g -H 'Accept: application/vnd.api+json' 'https://fuelwatch.oss.bhodges.me/v1?filter[product]=1&filter[day]=today'
curl 'https://fuelwatch.oss.bhodges.me/v1?brand=2,35&product=1,2,6&day=TODAY'Full documentation for all reference provided by the API are available in the Reference codes specification docs.
| Code | Name |
|---|---|
| 0 | Unknown |
| 2 | Ampol |
| 3 | Better Choice |
| 4 | BOC |
| 5 | BP |
| 6 | Caltex |
| 7 | Gull |
| 10 | Liberty |
| 11 | Mobil |
| 14 | Shell |
| 15 | Independent |
| 23 | United |
| 24 | Eagle |
| 25 | FastFuel 24/7 |
| 26 | Puma |
| 27 | Vibe |
| 29 | 7-Eleven |
| 30 | Metro Petroleum |
| 31 | WA Fuels |
| 32 | Costco |
| 34 | Atlas |
| 35 | EG Ampol |
| 36 | CGL fuel |
| 37 | X Convenience |
| 38 | Phoenix |
| 39 | Burk |
| 40 | Petro Fuels |
| 41 | Astron |
| 42 | OTR |
| 43 | Reddy Express |
| 44 | Dunning's |
| 45 | Perrys |
| 46 | UGO |
| 47 | Maisey Fuels |
| 48 | OMG Caltex |
| 49 | OMG Metro |
| 50 | Solo |
| 52 | Broome Diesel |
| 53 | Fuel Tech |
| Code | Name |
|---|---|
| 1 | Credit Cards |
| 2 | Debit Cards |
| 3 | Fuel Cards |
| 4 | ATM |
| 5 | Toilets |
| 6 | Bottled Gas |
| 7 | Trailer Hire |
| 8 | EFTPOS |
| 9 | Restaurant |
| 10 | Carwash |
| 11 | Workshop |
| 12 | Air |
| 13 | Water |
| 14 | Ice |
| 15 | Discount |
| 16 | Voucher |
| 17 | Bottled AdBlue |
| 18 | Pumped AdBlue |
| 19 | Truck Friendly |
| 20 | Convenience Store |
| 21 | Open 24 hours |
| Code | Name |
|---|---|
| 1 | Unmanned site (credit card charges may apply) |
| 2 | Entry Permit Required |
| 3 | Membership Required |
| 4 | Low Aromatic Fuel |
Published selections are cached in D1 for six hours (21,600 seconds) from the completed origin fetch. All users and Cloudflare data centers share this snapshot, including /v1 and /legacy. Cache identity includes the configured origin, absolute source date and normalized filters, so casing, parameter order, list order and filter[...] aliases reuse the same snapshot. Hits preserve meta.fetchedAt and never extend the expiry. The /v1 catalogue shares one complete extract per product/date across all supported local filters. Source-filtered selections and /legacy still have distinct entries. Combined responses report the oldest contributing fetchedAt and the earliest expiry.
The local Cache API holds the rendered response for up to the snapshot's remaining six-hour lifetime. HTTP freshness stops earlier at Perth midnight (relative day URLs change meaning), price expiry or an enrichment deadline. Rebuilding a response after edge eviction or enrichment expiry still uses D1 without calling FuelWatch. A cached tomorrow snapshot can become today at midnight because its source date is unchanged. Already published results do not need invalidation at 06:00 or 14:30. Empty results, and multi-product selections missing any requested fuel, are cached for at most 30 seconds, shortened at publication/day boundaries. This prevents an early request from hiding newly published prices. Browser responses require revalidation (max-age=0); shared HTTP caches receive only the remaining s-maxage. ETags support conditional GET and HEAD.
On a shared miss, a 60-second SQL lease elects one origin fetcher. Other callers wait up to ten seconds with bounded backoff, then receive 503 cache_refresh_busy and Retry-After: 2 if the refresh is still running. Failed fetches release the lease; an abandoned lease expires automatically. Cache publication is awaited before returning success and atomically replaces gzip-compressed chunks of at most 1,000,000 bytes, below D1's per-row limit. Delayed writers cannot overwrite a newer owner's snapshot. Each D1 operation has a two-second caller deadline. Scheduled invocations prune up to 100 expired selections and their chunks, without removing active refreshes.
X-FuelWatch-Cache: HIT|MISS describes the local response cache. When it is MISS, X-FuelWatch-Snapshot-Cache: HIT|MISS|BYPASS distinguishes a shared D1 hit, a newly stored origin fetch, and a storage fallback. On an edge hit, this snapshot header describes how that representation was originally built. X-FuelWatch-Source-Date and X-FuelWatch-Fetched-At retain origin provenance. Edge writes run under waitUntil. Cache failures are logged and fall back to bounded origin fetching; a D1 outage or unapplied migration therefore temporarily loses cross-data-center deduplication. Expired data is never used as an outage fallback. Home Assistant retains its own last good snapshots and displays their age/errors.
One eight-second deadline covers upstream headers, streaming body reads and retry delay. Network failures, 429 and 5xx responses allow at most one retry with jitter. A long Retry-After returns immediately rather than holding a worker open. Redirects and validation failures are not retried. Origin cookies, cache headers and XML ETags are not forwarded.
Source-filtered region/surrounding-suburb queries can create many distinct selections. Monitor D1 storage, rows read/written and unique-query traffic before adding Cloudflare rate-limiting rules. The existing 15-minute enrichment discovery cron continues independently of the hourly price refresh.
The 0 * * * * cron refreshes all seven unfiltered single-product selections at the start of every hour, every day. Cloudflare evaluates cron in UTC; this expression is also hourly at minute zero in AWST. Before 06:00 AWST it refreshes yesterday and today; from 06:00 until 14:30 it refreshes today; after 14:30 it includes tomorrow. This warms the exact absolute-date cache keys requested by Home Assistant, even when nobody has requested them yet. Every catalogue selection, including all-products requests and brand/exact-suburb changes, reuses these extracts. Source-filtered region/surrounding-suburb combinations are filled on demand.
The job refreshes D1 even while a previous snapshot is fresh, with at most three concurrent origin requests. Public readers continue using the previous valid snapshot during refresh. Scheduled delivery retries reuse snapshots fetched since that event's scheduled time. Failed or unexpectedly empty replacements keep the existing prices and their original expiry; successful refreshes start a new six-hour lifetime. Existing rendered edge responses retain their bounded lifetime and may show older same-period prices until they expire. The cron warms shared D1, not every edge data center.
Price warming runs without Google credentials or enrichment budget. feeds_warmed logs the AWST scheduled time, refreshed/reused/empty/failed counts and duration. feed_warm_failed identifies each failed product/day; other selections still run, then the cron invocation fails visibly if any refresh failed. Deploy the worker to register the new hourly trigger alongside */15 * * * * for enrichment. The existing D1 migration suffices; no new tables or API keys are needed.
The API is open source and can be self-hosted. If you'd prefer to self-host the API, you'll need a Cloudflare account with access to Cloudflare Workers and D1. More information about how to deploy the API is available here.
