# Waze Incidents API — Complete Integration Guide

Real-time Waze incidents (police, accidents, hazards, road closures, roadworks)
and traffic jams, served to your GPS app along a driving route.

- **Base URL:** `https://waze.sandagg.fr/api`
- **Interactive docs (OpenAPI/Swagger):** <https://waze.sandagg.fr/api/docs>
- **Auth:** send `X-API-Key: <3cfda11c2aefa41a27359bc3fad2bebae6a6a58c641080eb>` on every call (except `/health`).
- **Coordinates:** always `[latitude, longitude]`, decimal degrees (WGS84).

---

## 1. Architecture — read this first

Waze's data endpoint is gated by **reCAPTCHA Enterprise**: plain HTTP, headless
browsers, and datacenter/proxy IPs all get **HTTP 403**. Only a *real browser on
a real residential/mobile IP* is accepted.

So the app uses a **crowd-sourced model**: **each user's device** scrapes Waze
(it already has a real mobile IP and a real browser engine), and pushes what it
finds to this API, which **aggregates and redistributes** it to everyone.

```
                 ┌──────────────────────── user's phone ────────────────────────┐
                 │  hidden WebView → www.waze.com/live-map (real mobile IP)       │
                 │  intercept georss JSON  ──POST /ingest──►  ┌───────────────┐   │
                 │                                            │  Waze API     │   │
   GPS UI  ◄──── │  ──GET /route or POST /route (poll 5s)──►  │  (aggregator) │   │
                 │                                            └───────────────┘   │
                 └───────────────────────────────────────────────────────────────┘
```

Every device plays **two roles** (you can do one or both):

- **Contributor** — scrapes Waze in a WebView and `POST /ingest`. This is what
  feeds the system. A device contributing for its own route guarantees that
  route has data even if no one else is around.
- **Consumer** — `POST /route` / `GET /route/{id}` (or `GET /incidents`) to read
  the aggregated incidents and draw them on the map.

The server stores incidents with a **TTL** (they expire when no longer
refreshed), de-duplicates by Waze id, and filters by your route/point.

---

## 2. Authentication

Every request (except `GET /health`) needs the header:

```
X-API-Key: 3cfda11c2aefa41a27359bc3fad2bebae6a6a58c641080eb
```

Missing/invalid key → `401`. Keep the key in your app config / secrets, not in
source control.

---

## 3. Endpoint reference

### `GET /health` — liveness (no key)
```json
{ "status": "ok", "active_routes": 2, "warm_cells": 0 }
```

### `POST /ingest` — push scraped Waze data (Contributor role)
Send the **raw georss JSON** your WebView intercepted. The server parses and
stores it.

**Body (preferred):**
```json
{ "georss": { /* the exact JSON from www.waze.com/live-map/api/georss */ } }
```
Alternatively, send already-normalized arrays:
```json
{ "alerts": [ /* normalized alert objects */ ], "jams": [ /* normalized jams */ ] }
```

**Response:**
```json
{ "ingested": { "alerts": 3, "jams": 1 } }
```
Call it once per georss response you capture (they're small). Idempotent: the
same incident id just refreshes its freshness.

### `POST /route` — register/refresh a route & get its incidents (Consumer role)
Call once when the route is set, then re-call (or `GET /route/{id}`) every ~5 s.

**Body:**
| field | type | required | default | description |
|-------|------|----------|---------|-------------|
| `coordinates` | `[[lat,lon],…]` | yes | — | Route polyline. May be sparse (turn points); the server densifies it. |
| `current` | `[lat,lon]` | no | first point | Driver's live position (used for ordering). |
| `radius_m` | number | no | `800` | Keep incidents within this distance of the route. |
| `types` | `["POLICE",…]` | no | all | Restrict alert types. |
| `jams` | bool | no | `true` | Include traffic jams. |

**Response:** see [§4 Response shape](#4-response-shape).

### `GET /route/{route_id}` — cheap poll of a known route
Query params: `radius_m` (default 800), `types` (CSV, e.g. `POLICE,ACCIDENT`),
`jams` (bool). Returns `404` if the route expired → just `POST /route` again.

### `DELETE /route/{route_id}` — stop tracking a route
```json
{ "deleted": true }
```
Routes also auto-expire ~30 s after the last poll.

### `GET /incidents` — incidents around a point (Consumer)
| param | type | default | description |
|-------|------|---------|-------------|
| `lat`, `lon` | number | required | Center. |
| `radius_km` | number | `1.0` | Search radius. |
| `types` | CSV | all | Alert types filter. |
| `jams` | bool | `true` | Include jams. |
| `max_age` | int | TTL | Only items seen within N seconds. |

(`scan` param exists but is a no-op in device-ingest mode.)

### `GET /stats` — store totals (monitoring)
```json
{ "alerts": 3, "jams": 1,
  "alerts_by_type": {"POLICE":1,"ACCIDENT":1,"HAZARD":1},
  "last_update_epoch": 1780866217, "active_routes": 1 }
```

---

## 4. Response shape

`POST /route` and `GET /route/{id}`:

```json
{
  "route_id": "8029fd36a4175fd0",
  "counts": { "alerts": 3, "jams": 1 },
  "progress": { "cells_total": 6, "cells_scanned": 0, "percent": 0 },
  "alerts": [
    {
      "id": "a-police-1",
      "type": "POLICE",
      "subtype": "POLICE_VISIBLE",
      "category": "police",
      "street": "A6", "city": "Paris", "country": "FR",
      "nThumbsUp": 4, "nComments": null,
      "reliability": 7, "confidence": 5,
      "reportDescription": null, "reportBy": "wazer1",
      "pubMillis": 1780860000000,
      "lat": 48.8809, "lon": 2.3553,
      "age_seconds": 12,
      "dist_to_route_m": 0
    }
  ],
  "jams": [
    {
      "id": "j-1", "level": 4, "speedKMH": 8.5,
      "length_m": 1200, "delay_s": 180,
      "street": "Peripherique", "city": "Paris", "roadType": "freeway",
      "pubMillis": 1780860003000,
      "lat": 48.881, "lon": 2.356,
      "line": [[48.880,2.355],[48.881,2.356],[48.882,2.357]],
      "age_seconds": 12,
      "dist_to_route_m": 52
    }
  ]
}
```

`GET /incidents` is identical but items carry `distance_m` (from the point)
instead of `dist_to_route_m`, and there is no `progress`.

### Field notes
| field | meaning |
|-------|---------|
| `category` | UI bucket: `police`, `accident`, `hazard`, `road_closed`, `construction`, `jam`. |
| `age_seconds` | seconds since last confirmed on Waze (freshness). |
| `pubMillis` | original Waze report time (epoch ms). |
| `level` (jam) | `0` free flow … `5` standstill. |
| `delay_s` (jam) | extra seconds the jam adds. |
| `line` (jam) | the jam polyline `[[lat,lon],…]` — draw it as a colored segment. |
| `reliability`/`confidence` | Waze trust scores (0–10 / 0–5; may be null). |

---

## 5. Alert types & subtypes

Use `type` in the `types` filter. Common subtypes listed for reference.

| type | category | subtypes (examples) |
|------|----------|---------------------|
| `POLICE` | police | `POLICE_VISIBLE`, `POLICE_HIDING`, `POLICE_WITH_MOBILE_CAMERA` |
| `ACCIDENT` | accident | `ACCIDENT_MINOR`, `ACCIDENT_MAJOR` |
| `HAZARD` | hazard / construction | `HAZARD_ON_ROAD`, `HAZARD_ON_ROAD_OBJECT`, `HAZARD_ON_ROAD_POT_HOLE`, `HAZARD_ON_ROAD_CAR_STOPPED`, `HAZARD_ON_SHOULDER_CAR_STOPPED`, `HAZARD_ON_ROAD_CONSTRUCTION` (roadworks), `HAZARD_WEATHER_FOG`, … |
| `ROAD_CLOSED` | road_closed | `ROAD_CLOSED_EVENT`, `ROAD_CLOSED_CONSTRUCTION`, `ROAD_CLOSED_HAZARD` |
| `JAM` | jam | point-form jam alert (distinct from the `jams` array) |

> **Roadworks / travaux** = `HAZARD` with a `*_CONSTRUCTION` subtype, or
> `ROAD_CLOSED_CONSTRUCTION`. Both are tagged `category: "construction"`.

---

## 6. On-device Waze scraping (Contributor) — the WebView recipe

This is the part that gets real data. Your app runs a **hidden/offscreen
WebView**, points it at Waze live-map, intercepts the `georss` responses, and
forwards them to `POST /ingest`.

### 6.1 The interceptor script (inject into the WebView)

Inject this **before the page loads** so it patches `fetch`/`XHR` before Waze
uses them. It captures every georss JSON and hands it to the native layer.

```js
(function () {
  if (window.__wazeHook) return; window.__wazeHook = true;
  function send(payload) {
    try {
      var s = JSON.stringify(payload);
      if (window.ReactNativeWebView) window.ReactNativeWebView.postMessage(s);            // React Native
      else if (window.flutter_inappwebview) window.flutter_inappwebview.callHandler('georss', s); // Flutter (inappwebview)
      else if (window.AndroidWaze) window.AndroidWaze.onGeoRSS(s);                          // Android @JavascriptInterface
      else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.georss)
        window.webkit.messageHandlers.georss.postMessage(s);                               // iOS WKScriptMessageHandler
    } catch (e) {}
  }
  var isGeo = function (u) { return typeof u === 'string' && u.indexOf('/live-map/api/georss') !== -1; };

  // patch fetch
  var of = window.fetch;
  if (of) window.fetch = function () {
    var args = arguments;
    return of.apply(this, args).then(function (res) {
      try {
        var u = (res && res.url) || (args[0] && args[0].url) || args[0];
        if (isGeo(u) && res.ok) res.clone().json().then(send).catch(function () {});
      } catch (e) {}
      return res;
    });
  };

  // patch XHR
  var oOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (m, u) { this.__u = u; return oOpen.apply(this, arguments); };
  var oSend = XMLHttpRequest.prototype.send;
  XMLHttpRequest.prototype.send = function () {
    var self = this;
    self.addEventListener('load', function () {
      try { if (isGeo(self.__u) && self.status === 200) send(JSON.parse(self.responseText)); } catch (e) {}
    });
    return oSend.apply(this, arguments);
  };
})();
```

Then on the native side, wrap each captured payload and POST it:

```
POST https://waze.sandagg.fr/api/ingest
X-API-Key: 3cfda11c2aefa41a27359bc3fad2bebae6a6a58c641080eb
Content-Type: application/json

{ "georss": <the captured JSON> }
```

### 6.2 Driving the WebView so georss fires

Each georss covers only a **~1 km window with ≤10 items**. To cover a route you
must move the map. Load this URL pattern, waiting ~3 s per spot:

```
https://www.waze.com/live-map?lat=<LAT>&lon=<LON>&zoom=13
```

Two strategies (combine them):

- **Around the driver (continuous):** every few seconds, reload the WebView at
  the driver's current `lat/lon`. Cheap, always fresh where it matters.
- **Whole route at start:** sample the route into ~1 km steps and cycle the
  WebView through each step once, so the full route gets data quickly. Then
  switch to "around the driver".

Route sampling (port this to your platform — JS shown):
```js
function haversine(a, b) { /* meters between [lat,lon] a and b */
  var R=6371000,t=Math.PI/180,dl=(b[0]-a[0])*t,dn=(b[1]-a[1])*t;
  var x=Math.sin(dl/2)**2+Math.cos(a[0]*t)*Math.cos(b[0]*t)*Math.sin(dn/2)**2;
  return 2*R*Math.asin(Math.sqrt(x));
}
function sampleRoute(coords, stepM){ // coords: [[lat,lon],...]
  var out=[coords[0]];
  for(var i=1;i<coords.length;i++){
    var a=coords[i-1],b=coords[i],d=haversine(a,b),n=Math.floor(d/stepM);
    for(var k=1;k<=n;k++){var t=k*stepM/d;out.push([a[0]+(b[0]-a[0])*t,a[1]+(b[1]-a[1])*t]);}
    out.push(b);
  }
  return out; // feed each point to the WebView as lat/lon, ~stepM=800
}
```

### 6.3 Platform wiring

**React Native (`react-native-webview`):**
```jsx
<WebView
  ref={ref}
  source={{ uri: `https://www.waze.com/live-map?lat=${lat}&lon=${lon}&zoom=13` }}
  injectedJavaScriptBeforeContentLoaded={INTERCEPTOR_JS}
  onMessage={(e) => {
    const georss = JSON.parse(e.nativeEvent.data);
    fetch('https://waze.sandagg.fr/api/ingest', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': API_KEY },
      body: JSON.stringify({ georss }),
    });
  }}
  style={{ width: 1, height: 1, opacity: 0 }}   // offscreen/hidden
/>
// to move: ref.current.injectJavaScript(`location.href='https://www.waze.com/live-map?lat=${lat}&lon=${lon}&zoom=13';true;`)
```

**Flutter (`flutter_inappwebview`):**
```dart
controller.addJavaScriptHandler(handlerName: 'georss', callback: (args) async {
  final georss = jsonDecode(args[0]);
  await http.post(Uri.parse('https://waze.sandagg.fr/api/ingest'),
    headers: {'Content-Type':'application/json','X-API-Key': apiKey},
    body: jsonEncode({'georss': georss}));
});
// inject INTERCEPTOR_JS in onLoadStart; navigate with controller.loadUrl(... live-map?lat=&lon= ...)
```

**Android native (`WebView`):**
```kotlin
webView.settings.javaScriptEnabled = true
webView.addJavascriptInterface(object {
  @JavascriptInterface fun onGeoRSS(json: String) { postIngest(json) } // POST {"georss": <json>}
}, "AndroidWaze")
webView.webViewClient = object : WebViewClient() {
  override fun onPageStarted(v: WebView, url: String?, f: Bitmap?) { v.evaluateJavascript(INTERCEPTOR_JS, null) }
}
webView.loadUrl("https://www.waze.com/live-map?lat=$lat&lon=$lon&zoom=13")
```

(iOS `WKWebView`: use `WKUserScript` at `.atDocumentStart` for the interceptor
and a `WKScriptMessageHandler` named `georss`.)

---

## 7. Recommended end-to-end flow

```text
WHEN the user selects a route:
  routeCells = sampleRoute(routeCoords, 800)          # ~1 km steps
  POST /route { coordinates: routeCoords, current: gps }   # register, draw what's already known
  start CONTRIBUTOR loop (background):
      for each cell in routeCells (nearest-to-driver first):
          load WebView at cell.lat/lon ; wait ~3s      # interceptor auto-POSTs /ingest
      then keep reloading at the driver's current position

EVERY 5 s WHILE navigating (CONSUMER):
  POST /route { coordinates: routeCoords, current: gps }   # refresh + reprioritize
      (or lighter: GET /route/{route_id})
  diff vs displayed set → add/remove markers, redraw jam polylines

WHEN arrived / route cancelled:
  DELETE /route/{route_id}
  stop the WebView / contributor loop
```

Tips:
- Always send `current` on each poll.
- Use `radius_m` 500–800 so you only show incidents on the route.
- Treat `age_seconds` over a few minutes as "fading"; items vanish at TTL.
- A device that only consumes still works **if** other users feed the area; for
  guaranteed coverage, run the contributor loop on the driver's own route.
- Be gentle: ~one WebView reload every 2–3 s is plenty. Don't hammer Waze.

---

## 8. Errors

| status | meaning |
|--------|---------|
| `401` | Missing/invalid `X-API-Key`. |
| `404` | Unknown/expired `route_id` — re-`POST /route`. |
| `422` | Bad body (e.g. empty `coordinates`, malformed JSON). |

---

## 9. Quick test (curl)

```bash
KEY=3cfda11c2aefa41a27359bc3fad2bebae6a6a58c641080eb
BASE=https://waze.sandagg.fr/api

# 1) a device ingests a captured georss payload
curl -X POST $BASE/ingest -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"georss":{"alerts":[{"id":"x1","type":"POLICE","subtype":"POLICE_VISIBLE","location":{"x":2.3553,"y":48.8809},"nThumbsUp":4,"pubMillis":1780860000000}],"jams":[]}}'

# 2) a GPS asks for incidents along its route
curl -X POST $BASE/route -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"coordinates":[[48.8809,2.3553],[48.8430,2.3760]],"current":[48.8809,2.3553]}'

# 3) incidents around a point
curl "$BASE/incidents?lat=48.8809&lon=2.3553&radius_km=1" -H "X-API-Key: $KEY"
```

---

## 10. Notes & limits

- **Data only exists where devices have scraped recently.** Coverage is
  crowd-sourced; a lone user gets coverage by running the contributor loop on
  their own route.
- georss is capped at ~10 items per ~1 km window — that's a Waze limit, handled
  by scanning multiple windows along the route.
- The server keeps no browser in device-ingest mode (`WAZE_SCAN=0`); it's a pure
  aggregator. (It can optionally also scrape server-side if a residential proxy
  is configured — not needed for the on-device model.)
- Respect Waze's Terms of Service for your usage.
```
