HTTP API Reference

REST API served over HTTPS on port 8443. Used by the React, iOS, and macOS clients to monitor and control the server.

Endpoints Summary

EndpointMethodPurpose
/api/healthGETLightweight health check (no auth required)
/api/statusGETService status, tempo, client list
/api/service/controlPOSTStart/stop services
/api/apPOSTSwitch WiFi between client and access-point mode
/api/tempoGETCurrent tempo and time reference
/api/tempo/manualPOSTSet the manual (operator-chosen) BPM
/api/programGET/POSTGet/set LED program
/api/logGETServer log tail
/api/devicesGETConnected device list with IPs and last seen time

All POST endpoints validate request body size (max 4 KB) and required JSON fields. When --api-token is set, all endpoints require Authorization: Bearer <token>.

— All responses are JSON with Content-Type: text/json; charset=utf-8.

Authentication

When the server is started with --api-token, every request must include a Bearer token:

Authorization: Bearer <token>

Unauthenticated requests receive a 401 Unauthorized response:

{ "error": "Unauthorized" }

Rate Limiting

POST endpoints are rate-limited to 60 requests per 10-second sliding window. Exceeding the limit returns 429 Too Many Requests:

{ "error": "Too many requests" }

Request Limits

All POST endpoints reject request bodies larger than 4 KB with a 400 Bad Request response.


Endpoints

GET /api/health

Lightweight health check endpoint. Returns a fixed response with no authentication required. Used by clients to check server reachability.

Response 200 OK

{ "status": "ok" }

GET /api/status

Returns the server health status, service states, current tempo, and connected device count.

Response 200 OK

{
  "message": "It's all good!",
  "status": {
    "beat-detector": true,
    "manual-bpm": false,
    "udp-server": false,
    "tempo-broadcaster": false
  },
  "tempo": 120.5,
  "manualBpm": 120.0,
  "deviceCount": 3,
  "uptime_us": 9876543210
}
FieldTypeDescription
messagestringStatus message
statusobjectMap of service ID to running state (boolean)
temponumberCurrent tempo in BPM (from whichever tempo source is active)
manualBpmnumberOperator-chosen BPM used by the manual-bpm service
deviceCountnumberNumber of connected Pico W devices
uptime_usnumberMicroseconds since the server process started (time since last restart)

POST /api/service/control

Start or stop a server service.

Request body

{
  "id": "beat-detector",
  "status": true
}
FieldTypeRequiredDescription
idstringYesService identifier (beat-detector, manual-bpm, tempo-broadcaster, udp-server, http-server)
statusbooleanYestrue to start, false to stop

beat-detector (audio beat tracking) and manual-bpm (the software metronome) are mutually exclusive tempo sources: starting one automatically stops the other so only one beat stream reaches the fleet. Set the manual BPM value via POST /api/tempo/manual before or after enabling manual-bpm.

Response 200 OK

{
  "status": true
}

Error responses

StatusCondition
400 Bad RequestMissing id or status field, body too large, or invalid JSON
404 Not FoundService ID not recognized
429 Too Many RequestsRate limit exceeded

POST /api/ap

Switch the Raspberry Pi’s WiFi between client mode and access-point (hotspot) mode by shelling out to scripts/deploy/ap-mode.sh. Only meaningful on the Pi deployment (it drives NetworkManager via the network-control polkit grant the deploy installs).

The Pi has a single radio, so AP and client modes are mutually exclusive. Switching to AP mode tears down the WiFi link this request arrived over — the client must rejoin the Beatled hotspot and reconnect at https://192.168.4.1:8443/ to see anything further. The server itself keeps running.

Request body

{
  "mode": "on",
  "revertMinutes": 10
}
FieldTypeRequiredDescription
modestringYeson (activate hotspot), off (reconnect to WiFi), or status
revertMinutesintegerNoOnly with mode: "on". Auto-switch back to WiFi after N minutes (1–1440). A safety net so a bad switch can’t strand a headless Pi. Omit or 0 to stay on AP until switched back.

Response 200 OK

For status:

{
  "ap": "on"
}

For on / off:

{
  "result": "ok",
  "mode": "on",
  "output": "Auto-revert to WiFi scheduled in 10 min.\nActivating AP 'beatled-hotspot' (this drops upstream WiFi)..."
}

Note: when mode is on, the response may never reach the caller because the radio switches mid-request. The action still completes on the Pi.

Error responses

StatusCondition
400 Bad RequestMissing/invalid mode, revertMinutes out of range, body too large, or invalid JSON
500 Internal Server Errorap-mode.sh exited non-zero (response includes exitCode and output)
429 Too Many RequestsRate limit exceeded

GET /api/tempo

Returns the current tempo and beat time reference.

Response 200 OK

{
  "tempo": 128.0,
  "time_ref": 1707900000000000,
  "manualBpm": 120.0
}
FieldTypeDescription
temponumberCurrent tempo in BPM
time_refnumberBeat reference timestamp in microseconds since epoch
manualBpmnumberOperator-chosen BPM used by the manual-bpm service

POST /api/tempo/manual

Set the manual BPM used by the manual-bpm metronome service. This stores the value (it persists across the service being toggled off and on); it does not start the service — enable manual-bpm via POST /api/service/control, which also stops the audio beat detector. If manual-bpm is already running the new rate takes effect on the next beat.

Request body

{
  "bpm": 128.0
}
FieldTypeRequiredDescription
bpmnumberYesTarget tempo in BPM, in the range 20–400

Response 200 OK

{
  "manualBpm": 128.0
}

Error responses

StatusCondition
400 Bad RequestMissing/non-numeric bpm, value outside 20–400, body too large, or invalid JSON
429 Too Many RequestsRate limit exceeded

GET /api/program

Returns the active LED program and the list of available programs.

Response 200 OK

{
  "message": "Current program is 2",
  "programId": 2,
  "programs": [
    { "name": "Snakes!", "id": 0 },
    { "name": "Random data", "id": 1 },
    { "name": "Sparkles", "id": 2 },
    { "name": "Greys", "id": 3 },
    { "name": "Drops", "id": 4 },
    { "name": "Solid!", "id": 5 },
    { "name": "Fade", "id": 6 },
    { "name": "Fade Colors", "id": 7 },
    { "name": "Off", "id": 8 }
  ]
}
FieldTypeDescription
messagestringHuman-readable status
programIdnumberActive program ID
programsarrayAvailable LED programs with name and id

POST /api/program

Set the active LED program. The program change is broadcast to all connected Pico W devices.

Request body

{
  "programId": 3
}
FieldTypeRequiredDescription
programIdnumberYesProgram ID (0-8)

Response 200 OK

{
  "message": "Updated program to 3"
}

Error responses

StatusCondition
400 Bad RequestMissing programId field, body too large, or invalid JSON
429 Too Many RequestsRate limit exceeded

GET /api/log

Returns the server log tail as a JSON array.

Response 200 OK

[
  "2026-02-14 12:00:01 [info] Beat detected at 128.0 BPM",
  "2026-02-14 12:00:02 [info] Client registered: 192.168.1.42"
]

GET /api/devices

Returns the list of connected Pico W devices.

Response 200 OK

{
  "devices": [
    {
      "client_id": 1,
      "board_id": "E6614103E72B6A2F",
      "ip_address": "192.168.1.42",
      "last_status_time": 1707900120000000,
      "port_name": "pico-freertos",
      "git_sha": "1a2b3c4-dirty",
      "build_time_us": 1707800000000000,
      "owd_us": 1500,
      "qos": {
        "current_offset_us": -42,
        "uptime_us": 9999,
        "median_rtt_us": 1234,
        "next_beat_gap_total": 3,
        "intercore_drop_total": 0,
        "time_sync_outlier_total": 5,
        "valid_sample_count": 8,
        "last_applied_program_seq": 7,
        "server_received_at_us": 1707900120000000,
        "last_rtt_us": 555
      }
    }
  ],
  "count": 1
}
FieldTypeDescription
devicesarrayConnected device objects
devices[].client_idnumberServer-assigned client ID
devices[].board_idstringPico W unique board identifier (hex)
devices[].ip_addressstringDevice IP address
devices[].last_status_timenumberWall-clock timestamp (microseconds) of the most recent HELLO or TEMPO_REQUEST received from this device
devices[].port_namestringFirmware port: pico, pico-freertos, posix, posix-freertos, esp32, or unknown. Empty for v2-or-older firmware.
devices[].git_shastringShort Git SHA of the firmware build (possibly with -dirty). Empty for pre-v3 clients.
devices[].build_time_usnumberUnix epoch microseconds of the firmware build. 0 for pre-v3 clients.
devices[].owd_usnumberServer-smoothed one-way delay estimate (EWMA over the controller’s reported owd_us_estimate). Diagnostic only — beat timestamps are not delay-compensated.
devices[].qosobject | nullProtocol v4 diagnostic snapshot. null until the device has sent its first TEMPO_REQUEST or STATUS_RESPONSE. Fields: current_offset_us, uptime_us, median_rtt_us, next_beat_gap_total, intercore_drop_total, time_sync_outlier_total, valid_sample_count, last_applied_program_seq, server_received_at_us, last_rtt_us, sync_error_us (server-side estimate of this device’s clock-sync error: current_offset_us - ((server_received_at_us - rtt/2) - uptime_us); null until an RTT sample exists).
countnumberTotal connected devices

GET /api/qos

Fleet-wide aggregates over the controllers’ v4 QoS snapshots. Useful for the React Fleet QoS card and any external observability tooling.

{
  "device_count": 2,
  "reporting_count": 2,
  "min_offset_us": -42,
  "max_offset_us": 17,
  "fleet_skew_us": 59,
  "mean_rtt_us": 1234,
  "min_rtt_us": 1100,
  "max_rtt_us": 1400,
  "slowest_device_board_id": "E6614103E72B6A2F",
  "total_next_beat_gap": 3,
  "total_intercore_drops": 0,
  "total_time_sync_outliers": 5,
  "thresholds": { "skew_warn_us": 5000, "skew_fail_us": 20000 },
  "health": "ok"
}
FieldTypeDescription
device_countnumberTotal registered controllers
reporting_countnumberNumber of controllers that have sent at least one v4 QoS snapshot
min_offset_us / max_offset_usnumber | nullLowest / highest reported controller-side server-time-offset. Raw diagnostic — dominated by each device’s boot epoch, so not comparable across devices.
fleet_skew_usnumber | nullSpread of per-device sync_error_us (max - min); approximates the worst-case beat skew across the fleet and drives the health pip. null until at least one device has a computable sync error.
min_rtt_us / mean_rtt_us / max_rtt_usnumber | nullAggregate over each controller’s median_rtt_us
slowest_device_board_idstringboard_id of the device whose median_rtt_us is the largest
total_next_beat_gapnumberSum of next_beat_gap_total across all reporting devices
total_intercore_dropsnumberSum of intercore_drop_total
total_time_sync_outliersnumberSum of time_sync_outlier_total
thresholdsobjectCurrent values of --qos-skew-warn-us and --qos-skew-fail-us
healthstring"ok", "warn", "fail", or "unknown" (zero reporting devices). Computed server-side from fleet_skew_us vs. thresholds plus the drop / outlier totals — any non-zero forces fail.

CORS

When the server is started with --cors-origin, all API responses include:

Access-Control-Allow-Origin: <origin>
Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Authorization

OPTIONS and HEAD requests to any /api/* path return 200 OK for preflight support.


Error Format

All error responses use a consistent JSON format:

{
  "error": "Description of the error"
}