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 · Machine spec: /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:

{ "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, required400 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:

{ "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.