# FuelAlert API Reference

Base URL: `https://fuel-price.test/api`  
All endpoints return JSON. All endpoints require an `X-Api-Key` header.

**Authentication:**

```
X-Api-Key: your-secret-key
```

All requests without a valid key return `403 Forbidden`.

---

## Stations

### GET `/api/stations`

Returns nearby petrol stations with live prices for a given fuel type.

**Location — provide one of:**

| Parameter | Type | Description |
|---|---|---|
| `postcode` | string | UK postcode, outcode, or place name (resolved via postcodes.io) |
| `lat` + `lng` | float | Decimal lat/lng (required if no postcode) |

**Optional:**

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `fuel_type` | string | — | **Required.** See fuel type aliases below |
| `radius` | float | `10.0` | Search radius in km (0.1–50) |
| `sort` | string | `"price"` | `"price"`, `"distance"`, `"updated"`, or `"brand"` |
| `pricing_mode` | string | — | `"pump"` (reserved, no effect yet) |

**Sort values:**

| Value | Sorts by |
|---|---|
| `price` | Price ascending (cheapest first) — **default** |
| `distance` | Distance ascending (closest first) |
| `updated` | Price freshness descending (most recently updated first) |
| `brand` | Brand name A–Z |

**Fuel type aliases** (`fuel_type` accepts any of these):

| Alias | Maps to |
|---|---|
| `petrol`, `unleaded`, `e10` | E10 (standard unleaded) |
| `premium_unleaded`, `e5` | E5 (super unleaded) |
| `diesel`, `b7_standard` | B7 Standard (standard diesel) |
| `premium_diesel`, `b7_premium` | B7 Premium (premium diesel) |
| `b10` | B10 (biodiesel blend) |
| `hvo` | HVO (hydrotreated vegetable oil) |

**Example request:**
```
GET /api/stations?postcode=SW1A1AA&fuel_type=petrol&radius=5&sort=price
GET /api/stations?lat=51.5074&lng=-0.1278&fuel_type=diesel&radius=10&sort=distance
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=updated
GET /api/stations?postcode=M11AE&fuel_type=petrol&sort=brand
```

**Response:**
```json
{
  "data": [
    {
      "station_id": "0028acef5f3afc41...",
      "name": "Alex Fuel Station",
      "brand": "BP",
      "is_supermarket": false,
      "address": "123 High Street, London",
      "postcode": "SW1A 1AA",
      "lat": 51.5074,
      "lng": -0.1278,
      "distance_km": 1.23,
      "fuel_type": "e10",
      "price_pence": 14390,
      "price": 143.9,
      "price_updated_at": "2026-04-05T08:00:00.000Z"
    }
  ],
  "meta": {
    "count": 12,
    "fuel_type": "e10",
    "radius_km": 10.0,
    "lowest_pence": 14290,
    "highest_pence": 14890,
    "cheapest_price_pence": 14290,
    "avg_pence": 14523.5
  }
}
```

**Notes:**
- `price_pence` is the raw integer (e.g. `14390` = 143.90p). `price` is the float in pence (e.g. `143.9`).
- Closed stations (temporary or permanent) are excluded.
- Every call logs a search record used by the stats endpoint.

**Error — postcode not found:**
```json
{ "errors": { "postcode": ["Postcode not found."] } }
```

**Error — missing location:**
```json
{ "errors": { "lat": ["The lat field is required when postcode is not present."] } }
```

---

## Stats

### GET `/api/stats/searches`

Aggregate search statistics. Useful for a "social proof" counter on the frontend.

| Parameter | Type | Default | Notes |
|---|---|---|---|
| `period` | string | `"week"` | `"week"` (7 days) or `"month"` (30 days) |

**Example request:**
```
GET /api/stats/searches
GET /api/stats/searches?period=month
```

**Response:**
```json
{
  "total_searches": 3842,
  "unique_searchers": 1201,
  "avg_results": 14.3,
  "avg_lowest_price": 141.2,
  "avg_highest_price": 148.7,
  "avg_price": 144.9,
  "period": "week",
  "message": "Helped 1201 drivers find cheaper fuel this week so far!"
}
```

**Notes:**
- `avg_lowest_price`, `avg_highest_price`, `avg_price` are in **pence** as floats (e.g. `141.2` = 141.2p).
- `unique_searchers` is based on hashed IP — approximate unique users.

---

## Prediction

### GET `/api/prediction`

National or regional E10 fuel price direction forecast for the next 7 days, based on live price data signals. Always analyses E10 — the most widely available fuel and the one with the most price history.

| Parameter | Type | Description |
|---|---|---|
| `lat` | float | Optional. Decimal latitude. Enables regional prediction (50km radius). |
| `lng` | float | Optional. Decimal longitude. Required if `lat` is provided. |

**Example request:**
```
GET /api/prediction
GET /api/prediction?lat=51.5074&lng=-0.1278
```

**Response:**
```json
{
  "fuel_type": "e10",
  "current_avg": 143.9,
  "predicted_direction": "down",
  "predicted_change_pence": -2.1,
  "confidence_score": 74.5,
  "confidence_label": "high",
  "action": "wait",
  "reasoning": "National prices have been falling at 0.3p/day. Supermarkets led last week — independents typically follow within 48h.",
  "prediction_horizon_days": 7,
  "region_key": "national",
  "methodology": "multi_signal_live_fallback",
  "signals": {
    "trend": {
      "score": -0.8,
      "confidence": 0.92,
      "direction": "down",
      "detail": "Slope: -0.30p/day over 5 days (R²=0.92) [Adaptive lookback active]",
      "data_points": 5,
      "enabled": true,
      "slope": -0.3,
      "r_squared": 0.92
    },
    "day_of_week": {
      "score": 0.0,
      "confidence": 0.0,
      "direction": "stable",
      "detail": "Insufficient history for day-of-week pattern.",
      "data_points": 0,
      "enabled": true
    },
    "brand_behaviour": {
      "score": -1.0,
      "confidence": 0.6,
      "direction": "down",
      "detail": "Supermarkets fell 3.5p vs majors 0.5p (divergence: 3.0p). Expect majors to follow.",
      "data_points": 2800,
      "enabled": true
    },
    "national_momentum": {
      "score": 0.0,
      "confidence": 0.0,
      "direction": "stable",
      "detail": "National momentum disabled for national predictions",
      "data_points": 0,
      "enabled": false
    },
    "regional_momentum": {
      "score": 0.0,
      "confidence": 0.0,
      "direction": "stable",
      "detail": "No coordinates provided for regional momentum analysis",
      "data_points": 0,
      "enabled": false
    },
    "price_stickiness": {
      "score": 0.05,
      "confidence": 0.8,
      "direction": "stable",
      "detail": "Sticky prices (avg hold: 5.2 days) — more predictable.",
      "data_points": 160,
      "enabled": true
    }
  }
}
```

**`region_key` values:**

| Value | Meaning |
|---|---|
| `"national"` | No coordinates provided. `current_avg` and signals use national data. `regional_momentum` is disabled. |
| `"regional"` | Coordinates provided. `current_avg` uses stations within 50km. `regional_momentum` is the primary signal (50% weight). Falls back to national average if no stations found in radius. |

**Key fields:**

| Field | Values | Meaning |
|---|---|---|
| `predicted_direction` | `"up"`, `"down"`, `"stable"` | Price trend direction |
| `action` | `"fill_now"`, `"wait"`, `"no_signal"` | Consumer-facing recommendation |
| `confidence_label` | `"high"` (≥70), `"medium"` (≥40), `"low"` (<40) | Signal strength |
| `predicted_change_pence` | float | Expected p/litre change over 7 days |
| `current_avg` | float | Average price in pence (e.g. `143.9` = 143.9p). Regional if lat/lng given, else national. |

**Signal weights:**

| Scope | Signal | Weight |
|---|---|---|
| National | trend | 45% |
| National | brand_behaviour | 25% |
| National | day_of_week | 20% |
| National | price_stickiness | 10% |
| Regional | regional_momentum | 50% |
| Regional | trend | 20% |
| Regional | day_of_week | 15% |
| Regional | brand_behaviour | 10% |
| Regional | price_stickiness | 5% |

**Signal structure** (each signal in `signals`):

| Field | Type | Notes |
|---|---|---|
| `score` | float (-1.0 to 1.0) | Negative = falling pressure, positive = rising |
| `confidence` | float (0.0–1.0) | How reliable this signal is |
| `direction` | `"up"` / `"down"` / `"stable"` | Signal direction |
| `detail` | string | Human-readable explanation |
| `data_points` | int | Number of price records used |
| `enabled` | bool | False if signal was skipped (missing data or coordinates) |

**LLM-backed prediction** — separately, the nightly `oil:predict` command generates an oil price direction from Brent crude data and stores it in `price_predictions`. This feeds into `AlertScoringService` (Signal 4) but is not exposed directly through this endpoint. See [LLM Prediction Providers](llm-prediction-providers.md).

---


## Error Shapes

**Validation error (422):**
```json
{
  "message": "The fuel type field is required.",
  "errors": {
    "fuel_type": ["The fuel type field is required."]
  }
}
```

**Forbidden (403)** — missing or invalid `X-Api-Key`:
```json
{ "message": "Forbidden." }
```
