- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count - Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar - Add brand filter dropdown with dynamic brand list from parent, emit update events - Move station count from StationList to PostSearchFilters, display as "X station(s) found" - Remove sort tabs and brand filter from StationList component - Add force-new-line div for mobile layout between Refine and Sort groups - Include brand filter in hasActive check and resetFilters function - Update Home.vue to pass brands/brandFilter props and handle brandFilter updates - Add reset() method to useStations composable to clear state on empty query - Clear search state when route query is empty instead of attempting search - Update Fuel Finder API base URL to include /api/v1 path - Adjust map zoom levels for 10-15 mile radius range - Update API token request to use retry and increase timeout to 60s
8.4 KiB
External API & Data Sources
UK Fuel Finder API (gov.uk) — PRIMARY SOURCE
- Base URL:
https://www.fuel-finder.service.gov.uk/api/v1/ - Returns: all UK station prices + station metadata (~14,500 stations)
- Update frequency: stations report within 30 minutes of price change
- Our polling interval: every 30 minutes via scheduler (incremental using
effective-start-timestamp), station metadata auto-refreshed once per day on the first poll after midnight
Authentication
OAuth 2.0 using JSON body POST (not form-encoded). Credentials in .env as FUEL_FINDER_CLIENT_ID / FUEL_FINDER_CLIENT_SECRET.
Get token:
POST /api/v1/oauth/generate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}
Response: {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "eyJ..."}
Refresh token:
POST /api/v1/oauth/regenerate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}
Token caching strategy (FuelPriceService):
- Cache the
access_tokenwith TTL =expires_in- 60 seconds (3540s) - Cache key:
fuel_finder_access_token - On cache miss: call
generate_access_token, store result, return token - Use the
refresh_tokento regenerate before expiry if needed - Include token in every API request:
Authorization: Bearer {token}
Endpoints
GET /api/v1/pfs/fuel-prices?batch-number={n}— all station prices (500 stations per batch)GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS— incremental, only prices changed since timestampGET /api/v1/pfs?batch-number={n}— all station metadata (500 per batch)GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS— incremental station metadata
Fuel prices response fields (array of stations):
node_id,public_phone_number,trading_name— station identifiersfuel_prices[]— array of{fuel_type, price, price_last_updated, price_change_effective_timestamp}- Fuel types (API casing):
E5,E10,B7_Standard,B7_Premium,B10,HVO— lowercased on ingest viaFuelType::fromApiValue() - Price is a float (e.g.
159.9= 159.9p) — multiply × 100 and store as integer pence
Station metadata response fields (array of stations):
node_id,trading_name,brand_name,is_same_trading_and_brand_name,public_phone_numberis_supermarket_service_station,is_motorway_service_stationtemporary_closure,permanent_closure,permanent_closure_datelocation—{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}amenities— OBJECT with boolean flags:{adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}. Normalised at ingest to a flat array of enabled keys.fuel_types— OBJECT with boolean flags:{E10, E5, B7_Standard, B7_Premium, B10, HVO}. Normalised at ingest to a flat array of enabled keys.opening_times—usual_days.{monday..sunday}.{open, close, is_24_hours}+bank_holidays.type.{open_time, close_time, is_24_hours}. Stored as raw JSON, not used in scoring.
Required-field validation
Stations missing any of node_id, trading_name, location.postcode, location.latitude, location.longitude are dropped at ingest with a warning. Price rows missing any of fuel_type, price, price_last_updated, price_change_effective_timestamp are skipped silently.
Incremental polling (FuelPriceService::pollPrices)
On each successful poll the wall-clock start time is cached under fuel_finder_last_price_poll_at (forever). The next poll sends this as effective-start-timestamp. Cold start (cache miss) performs a full fetch.
FK safety
Price batches are filtered against the stations table before insert — any station not yet in stations is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up.
FuelPriceService responsibilities
- Fetch OAuth token (cache it)
- GET all station prices
- Upsert
stationstable with metadata - Insert new rows into
station_pricesonly when price has changed for that station+fuel combo - Call StationTaggingService to set
is_supermarketandbrand - Dispatch
PricesUpdatedEventfor downstream processing
Deduplication
Only insert a new station_prices row if price differs from the most recent stored price
for that (station_id, fuel_type) combination. Avoids row explosion on unchanged prices.
Credentials in .env
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
Postcodes.io — location resolution
- Free, no API key required
- Handled by
PostcodeService::resolve(string $query): ?LocationResult - Returns
LocationResultDTO withquery,displayName,lat,lng - Results cached for 30 days — cache key
postcode:{normalised_input} - Failed lookups are NOT cached — retried on next request
- Input is auto-detected:
| Input type | Example | Endpoint |
|---|---|---|
| Full postcode | SW1A 1AA |
GET /postcodes/{postcode} |
| Outcode (district) | PE7 |
GET /outcodes/{outcode} |
| Place / city name | Manchester |
GET /places?q={query}&limit=1 |
Anonymous search flow: user types a postcode/city → PostcodeService::resolve() → lat/lng stored in a JSON cookie (30 days) alongside the query string. On return visits, cookie lat/lng is used directly — postcodes.io is only called when the search term changes.
Registered users: postcode resolved once on registration, lat/lng stored on users table — not re-resolved unless postcode changes.
FRED API (St. Louis Fed) — Brent crude prices
- Series:
DCOILBRENTEU(daily Brent spot price, USD/barrel) - Endpoint:
GET https://api.stlouisfed.org/fred/series/observations - Params:
series_id=DCOILBRENTEU,sort_order=desc,limit=30,file_type=json - Free API key required — stored as
FRED_API_KEYin.env - Handled by
OilPriceService::fetchBrentPrices() - Fetched daily at 7am via
oil:predict --fetchscheduler command - FRED uses
"."as a placeholder for non-trading days (weekends/holidays) — filtered out before insert - Stored in
brent_pricestable, upserted ondateprimary key
Anthropic API — oil price direction prediction
- Endpoint:
POST https://api.anthropic.com/v1/messages - Model:
claude-haiku-4-5-20251001(configurable viaANTHROPIC_MODELin.env) - Key stored as
ANTHROPIC_API_KEYin.env - Handled by
OilPriceService::generateLlmPrediction() - Called once daily after FRED fetch — sends last 30 days of Brent prices + pre-computed EWMA context
- Response must be JSON:
{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "..."} - Model sometimes wraps JSON in markdown code fences — these are stripped before
json_decode - Confidence is capped at 85 regardless of what the model returns
- On any failure (API error, malformed JSON, invalid direction) → falls back to EWMA silently
- Result stored in
price_predictionstable withsource = 'llm'
EWMA fallback (OilPriceService::generateEwmaPrediction()):
- Compares 3-day EWMA vs 7-day EWMA on chronological Brent price data
- Threshold: ±1.5% change → rising/falling; below → flat
- Confidence capped at 65 (simpler model)
- Used when: no
ANTHROPIC_API_KEYset, or LLM call fails - Result stored in
price_predictionstable withsource = 'ewma'
OneSignal — push notifications
- REST API:
https://oapi.onesignal.com/notifications - App ID + REST API key stored in .env as
ONESIGNAL_APP_ID,ONESIGNAL_API_KEY - Target by
player_id(stored inusers.push_token) - No official Laravel package needed — use Laravel HTTP client (
Http::post(...)) - Free plan: 10,000 subscribers — sufficient for v1
Vonage — WhatsApp + SMS
- Package:
vonage/client-corevia Composer - Credentials:
VONAGE_KEY,VONAGE_SECRET,VONAGE_WHATSAPP_FROMin .env - WhatsApp: Messages API, utility template category (pre-approved)
- SMS: SMS API, alphanumeric sender ID "FuelAlert"
- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components
HTTP client
Use Laravel's built-in Http facade for all external API calls.
Always set a timeout: Http::timeout(10)->get(...).
Wrap in try/catch — log failures, never let a failed API call crash the scheduler.