# 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_token` with 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_token` to 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 timestamp - `GET /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 identifiers - `fuel_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 via `FuelType::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_number` - `is_supermarket_service_station`, `is_motorway_service_station` - `temporary_closure`, `permanent_closure`, `permanent_closure_date` - `location` — `{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 1. Fetch OAuth token (cache it) 2. GET all station prices 3. Upsert `stations` table with metadata 4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo 5. Call StationTaggingService to set `is_supermarket` and `brand` 6. Dispatch `PricesUpdatedEvent` for 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 `LocationResult` DTO with `query`, `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_KEY` in `.env` - Handled by `OilPriceService::fetchBrentPrices()` - Fetched daily at 7am via `oil:predict --fetch` scheduler command - FRED uses `"."` as a placeholder for non-trading days (weekends/holidays) — filtered out before insert - Stored in `brent_prices` table, upserted on `date` primary key ## Anthropic API — oil price direction prediction - Endpoint: `POST https://api.anthropic.com/v1/messages` - Model: `claude-haiku-4-5-20251001` (configurable via `ANTHROPIC_MODEL` in `.env`) - Key stored as `ANTHROPIC_API_KEY` in `.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_predictions` table with `source = '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_KEY` set, or LLM call fails - Result stored in `price_predictions` table with `source = '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 in `users.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-core` via Composer - Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .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.