# 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 15 minutes via scheduler (incremental), full refresh once daily

### 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}`

Fuel Finder REST API
The Fuel Finder API is a REST API that gives a simple, consistent way to request, create and update data. REST stands for Representational State Transfer which is an architectural software style in which standard HTTP request methods are used to retrieve and modify representations of data. This is identical to the process of retrieving a web page or submitting a web form.

Representational State Transfer (REST) web services
In a RESTful API, each data resource has a unique URL and is manipulated using standard HTTP verbs such as:

GET to request a resource
POST to create a resource (not used for read-only endpoints)
PUT to change a resource (not used for read-only endpoints)
DELETE to remove a resource (not used for read-only endpoints)
Example: request a price resource
GET: https://api.fuelfinder.service.gov.uk/v1/prices/GB-12345 HTTP/1.1
The request uses GET and does not include a request body.

In a RESTful API, a resource is modified by POSTing a revised resource representation, in this case JSON, to the same resource URL:

POST: https://api.fuelfinder.service.gov.uk/v1/<endpoint>
Content-Type: text/json
{
"CustomerName": "Joe Bloggs",
"Address": "",
"etc": etc
}
REST builds on the features of HTTP. Because each resource has a globally unique URL and can be fetched with GET, REST APIs can benefit from existing network components such as caches and proxies.

The JSON data format
Responses use JSON (JavaScript Object Notation). JSON is a compact, widely used format for storing and exchanging data. Most programming languages support JSON, which makes it well suited to HTTP-based API services.

#### Endpoints
- Endpoints
- Method	Endpoint
- GET	Fetch all PFS fuel prices
- GET	Fetch incremental PFS fuel prices 
- GET	Fetch PFS information 
- GET	Fetch incremental PFS information


```
https://www.fuel-finder.service.gov.uk/api/v1/pfs/fuel-prices?batch-number
[
  {
    "node_id": "0028acef5f3afc41c7e7d56fb285a940dfb64d6fea01cb4accd79c148321112d",
    "public_phone_number": null,
    "trading_name": "Alex Fuel Station",
    "fuel_prices": [
      {
        "fuel_type": "E5",
        "price": 159.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      },
      {
        "fuel_type": "E10",
        "price": 132.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      },
      {
        "fuel_type": "B7_STANDARD",
        "price": 141.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      }
    ]
  },
  {
    "node_id": "01da92125c3751767044d06b202f45da5933f0e16e256fa3e98a16af8386308d",
    "public_phone_number": "",
    "trading_name": "Star Garage",
    "fuel_prices": [
      {
        "fuel_type": "E5",
        "price": 159.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      }
    ]
  },
  {
    "node_id": "020592cd81196efdb61ab2135f837ddf3d2bee4e64346810270f0b088b4c09d8",
    "public_phone_number": null,
    "trading_name": "Blue Hills Fuel Station",
    "fuel_prices": [
      {
        "fuel_type": "E5",
        "price": 159.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      },
      {
        "fuel_type": "B7_STANDARD",
        "price": 141.9,
        "price_last_updated": "2026-02-17T16:03:04.938Z",
        "price_change_effective_timestamp": "2026-02-17T16:00:00.000Z"
      }
    ]
  }
]
```

```
https://www.fuel-finder.service.gov.uk/api/v1/pfs?batch-number=1
[
  {
    "node_id": "9b275ab576eeba3c6677984be15ee22a74e54fdfe8e5ea700e84a03178dc4ac1",
    "public_phone_number": null,
    "trading_name": "TEST",
    "is_same_trading_and_brand_name": true,
    "brand_name": "TEST",
    "temporary_closure": false,
    "permanent_closure": false,
    "permanent_closure_date": null,
    "is_motorway_service_station": false,
    "is_supermarket_service_station": false,
    "location": {
      "address_line_1": "HALL & WOODHOUSE, TAPLOW BOATYARD, MILL LANE, TAPLOW, MAIDENHEAD, SL6 0AA",
      "address_line_2": null,
      "city": "MAIDENHEAD",
      "country": "England",
      "county": null,
      "postcode": "SL6 0AA",
      "latitude": 51.5268585,
      "longitude": -0.700361
    },
    "amenities": [
      "water_filling"
    ],
    "opening_times": {
      "usual_days": {
        "monday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "tuesday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "wednesday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "thursday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "friday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "saturday": {
          "open": "00:00:00",
          "close": "00:00:00",
          "is_24_hours": false
        },
        "sunday": {
          "open": "00:00:00",
          "close": "23:59:00",
          "is_24_hours": true
        }
      },
      "bank_holiday": {
        "type": "bank holiday",
        "open_time": "00:00:00",
        "close_time": "00:00:00",
        "is_24_hours": false
      }
    },
    "fuel_types": [
      "E10",
      "E5",
      "HVO",
      "B10"
    ]
  },
  {
    "node_id": "4fd9a4c6b48358b9b5c95989fba100fdcbb87c9e909ed4ce1ad96f64ffb8b56a",
    "public_phone_number": "+44 7723608248",
    "trading_name": "TEST FORECOURT 1",
    "is_same_trading_and_brand_name": true,
    "brand_name": "TEXACO ONE",
    "temporary_closure": false,
    "permanent_closure": null,
    "permanent_closure_date": null,
    "is_motorway_service_station": false,
    "is_supermarket_service_station": false,
    "location": {
      "address_line_1": "NEWPORT",
      "address_line_2": "",
      "city": "BROUGH",
      "country": "ENGLAND",
      "county": "EAST YORKSHIRE",
      "postcode": "HU15 2RD",
      "latitude": 51.258503,
      "longitude": -3.417567
    },
    "amenities": [
      "adblue_packaged",
      "adblue_pumps",
      "car_wash",
      "customer_toilets"
    ],
    "opening_times": {
      "usual_days": {
        "monday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "tuesday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "wednesday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "thursday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "friday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "saturday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        },
        "sunday": {
          "open": "06:00:01",
          "close": "23:00:01",
          "is_24_hours": false
        }
      },
      "bank_holiday": {
        "type": "standard",
        "open_time": "06:00:01",
        "close_time": "23:00:01",
        "is_24_hours": false
      }
    },
    "fuel_types": [
      "B10"
    ]
  },
  {
    "node_id": "91bdda1c07fa05110a31639cc66932f9ed8bd388d4f6be542a423365bcfd53e1",
    "public_phone_number": "+442071930000",
    "trading_name": "SUPERFUEL LOUGHBOROUGH 12",
    "is_same_trading_and_brand_name": true,
    "brand_name": "SUPERFUEL STATION 4",
    "temporary_closure": false,
    "permanent_closure": null,
    "permanent_closure_date": null,
    "is_motorway_service_station": false,
    "is_supermarket_service_station": false,
    "location": {
      "address_line_1": "14 LONDON ROAD",
      "address_line_2": "FUELVILLE",
      "city": "LOUGHBOROUGH",
      "country": "ENGLAND",
      "county": "LEICESTERSHIRE",
      "postcode": "LE11 9AA",
      "latitude": 50.503343,
      "longitude": -2.12444
    },
    "amenities": [
      "adblue_packaged",
      "adblue_pumps",
      "car_wash",
      "customer_toilets",
      "water_filling"
    ],
    "opening_times": {
      "usual_days": {
        "monday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "tuesday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "wednesday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "thursday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "friday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "saturday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        },
        "sunday": {
          "open": "06:00:00",
          "close": "22:00:00",
          "is_24_hours": false
        }
      },
      "bank_holiday": {
        "type": "standard",
        "open_time": "08:00:00",
        "close_time": "20:00:00",
        "is_24_hours": false
      }
    },
    "fuel_types": [
      "E5",
      "HVO",
      "B10",
      "B7_PREMIUM",
      "B7_STANDARD"
    ]
  }
]
```

### 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://api.fuel-finder.service.gov.uk
```

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