Every endpoint the dashboard talks to, and the rules they share. Summarised in the README.
Responses are gzip-encoded when the client sends Accept-Encoding: gzip and the
body is at least 1 KiB (smaller ones are sent as-is - gzip's framing can make a
short body bigger, and it already fits in one packet). Every such response
carries Vary: Accept-Encoding. The two streaming downloads, /api/export and
/api/speed/runs.csv, are always sent uncompressed so they keep streaming at
constant memory.
Every POST must carry Content-Type: application/json, including the ones
with no body at all (/api/speedtest, /api/speedtest/abort, /api/netinfo,
/api/iperf/check, /api/speedtest/servers, /api/speedtest/candidates,
/api/auth/logout), which answer 415 without it. It is a CSRF guard: a cross-site form cannot set that content
type without a preflight this daemon never grants. So
curl -X POST -H 'Content-Type: application/json' http://127.0.0.1:9000/api/speedtest,
and -d '{…}' where a body is listed below.
GET /api/status- current status, uptime, per-family state, targets, latest speed, and live speedtest progress. Every uptime figure ships with its observation coverage (uptime_coverageper window,uptime_custom_coveragefor?upMins=); a coverage of0means the window observed nothing and has no uptime to report, exactly aspingularity_uptime_ratiois then absent. The latest run rides along asspeed(nulluntil the first test completes), andspeed_knownsays whether thatnullis a fact: a poll whose store read failed still answers200with anullspeed, and reportsspeed_known: falseso a client can tell "no run yet" from "could not look" rather than acting on thenull- the dashboard empties its speed panel on the first and leaves it alone on the second. A running speedtest is reported asspeedtest_runningplusspeedtest_run_id(0when idle) - that id is what/api/speedtest/aborttakes, so a stop can name the run it was decided against. A fresh install awaiting first-run consent reportsquick_setup_pending, andaccess_local_onlymirrors the loopback-only access filter (so a client can default the Quick Setup access choice to how the install booted);bridged_containeris present only in a bridged container, where measurements describe the container network rather than the host'sGET /api/series?mins=…[&exclude=…]- latency / online time series (server-side bucketed);excludedrops targets from the lowest-latency line. Also takes an absolute window as?from=&to=(unix seconds, half-open[from, to); omittofor an open end), which wins overmins. Either form reaches at most 366 days back: afromolder than that is silently raised to the floor, and aminsbeyond it is ignored in favour of the endpoint's default window - so a window lying entirely further back than a year returns nothing even where retention kept the rows. The bucket width follows the part of the window that can hold data -[from, min(to, now))- so an omitted or futuretobuckets as if the window ended now rather than coarsening the lotGET /api/events?limit=&offset=- paginated up/down transition (outage) log.limitdefaults to 10 (50 on/api/speed/runs) and is silently capped at 1000, the same ceiling/api/logsuses - page withoffsetand trust thetotalin the body rather than the length of the array you got backPOST /api/outages/delete-{ts}delete one resolved outage (ts= the unix seconds of its closing up event); removes it from the log, heatmap, and uptime stats. IdempotentGET /api/speed?mins=…- speedtest history for the chart, capped at about 1500 points. A window holding fewer runs than that is returned in full; a larger one is thinned by taking an even stride through the runs by position (not by time), always keeping the newest. Every point is a real recorded row, never a derived value - unlike/api/series, which buckets by time and reduces each bucket to one number (the lowest latency measured in it, and the mean DNS time). The stride is positional, so widening a window past 1500 runs re-picks from scratch: the newest run is always kept, but the other points are generally different runs rather than a superset of the narrower window's. The body stays a bare array; the disclosure is in the headers -X-Total-Count(recorded rows in the window: the runs, plus any Best-of round members kept by Discard losers off, which are rows here like any other),X-Returned-CountandX-Sampled(truewhen thinned) - so a client can tell a thinned answer from a complete one. A thinned answer also carriesX-Total-Runs, the runs alone (rows withoutround_ts), for a caveat that compares runs with runs; an unthinned answer has no run count in its headers, so count the rows withoutround_tsin the body. For every run, use/api/speed/runs(paginated) or/api/speed/runs.csv- both cover the whole history rather than a window, so a caller that wants one window filters ontsitself. Also takes an absolute window as?from=&to=(unix seconds, half-open[from, to); omittofor an open end), which wins overminswhen present - with the same 366-day reach as/api/series, so a window entirely older than that comes back empty however long retention keeps the runsGET /api/speed/runs?limit=&offset=- paginated run history (full detail). With Discard losers off, a Best-of round's other measurements are rows of their own here and on/api/speed(the chart plots them like any other run), each carryinground_ts- the winner row'sts; the latest-run endpoints and the digest never list them. Runs that recorded them carryip_family(4/6/mixed, the family the transfer actually used) andudp_direction(down/up, which way the loss/jitter probe sampled); on runs that didn't establish one - and rows predating the fields - the keys are omitted rather than sent emptyGET /api/speed/runs.csv- all runs as CSV. Everything added since the original column set is appended at the END, so consumers indexing existing columns by position keep working. In order, the tail isip_familyandudp_direction(blank = unrecorded);round_of, which on a row measured in a Best-of round that another server won (Discard losers off) carries the winner's timestamp and is blank on a test's own result; and then the same five the runs table's Centre column is built from -win_reason,race_outcome,race_winner_label,race_winner_ms,race_racersPOST /api/speed/runs/delete-{ts}delete one speedtest runGET /api/speed/runs/servers?ts=- the server-selection report for one Ookla run (ts= the run's unix seconds) - every automatic run, challenge run, and pinned run writes one, not only Best-of rounds: every candidate that was ranked, raced, measured, or failed - each with its own numbers, the capacity the round believed, any direction it refused to believe, and the rule that made the winner win (win_reasonon the winner row:fastest_ranked,fallback,incumbent,on_net,challenger,challenger_won,challenger_failed,pinned, and for Best-of roundsscore,favourite(a starred server scored highest),ping_bootstrap,pinned_bestof,pinned_companion- the same value the runs table shows as the muted tag).404only when no such run exists; a run with no report (an iperf3 run, history from before the report existed, an old backup) answers200with an emptyserversarrayGET /api/speed/usage- cumulative data used per windowPOST /api/speedtest/candidates- the field an automatic Ookla run would race right now: every origin's pool (exit router, ISP city, starred servers' cities, the last race's winning city, Ookla's placement), deduplicated and pinged, fastest first, plus the rest of the winning city's field pinged the same way (a Best of above 1 ranks the whole union instead, its pools widened to the count, so every row is then in the field and nothing more is pinged);in_fieldmarks the rows a run would actually choose from, anddistance_kmis re-measured from the winning city for every row that has a coordinate; when Ookla's own placement wins (it has no coordinate to measure from) each row keeps the distance from the origin that listed it, so distances are then not comparable across origins;409while a speedtest is runningPOST /api/speedtest/ping-{ids:[...]}measure the ping to up to twelve Ookla servers by ID, the way the race pings (no transfer), probing each one's upload endpoint on the way - the picker's saved-pane refresh; answers{pings:{id: ms|null}, health:{id: bool}}(healthonly where the probe could tell, so a starred server outside every listing can earn its Unsupported mark);409while a speedtest is runningGET /api/speed/server-pings?ids=- the median of each server's recent ranking pings from this daemon's own runs (no network); a server never ranked is absentPOST /api/speedtest/servers?city=- list Ookla servers (near a city -404when the geocoder knows no such place,502when it could not be asked at all, a distinction the picker reports differently;?id=<ookla id>resolves one server by its Ookla ID instead - identity plusfallback_okwhen its upload endpoint could be probed, no coordinate, and adistance_kmof0that means unknown, because that endpoint reports the caller's position as the server's:404when Ookla answers and knows no such server,502/504when Ookla could not be asked; by default the list is centred where auto last tested, else near you)POST /api/iperf/check?addr=- check that an iperf3 server is reachableGET /api/heatmap?days=365[&tz=Europe/Berlin]- daily downtime, pluswindow_s/observed_son every day it returns (how much of it was in range and how much was actually monitored). The response is sparse: a day earns a row only if it had an outage or was not watched end to end, so a year of clean monitoring comes back as a handful of rows rather than 366. An absent date therefore means "nothing on record", not "watched all day and clean" - history you deleted, or that retention pruned, is missing in exactly the same way, so a client must not read observation coverage out of a gap.daysdefaults to 365 and is capped at 366;tztakes an IANA zone name and decides where each day starts (default: the server's own zone), so a client in another zone gets its own calendar days rather than the server'sGET /api/netinfo- connection info (IP/ISP/DNS);POSTforces a full refreshGET|POST /api/settings- read / update live settings. POST is a partial update: fields you omit keep their current value, and only the settings form is reachable here - monitoring (/api/monitoring), access/auth (/api/access), the update check (/api/update) and logging (/api/logs) have no field in it. A value the daemon refuses outright - today, an iperf3 password beginning with the reservedenc:v1:seal prefix - comes back as400with the reason as the body, so it can be shown to whoever typed it; a500here really is a daemon-side failure. Two names are refused outright as well:speed_auto_locandspeed_auto_labelscoped automatic server selection to one city in older releases and are not settings any more, so a non-empty value for either is a400naming what decides selection now (a pinnedspeed_server_id, else the raced cities, which the servers you star inspeed_serversenter) rather than a200that would change nothing. Sending them empty is still fine - that asks for the state the daemon is in. Rows an older release stored are left alone: they are still in an export, and a daemon that still reads them finds them. A schedule (sched_lat_*,sched_speed_*) switched on with no window at all could never be on, so it is saved as off and the response says so; a window that selects no weekday is saved as sent, and a schedule left with only such windows parks its feature - latency probing stops, no automatic speedtest fires - which the daemon says on stdout at every bootGET|POST /api/access- read / update access controls (local-only, auth, password); once auth is active, any change must carrycurrent_passwordPOST /api/auth/login/POST /api/auth/logout- session login / logout. The session cookie lasts 30 days, and a logout revokes every signed-in browser rather than just the one that asked (the revocation is persisted, so it survives a restart) - which is also how you evict a lost laptopPOST /api/speedtest- run a speedtest nowPOST /api/speedtest/abort[?run=…]- stop a speedtest in flight. A bare POST stops whatever is running when it arrives;run=(thespeedtest_run_idfrom/api/status) stops only that run, and is recommended for any client that might be delayed - a stop decided seconds ago would otherwise kill a run that started in between.204= stopped,409= nothing matching to stop (idle, or that run already ended),400=runwas not a run id. A best-of-N run that has already measured a server keeps that result; an abort before the first result stores nothing.204means the run was released, not that the traffic stopped: the Ookla engine's transfer does not observe cancellation, so it keeps moving bytes until its own 15-second capture window closes. Those bytes are still counted against your data usage, and a fresh run started immediately afterwards waits (up to ~20s) for the abandoned one to go quiet rather than measuring through itGET|POST /api/monitoring- read / set{enabled}master start/stop (the power toggle)POST /api/quick-setup- apply the first-run Quick Setup answer in ONE transaction (speedtest cadence, network access, update check, and an optional login) and mark it answered so the dialog never returns;{dismiss:true}marks it answered without changing anything else (once a login is active, dismissing requires that login too).auth_enabledmust agree with whether apasswordis sent. Two things refuse a full answer with403: a login already being configured (change access under Settings then), and the first-run window having closed - the offer lasts 48h from first launch, and once it lapses this endpoint stops accepting full answers just as/api/statusstops advertising it.{dismiss:true}keeps working past that point, because all it writes is the answered marker a lapsed window already implies, and a dialog left open on a stale tab should always be able to close itself. Fresh installs only; the offer isquick_setup_pendingin/api/statusGET|POST /api/update- update-check status / toggle the daily release pollGET|POST /api/logs- the About-tab log viewer: read recent lines (or?download=1for a text file, still the complete buffer - add&masked=1for the PII-masked form, which is the one to attach to a bug report; the raw form is the default here, since the redaction setting is a display choice) / set log level, PII redaction, or clear the buffer. A bare read returns the newest 500 lines;?limit=asks for a different window (capped at 1000,?limit=0for the whole buffer). Responses carrylimit(the cap applied) andbuffered(lines held), so a short answer can be told from a complete one, plusepoch,first_seq,next_seqanddropped, so a poller can pass?since=<next_seq>&epoch=<epoch>and be sent only what has arrived since -sinceis ignored unlessepochmatches, because a restart reseeds the buffer and re-uses the same sequence numbers for different linesPOST /api/data/delete-{type: latency|speed|downtime}clear that dataGET /api/export?config=1&latency=1&speed=1&downtime=1/POST /api/import- export / import config + history. Pick at least one of those four categories (any non-empty value selects one); with none at all the export is a400. (JSON; export streams a single consistent snapshot with a small manifest; import streams in bounded batches and is not atomic - a mid-file error leaves earlier categories applied and returns{partial:true, committed:{…}}. Import puts no cap on the total request (a default install's own export outgrows any fixed one) and bounds the pieces instead: 8 MiB per record, 256 MiB per JSON element (413), 8 MiB per batch held in memory. In a file from Pingularity's own exporter, config is applied last, so a data failure can't half-change your settings; a hand-built or third-party file is applied in its key order, so putconfiglast yourself. A restore that arrives once the daemon has begun shutting down is refused with503rather than half-applied; one already in flight holds the shutdown open until the login/access repairs that follow it have finished)POST /api/notify/test-{url}send a test alert to a webhookPOST /api/notify/heartbeat/test-{url}check in to a heartbeat URL. There is no dry run, so this counts as a real check-in and resets the watchdog's countdown
All endpoints are unauthenticated by default; what protects a fresh install - native or container - is the Network access filter starting off (localhost only). Once you open network access for other devices or Prometheus, every device on the LAN can use every endpoint - the Access tab's login (cookie for browsers, HTTP Basic for API/Prometheus) is the fix if that LAN isn't fully trusted. Either way, the dashboard speaks plain HTTP: for exposure over untrusted networks, still front it with a TLS reverse proxy. The webhook test posts to a URL you supply, so treat access to the dashboard as access to that capability.
DNS-rebinding protection is always on: requests whose
Hostheader is a public domain are refused (403), which stops malicious web pages from using a local browser as a proxy into the API. IP addresses,localhost, dotless LAN names (plex:9000), and.local/.lan/.home/.internal/.home.arpaall work without configuration. Serving the dashboard behind a reverse proxy on a real domain? Set-allow-host=ping.example.com(comma-separate several) and have the proxy preserve theHostheader - it must reach Pingularity as the public domain so the rebinding guard can vet it and the session cookie is markedSecure. Add-trusted-proxywith the proxy's address so the login rate limiter keys on the real client instead of the proxy.Secrets at rest: the database stores the login password hash and webhook/heartbeat URLs - so Pingularity creates its data directory
0700and the database file0600(owner-only). Keep it that way if you relocate the DB with-db.Legacy Docker volumes: the database file is always owner-only, but a named volume created by an older image may have a group/world-readable directory root (Docker's volume copy-up loosens it). A volume first created by a current image is recognized as the daemon's own - its path, its owner, and a marker file the image plants - and re-tightened to
0700at boot automatically. One created by an older image carries no marker, and Pingularity won't silently re-lock a directory it can't prove is its own - so it logs a one-line notice on start instead. The data is already private; to clear the notice, tighten the directory once from any container that can reach the volume (the default image has no shell todocker execinto):docker run --rm -v pingularity-data:/data debian:13-slim chmod 700 /data.The full picture - what the trust boundary is, what privilege each install channel runs with, what the defaults protect and how to deploy it safely - is in docs/security-model.md. To report a vulnerability, see SECURITY.md.