# SurfScore API

# SurfScore External API — Specification

**Base URL:** `https://api.surfscore.live` · **Version:** v1.1 (2026-07-21 — CSV export removed; deterministic 2dp precision contract) · **Interactive docs:** [`/v1/docs`](https://api.surfscore.live/v1/docs) · **Machine spec:** [`/v1/openapi.json`](https://api.surfscore.live/v1/openapi.json)

Read-only B2B access to SurfScore session, telemetry, sensor, and coverage data, plus webhook subscriptions for session lifecycle events.


---

## Authentication

Every request (except `/health`, `/v1/docs`, `/v1/openapi.json`) requires:

```
Authorization: Bearer ss_live_<43 base64url characters>
```

API keys are per-organization, issued and revoked by org admins in **surfscore.live → account menu → API Keys**. The secret is shown exactly once at creation. Keys carry **scopes** (below); requests to an endpoint without the required scope return `403`. Access requires the `api_access` entitlement and an active subscription — a lapsed subscription returns `403 subscription-inactive` within 60 seconds.

| Scope | Grants |
|-------|--------|
| `read:sessions` | Session listing, detail, summary |
| `read:telemetry` | Telemetry (all formats), timeline |
| `read:reports` | Heatmaps |
| `read:scans` | Sensor endpoints (QSCAN-derived data) |
| `manage:webhooks` | Webhook endpoint CRUD |
| `stream` | Reserved for the streaming add-on (Phase 2 Wave 2, not yet live) |

## Response envelope, attribution, errors

Successful JSON responses are wrapped:

```json
{ "data": { }, "attribution": { "text": "SurfScore(TM) - Waveriders Collective Inc.", "url": "https://surfscore.live" }, "meta": { "request_id": "<uuid>", "score_version": "urs-v1" } }
```

**Attribution is required on redistribution** — SurfScore is a Waveriders Collective Inc. trademark. Derived scores always carry a `score_version` (`urs-v1` for telemetry-domain URS; `surfscore-v1` for scan-domain sensor rollups).

Errors are RFC 9457 `application/problem+json` with `type`, `title`, `status`, `detail`, `instance`. Every response carries `X-Request-Id`.

## Rate limits & caching

300 requests/minute per key, reported via `RateLimit-Limit` / `RateLimit-Remaining` headers. Completed-session resources return `ETag` + `Cache-Control: private, max-age=3600`; lists and live-ish resources are `no-store`.

## Pagination

Keyset cursors: pass `limit` (≤1000) and the opaque `cursor` from the previous response's `next_cursor` (`null` = end). Telemetry also supports `since` (ISO-8601) for incremental pulls.


---

## Endpoints

### Sessions — scope `read:sessions`

`**GET /v1/sessions**` — list sessions, newest first. Query: `mode` (`walk_test`|`monitor`), `status` (`calibrating`|`active`|`completed`), `from`, `to` (ISO-8601, on `started_at`), `limit`, `cursor`. Returns `sessions[]` (`id`, `session_mode`, `status`, `source_type`, `location_type`, `floorplan_id`, `outdoor_map_id`, `device_label`, `started_at`, `ended_at`) + `next_cursor`.

`**GET /v1/sessions/{id}**` — one session (same fields). `404` if not found or not your org's.

`**GET /v1/sessions/{id}/summary**` — whole-session rollup: `row_count`, `device_count`, `duration_seconds`, `rsrp_avg`/`_p50`, `rsrq_avg`/`_p50`, `sinr_avg`/`_p50`, `surf_score_avg`/`_p50`, `carriers[]`, `bands[]`. Active sessions return a partial (so-far) summary.

### Telemetry — scope `read:telemetry`

`**GET /v1/sessions/{id}/telemetry**` — the per-point record. Two formats via `?format=`:

| Format | Behavior |
|--------|----------|
| `json` (default) | Paginated envelope (`limit`/`cursor`/`since`), rows below |
| `geojson` | RFC 7946 `FeatureCollection` of Point features (lat/lng rows; indoor x/y-only rows omitted, count noted); same pagination; attribution/meta as foreign members; drops into MapLibre/Mapbox/Leaflet |

> **Removed 2026-07-21:** the `format=csv` whole-session download was withdrawn (WAV-137, before any consumer adopted it). Pull paginated JSON and serialize client-side if you need CSV.

Row fields (JSON/GeoJSON properties; each row also carries `id`): `device_id, modem_id, recorded_at, x, y, lat, lng, heading, speed, accuracy_m, pci, rsrp, rsrq, sinr, rssi, band, bandwidth, protocol, mcc, mnc, carrier, jitter_ms, rtt_ms, packet_loss_pct, ttfb_ms, dns_ms, dl_mbps, ul_mbps, urs, temp_c, fan_speed_rpm, tx_power, location_status, signal_bars, earfcn, nrarfcn, wifi_rssi, wifi_channel, wifi_standard, wifi_vendor, interface_type, surf_score`

Units: dBm (`rsrp`, `rssi`), dB (`rsrq`, `sinr`), ms, Mbps, °C, meters. `urs` = raw device-reported score; `surf_score` = server-computed canonical score (`score_version: urs-v1`).

**Precision contract (aggregated endpoints — summary, timeline, heatmap):** dB/dBm metrics and SurfScore averages at **2 decimal places**; throughput (Mbps) and capacity (MHz) at **2 dp**; latency metrics (`rtt_ms` etc.) as **whole milliseconds**; coordinates untouched. Aggregate values and array ordering are deterministic — identical requests return byte-identical payloads, safe for diffing/caching.

`**GET /v1/sessions/{id}/timeline**` — pre-aggregated per-`(bucket, device)` stats. Query: `bucket` (seconds, ≥10, default 60), `from`, `to`. Window capped at 5,000 buckets (`400` over cap). Buckets: `bucket_start`, `device_id`, `sample_count`, `rsrp_avg/min/max`, `rsrq_avg`, `sinr_avg`, `surf_score_avg`, `rtt_ms_avg`, `dl_mbps_avg`, `ul_mbps_avg`.

### Heatmaps — scope `read:reports`

`**GET /v1/maps/{id}/heatmap**` — per-cell RF grid over **completed** sessions. Query (required): `kind` = `floorplan`|`outdoor`; `session_ids` = comma-separated UUIDs (`400` if missing). Optional `cell` = cell size (px for floorplan, default 16; meters for outdoor, default 10). Returns `cells[]` (`cell_x/cell_y` or `cell_lat/cell_lng`, `sample_count`, `avg_rsrp`, `avg_sinr`, `avg_rsrq`, `avg_bandwidth`) + `facets[]` (per device/modem/operator/band sample counts).

### Sensors — scope `read:scans`

`**GET /v1/sessions/{id}/sensors**` — per-station, per-operator LTE/NR SurfScore rollups (`score_version: surfscore-v1`) for the sensor devices bound to a monitor session, aggregated over each binding's window. Works for completed and active sessions.

`**GET /v1/sensors/{deviceId}/timeseries**` — per-operator (with per-band drill-down) score series for one sensor device. Query: `start`, `end` (default last 7 days), `bucket` (minutes, ≥5, default 60; 5,000-bucket cap).

### SurfScan scan data — scopes `read:scans` / `read:reports`

`**GET /v1/scans**` — scope `read:scans`. List your org's QSCAN scan sessions, newest first. Query: `from`, `to`, `limit` (≤1000), `cursor`. Returns `scans[]` (`scan_id`, `device_id`, `device_nickname`, `start_time`, `end_time`, `scan_mode`, `total_networks`, `notes`) + `next_cursor`.

`**GET /v1/coverage/hexes**` — scope `read:scans`. Org-wide H3 resolution-9 coverage rollup. Query: `device_ids` (CSV, default all), `from`, `to`. Per hex: `h3_cell_9`, `latitude`/`longitude`, `surfscore` (`score_version: surfscore-v1`), `scan_count`, `session_count`, `operator_count`. Data-quality corrections (mirrored-PLMN dedup, noise-operator exclusion) applied server-side.

`**GET /v1/reports/operator-compare**` — scope `read:reports`. Per-operator LTE/NR SurfScore and deployed-capacity comparison over selected hexes. Query: `hex_ids` (CSV of `h3_cell_9`, **required** — `400` if missing), `session_ids` (CSV, optional). Returns per operator: `operator_surfscore`, `lte`/`nr`/`total_capacity` (MHz), LTE/NR scores, carrier/band counts, and full per-carrier detail. Capacity output applies the serving-grade filter (RSRP ≥ −85), CBRS exclusion, and mirrored-PLMN dedup.

`**GET /v1/reports/expert**` — scope `read:reports`. Expert report: eNB/tower-grouped carrier detail over selected hexes. Query: `hex_ids` (CSV, **required**), `session_ids` (CSV, optional). Per tower: `enb_id`, `operator`, `band_list`, and full per-carrier detail (band, arfcn, rsrp/rsrq, bandwidth/display_bandwidth, frequency, duplex_type, surfscore, scan_count). **Raw fidelity:** unlike operator-compare, NO serving-grade filter, CBRS exclusion, or mirrored-PLMN dedup is applied — this is raw material for your own reports; apply your own filtering.

### Webhooks — scope `manage:webhooks`

`**POST /v1/webhooks**` — register an HTTPS endpoint. Body: `{"url": "https://<your-endpoint>", "events": ["session.started", "session.completed"]}` (`events` optional, defaults to both). Returns the endpoint **including** `**secret**` **exactly once** — store it; it is never retrievable again. Max 10 endpoints per org.

`**GET /v1/webhooks**` — list endpoints (never includes secrets): `id`, `url`, `events`, `active`, `created_at`, `last_success_at`, `last_failure_at`, `consecutive_failures`.

`**DELETE /v1/webhooks/{id}**` — remove; pending deliveries are discarded. `204`.

**Delivery:** on session start/complete we POST:

```json
{ "event": "session.completed", "session": { "id": "<uuid>", "session_mode": "walk_test", "status": "completed", "tenant_id": "<uuid>", "started_at": "<ISO-8601>", "ended_at": "<ISO-8601 or null>" } }
```

Headers: `X-Surfscore-Event`, `X-Surfscore-Delivery` (stable UUID across retries — deduplicate on it; delivery is at-least-once), `X-Surfscore-Signature: t=<unix>,v1=<hex>`. Respond 2xx within 10 s. Failures retry at 1m, 5m, 30m, 2h, 8h (6 attempts total), then drop.

**Signature verification:** compute `HMAC-SHA256(secret, "<t>" + "." + <raw body bytes>)`, hex-encode, constant-time compare with `v1`; reject if `|now − t| > 300 s`. Verify against the raw bytes exactly as received — re-serializing the JSON will not match.


---

## Operational notes

* All timestamps are ISO-8601 UTC. All IDs are UUIDs unless noted (`deviceId` is a device string).
* A `404` means not-found **or** not-your-organization — resources are strictly tenant-isolated.
* SSE live streaming (`stream` scope) is the next phase and not yet available; poll `/telemetry?since=` for near-real-time (≈10–15 s freshness) today.

*SurfScore™ is a trademark of Waveriders Collective Inc. API responses must retain attribution when redistributed.*