HTTP & WebSocket API
Everything the UI does is backed by a versioned JSON API under /api/v2. It’s handy for dashboards, scripts, and home-automation pulls. Almost every endpoint is a read-only GET; the exceptions are the nineteen token-gated endpoints — eleven writes and eight reads — under Changing a station.
Base URL in the examples is
http://localhost:8502. Adjust for your host, and remember any reverse-proxy auth you’ve added.
Auth: the built-in session sign-in gates the
/admin*UI routes, the/station/<tab>management pages and the page actions that change something — none of which live under/api/v2. Every read endpoint under/api/v2/*, the WebSocket stream, and the health check are open to anyone who can reach the port — restrict them at the network layer (VPN / proxy allow-list) if that matters, or turn on private mode, after which every read endpoint and both WebSockets answer401without a session (the health check, and/api/v2/metricswhenmetricsis carved out, stay open).The write endpoints, and the eight token-gated reads, are the exception and do not follow that rule: each needs
Authorization: Bearer <token>, and a station with noBNB_API_TOKENanswers404to all of them. See Changing a station.
OpenAPI: a machine-readable OpenAPI 3.1 description of the JSON API is served at
GET /api/v2/openapi.json(and committed atcrates/birdnet-web/openapi.json). Load it into Swagger UI, Redoc, Postman, oropenapi-generatorto explore the endpoints and generate clients. It describes every route served under/api/v2, and a test holds it to that:every_route_the_api_serves_is_documentedreads the routes from the router itself (axum’sDebuglisting of every router the server merges: public, admin and token-gated) and fails, naming each, on a served method and path that the document omits;every_documented_operation_is_servedfails on one the document names that no route serves; andthe_document_is_structurally_validchecks that every$refresolves, every path placeholder is a declared path parameter, and every operation has a uniqueoperationId, a summary and a described response. Two things are not in it because they are not under/api/v2: the live audio stream at/stream, and the feeds and share pages. The two WebSockets,/api/v2/ws/detectionsand/api/v2/ws/spectrogram, are described by the status that upgrades them,101, with the messages they carry. The API root is/api/v2exactly, and/api/v2/is a404; an OpenAPI path must begin with/, so the root is the one path item that names its own server (/api, path/v2) rather than being spelled/. The structural check is a small one of our own and is not Redocly’s rule set (redocly.yamlis in the repository root); runredocly lint crates/birdnet-web/openapi.jsonto apply that.
Health & metrics
curl http://localhost:8502/api/v2/health
{
"status": "healthy",
"version": "0.18.0",
"database": "ok",
"analytics": true,
"detection_daemon": "running",
"detection_writes": "accepted",
"detection_silence_secs": 142,
"analysis_queue_depth": 2,
"detection_deadman": "ok",
"classifiers": [
{
"model": "birdnet",
"state": "ok",
"consecutive_failures": 0,
"failure_threshold": 10,
"windows": 48210,
"last_window_at": 1788973195,
"last_success_at": 1788973195,
"error_class": null
}
],
"data_volume": {
"writable": true,
"mount": "intact",
"disk": "ok",
"used_percent": 35.2,
"checked_at": 1788973200
},
"admin_bootstrap": "ok",
"boot_anomalies": [],
"strict": false
}
status is "healthy" (HTTP 200) or "degraded" (HTTP 503 — the database
is unreachable, the last recorded integrity check failed, or the data volume is
not taking writes), so monitoring can alert on the status code alone.
data_volume is the last of a once-a-minute probe of the data directory
("unchecked" before the first): whether a test write succeeded (writable,
with write_error when it did not — a read-only remount after I/O errors and
a card full to the byte both fail it), whether the directory is still its own
mount (mount is intact, vanished, not-a-mount for a directory that was
never a separate filesystem, or unknown), and the df verdict (disk is
ok, low, critical or unknown). writable: false or mount: vanished
is degraded on every reading: the station is running and keeping nothing.
disk: critical or unknown, and admin_bootstrap: failed, are reported as
degraded only under ?strict=1.
analysis_queue_depth is how many raw segments were waiting for analysis
after the daemon’s last sweep (null before it reports). Above 40 the daemon
analyses one segment in two rather than fall further behind, counts the rest
in birdnet_segments_shed_total, and raises the backlog station-health
condition; see the deployment chapter for the policy.
detection_deadman is the detection deadman’s own verdict: tripped while it
has an open quiet episode (no detections for longer than its threshold, or
never, once the station has listened that long), ok otherwise, off when
its threshold is 0. tripped is degraded under ?strict=1, so a monitor
polling the strict endpoint goes red at the moment the notifier fires rather
than reading detection_silence_secs against a threshold of its own.
classifiers is each loaded classifier, primary first (empty until the
daemon has analysed a file). state is failing once a classifier has failed
failure_threshold analysis windows in a row and answered none since, and
ok otherwise; error_class says why the latest failed window failed —
inference_error (the model returned an error) or non_finite_output (it
answered with NaN or infinite scores, which are never recorded). Times are
seconds since the Unix epoch, null for never. A failing classifier is
degraded under ?strict=1 and raises the classifier:<id> station-health
condition. On a station with several classifiers the others keep recording
while one fails, which is why the detection deadman cannot be relied on to
notice.
boot_anomalies is what the boot journal found this start changed since the
last one: db_lost (the database held detections at the last start and holds
none, or is absent, now), db_path_changed, mount_lost (the data directory
was its own mount and is not now — the volume did not mount, and the station
is writing to the disk beneath it), version_rollback, config_reverted (the
configuration file has errors and the station is running on the last one a
start succeeded on), config_rejected (errors and no last-good copy: the
station is running web-only so this report is reachable) and audio_card_moved
(an ALSA device addressed by card index, plughw:1,0, resolves to a different
card than at the last start: the index moved on re-enumeration and the station
is recording from another device; address the card by id). The journal is kept in
the configuration directory, outside the data volume, so a volume that fails
to mount cannot take the memory of the last start with it; before it, such a
start was indistinguishable from a first run. A non-empty list is degraded
under ?strict=1 and raises the boot-anomaly station-health condition.
database is "ok", "unchecked" or "error". It reports the verdict of the
daily maintenance integrity check, not a check run at request time: that
check reads every page of the database file, and this endpoint is polled by the
container health check every 30 seconds. On a multi-year station a per-request
check could not finish inside the health check’s own timeout. "unchecked"
means no verdict is on record yet — normal for the first few minutes after a
fresh install, and reported healthy/200, because “not yet verified” is not
“broken”. A failure stays reported until it is fixed, rather than depending on
which request happened to catch it.
analytics_mirror_failures counts detections the database accepted and the
DuckDB analytics copy refused since the process started; a non-zero value
means the behavioural dashboards are behind until the startup drift check
rebuilds the copy, and raises the analytics-mirror station-health condition
while the failures are recent. detection_daemon is "running" or "stopped" — "stopped" means web-only
mode or an unconfigured model/labels/watch-dir, i.e. the UI is up but nothing is
being analysed. analytics reports whether the DuckDB engine is active.
detection_silence_secs is the end-to-end freshness signal: seconds since the
most recent stored detection (the deadman watchdog’s measurement), or null
before the first measurement / on a station that has never detected anything.
A value that climbs past your expected quiet period means the chain from
microphone to database has stopped producing rows even if every component
looks healthy.
| Endpoint | Purpose |
|---|---|
GET /api/v2/health | JSON liveness/health check (use for monitoring). |
GET /api/v2/health/conditions | What is wrong right now: the station-health conditions (a dead microphone, a full disk, a failing backup, a lost data volume, a boot that lost something) as the notifier last evaluated them, with evaluated_at. These used to be push-only. Always 200; /health carries the verdict. |
GET /api/v2/metrics | Prometheus metrics (text format) — see Integrations. |
GET /api/v2/stats | Summary counts (detections, species, today). |
GET /api/v2/system/disk | Disk usage for the data directory. Answers 503 when the disk is critical (95 % used) and 200 otherwise. Fullness is measured against the space this user can actually reach — used / (used + available), the same figure df’s Use% column reports — not against the raw device size, which on any ext4 with its default 5 % root reserve, or inside a container quota, is larger than anything the station can use. |
Detections
curl 'http://localhost:8502/api/v2/detections/recent?limit=2'
{
"detections": [
{
"date": "2026-05-22", "time": "21:10:31",
"com_name": "Song Sparrow", "sci_name": "Melospiza melodia",
"confidence": 0.83, "cutoff": 0.7,
"lat": 42.3601, "lon": -71.0589,
"week": 21, "sens": 1.25, "overlap": 0.0,
"file_name": "Song_Sparrow-83-2026-05-22-...wav"
}
]
}
| Endpoint | Common query params |
|---|---|
GET /api/v2/detections | date, species (common or scientific name), source, limit, offset — paginated, newest first; the filters combine, and total_count counts every page of the filtered set |
GET /api/v2/detections/recent | limit — most recent first |
GET /api/v2/detections/daily | days — per-day counts over the last N days |
GET /api/v2/detections/export | format (csv or json, default csv), from, to (inclusive YYYY-MM-DD) — the detection history as a download (detections.csv); more than 1 000 000 rows is a 413 that asks you to narrow the range |
GET /api/v2/detections/export/birddb | from, to — BirdNET-Pi’s semicolon-separated BirdDB.txt (BirdDB.txt), the twelve BirdNET-Pi columns and nothing more |
GET /api/v2/detections/export/ebird | date, lat with lon, location, min_confidence, protocol, observers, state, country, complete — eBird Record Format (ebird_export_….csv) |
The rest of the export family (detections/export/raven, the per-clip Raven and Audacity tables) is under Export, and the exports’ columns are described on the Backups page.
Analysis runs
GET /api/v2/analysis-runs?limit= lists the station’s analysis runs, newest first (limit defaults to 50, at most 500), as {"runs": [...]}, and GET /api/v2/analysis-runs/{id} returns one (or 404). A run is one detection-daemon start: it records the SHA-256 of the model and labels it analysed with and the settings in force, and carries detections, the number of rows that name it. A detection row’s run_id points at one of these; the exports resolve it to the model’s name and checksum, and this is where the rest (when the run started, the label count, the geomodel) is read.
Languages
GET /api/v2/languages lists the languages species common names can be shown in, as {"languages": [{"code", "name", "native_name", "active", "coverage"}], "count", "active"}. coverage is {"named", "of"}, how many of the installed classifier’s species the language names, and null for English and when no classifier label file was available to count against. See Species Names in Your Language.
Species
| Endpoint | Common query params | Purpose |
|---|---|---|
GET /api/v2/species/top | limit | Most-detected species with counts |
GET /api/v2/species/search | q, limit | Search by name |
GET /api/v2/species/detail | name | Per-species stats |
GET /api/v2/species/activity | date | Hourly activity (all species) on a given day |
GET /api/v2/species/occurrence | week (1–48), q, admitted (true/false), limit (default 100, at most 1000), offset | What the station’s range (occurrence) filter says about each species for its place and a week: the model’s probability, the threshold, whether the station admits the species and why. See Why isn’t a bird heard here? |
GET /api/v2/species/export | format (csv or json), limit | Per-species totals as a download (species.csv) |
GET /api/v2/species/image/{scientific_name} | — | The image’s metadata (URL, cache status, description) as JSON, or 404; it does not download the picture. Only species the station has detected are looked up |
GET /api/v2/species/image/{scientific_name}/file | — | The cached picture itself (a custom image first, then the provider’s), with a five-minute private cache header on the picture and on the 404 |
GET /api/v2/recordings | species (matched against the file name), limit (default 50, at most 500), offset | List the files in the recordings directory: filename, size_bytes, modified_secs, content_type |
GET /api/v2/recordings/{filename} | — | Stream a recorded clip, honouring Range requests (206); the type follows the extension (WAV, MP3, FLAC, Ogg, AAC) |
GET /api/v2/spectrogram/{filename} | species, confidence, time (the overlay label), thumb=1 (a small bare preview) | A PNG spectrogram of that clip, rendered on demand and cached, in the station’s spectrogram_colormap and spectrogram_range_db; with the raw_spectrogram setting on, never labelled. ETag and Cache-Control: private, no-cache; a matching If-None-Match is 304 |
GET /api/v2/dashboard-image | — | The file named by the dashboard_image setting, as the PNG, JPEG, GIF or WebP it is by its first bytes; 404 for anything else, a missing file or one over 8 MiB. ETag, private, no-cache, 304 on a match |
Why isn’t a bird heard here?
GET /api/v2/species/occurrence answers it with the number behind it. The station asks a range model which species occur around its location in a week of the model’s 48-week year (four weeks to a month) and admits a species when the model scores it at or above the threshold (SF_THRESH, default 0.03), or you put it on the Always allowed list, and your include and exclude lists allow it. The response is every species the model scores, most likely first, each with that decision:
{
"location": { "latitude": 52.5, "longitude": 13.4 },
"week": 22, "week_is_current": false, "threshold": 0.03,
"scored": 6522, "admitted_total": 412, "matched": 1, "limit": 100, "offset": 0,
"species": [{
"scientific_name": "Spiza americana", "common_name": "Dickcissel",
"display_name": "Dickcissel", "probability": 0.0004, "admitted": false,
"verdict": "held-back-by-range",
"explanation": "Held back: the range model gives it <0.01 % here in week 22; this station admits species at 3 % and above."
}]
}
verdict is admitted, admitted-by-whitelist (under the threshold, allowed by the Always allowed list), held-back-by-range (under the threshold) or held-back-by-lists (over it, kept out by your include or exclude list). week defaults to the current week; q matches either name, or the name in the station’s language; admitted=false is the held-back species. A week outside 1–48, an admitted that is not true or false, and a limit outside 1–1000 are a 400 that names the range and does not run the model (the model reads a week it does not know as “no seasonal information” and answers with a year-round list, so the value is refused rather than clamped).
A station that cannot say answers 503 with a reason and no species key, because an empty list would read as “nothing occurs here”: no-location (the station has no coordinates), no-range-model (no range model is loaded: the filter is off and every species the classifier knows is a candidate), range-model-failed (the model is there and would not run; the log has the reason), or no-detection-daemon (this process does not run the detection daemon). The coordinates are rounded to one decimal place (about 11 km), so this endpoint adds no precision of its own; that is not a way of hiding the location, which the public pages already show more exactly (sunrise and sunset times on Today and the Dawn chorus, and each detection’s own latitude and longitude). The same answer is the page at /species/occurrence, linked from the Species page.
Time-series & analytics
These power the charts on the Analytics and time-series pages. The richer ones use the DuckDB analytics engine, which is built in and on by default (Configuration); they return a “not available” status only if you’ve disabled it.
| Endpoint | Purpose |
|---|---|
GET /api/v2/timeseries/daily · weekly · hourly | Counts over time |
GET /api/v2/timeseries/diversity | Species diversity over time |
GET /api/v2/timeseries/trend · year-over-year | Trends and year-on-year comparison |
GET /api/v2/timeseries/peak-windows · gaps · anomalies | Peak activity, quiet gaps, outliers |
GET /api/v2/timeseries/accumulation | Life-list accumulation curve |
GET /api/v2/timeseries/heatmap · sessions | Hour×day heatmap, activity sessions |
GET /api/v2/timeseries/status | Whether the time-series engine is available |
GET /api/v2/analytics/sessions · retention · funnel · next-species · previous-species · patterns | DuckDB behavioral analytics |
GET /api/v2/analytics/funnel-events | The window_funnel_events variant of funnel, with the same parameters (species, a comma-separated sequence of at most 64 names; window; hour_start, hour_end) |
GET /api/v2/analytics/sequence-count · sequence-match-events | The sequence_count and sequence_match_events variants of patterns, with the same parameters (species, max_gap, hour_start, hour_end) |
GET /api/v2/analytics/status | Analytics build flags and the state of the store |
GET /api/v2/insights/dawn-chorus?period_days=&min_days= | Each species’ typical first song of the morning (mean and earliest first detection between 04:00 and 10:00), over species heard on at least min_days (default 3) of the last period_days (default 30, at most 365) days, earliest first. A value that is not a positive integer is a 400; one over the limit is clamped, and both values applied are echoed. |
GET /api/v2/insights/migration | Species first heard in the last 14 days, and species with at least 5 detections and none in the last 14 days, with when and how often. |
GET /api/v2/insights/phantom-species | Species detected at least 3 times in the last 30 days with a mean confidence under 0.6, least confident first — often a noise source the model keeps mistaking for a bird. Rejected detections do not count. |
GET /api/v2/insights/expected-today | Species heard within 3 days either side of today’s date in any of the last 10 years, most regular first, with years_of_data. |
GET /api/v2/insights/expected-today/regional | What eBird observers reported nearby that this station does not usually hear now: the eBird snapshot less the list above. available is false without a snapshot (eBird). |
GET /api/v2/dashboard/kpis | Species ever heard, detections today, the busiest day since this date last year (best_day), and the run of consecutive days with a detection ending today (detection_streak; 0 when today has none). With none, best_day is {"date": "", "count": 0}. |
GET /api/v2/analytics/species/summary?start_date=&end_date=&limit= | Each species’ count, mean and highest confidence, and first and last detection (first_heard, last_heard: RFC 3339 with the offset in force at the time) over the range, most detected first, as a bare array. Either end may be left open; a date that is not a calendar day, or a reversed range, is a 400. |
GET /api/v2/analytics/species/detections/new?start_date=&end_date=&limit=&offset= | Species first heard here within the range (default: the 30 days before today through today), newest first, with count_in_period, as a bare array. limit defaults to 100 (0 also means 100); a limit or offset that is not a non-negative integer is a 400. |
GET /api/v2/analytics/species/daily?date=&min_confidence=&limit= | One day’s species (default today), most detected first and ties by the later last detection, as a bare array. Each has scientific_name, common_name, count, 24 hourly_counts, high_confidence (a detection at 0.8 or more), current_season, and — left out when zero, false or empty — max_confidence, first_heard, latest_heard, thumbnail_url, is_new_species, is_new_this_year, is_new_this_season (first heard here, since 1 January, or since the season began, at most 7 days before), days_since_first_seen, days_this_year, days_this_season and days_since_last_seen (back to the previous day it was heard). All read as of date. Seasons follow the hemisphere: north of 10° spring/summer/fall/winter from 20 March, 21 June, 22 September and 21 December; south of −10° the same names six months on; between, and with no location, wet1/dry1/wet2/dry2 from the 1st of March, June, September and December. min_confidence is a percentage (anything that is not a number is 0); limit defaults to 30. |
GET /api/v2/analytics/sources?start_date=&end_date= | The audio sources with detections in the range (all history when it is open), busiest first, as {"sources": [{"id", "name", "count"}]}. id is the stream id each detection records; name is the name given on the Capture screen, or the id. Rejected detections, and detections from before sources were recorded, are in no count. A bad or reversed range is a 400. |
GET /api/v2/analytics/confidence/distribution?start_date=&end_date=&species=&bins=&limit= | Each species’ confidence distribution: [{"scientificName", "commonName", "bins", "total"}], where bins is the fraction of its detections in each of bins equal-width bins over 0–1 (a bin holds its lower edge; 1.0 is in the last). start_date is required; end_date defaults to 30 days after it. bins defaults to 20 and is held to 5–50. Without species, the limit most detected species (default 5, at most 10; anything else is 5), less those with fewer than 20 detections in the range; with species (scientific or common name, any case), that species alone. |
GET /api/v2/analytics/time/year-over-year?date= | Detections from 1 January to date (default today), cumulatively, against the same span of the year before: {"currentYear", "previousYear", "points": [{"date", "monthDay", "thisYear", "lastYear", "delta"}]}, one point a day. Aligned by calendar date: on 29 February, last year’s figure is its 28 February; last year’s 29 February counts from its 1 March. A date that is not a calendar day is a 400. |
GET /api/v2/analytics/time/dawn-onset?start_date=&end_date=&species= | One row per day of the range — quiet days too — as [{"date", "onsetRelMinutes", "detectionCount"}]. onsetRelMinutes is the day’s third-earliest detection, in minutes after civil dawn (negative before it), and null on a day with fewer than five detections, a day the sun never gets 6° below the horizon, or at a station with no location set. start_date is required; end_date defaults to 30 days after it; species is a scientific or common name. |
GET /api/v2/analytics/time/heatmap?start_date=&end_date=&species=&format= | Detections by day and time of day: {"dates", "slotResolutionMinutes", "cells": {"dateIndex", "slot", "count"}}. dates lists every day of the range; the three cell arrays run in parallel and list only occupied cells, by day and then slot; slot n starts n × slotResolutionMinutes minutes after local midnight. Slots are 15 minutes up to 90 days, 30 up to 180, then 60. format=csv answers the same cells as date,slot,slot_start,count. Range rules as above. |
GET /api/v2/analytics/time/distribution/species?start_date=&end_date=&species=&limit= | Each species’ detections by local hour as fractions of its total: [{"scientificName", "commonName", "buckets", "total"}], 24 buckets from midnight. The limit most detected species (default 5, at most 10; anything else is 5), or those named by a repeated species (?species=A&species=B, scientific or common names, at most 10 — eleven is a 400). Range rules as above. |
GET /api/v2/analytics/time/succession?start_date=&end_date=&species=&limit= | The same species’ hourly detections as counts: [{"scientificName", "commonName", "counts", "total"}]. Default 6 species, at most 10. |
These insights, dashboard and analytics/species endpoints read SQLite directly, so they answer with the analytics engine off. Their parameters, defaults and field names are birdnet-go’s, so a client written for either station reads both. A station whose detections cannot be read answers 500 rather than an empty list.
Diagnosing empty analytics dashboards
GET /api/v2/analytics/status is the first thing to check when the analytics
screens are blank. Its analytics_compiled and analytics_configured fields
describe intent — that the binary has DuckDB in it and a database was wired
up — and both stay true in every case where the dashboards are actually
broken. The store object is the part that differs:
{
"analytics_compiled": true,
"analytics_configured": true,
"store": {
"extension_loaded": true,
"self_test": "ok",
"detections": 412903,
"unplaceable_detections": 3,
"detections_placeable": 412900,
"engine_duckdb_version": "v1.5.6",
"engine_platform": "linux_arm64",
"embedded_extension": {
"version": "v0.10.0",
"duckdb_version": "v1.2.0",
"abi": "C_STRUCT",
"platform": "linux_arm64",
"mismatch": null
}
}
}
extension_loaded: false— the behavioural functions (sessionize,retention,window_funnel,sequence_*) will fail while the time-series screens keep working. Checkembedded_extension.mismatch: a non-null value means the copy built into this binary can never load, which leaves an offline station with no behavioural analytics. Itspropertysays whether theDuckDB version, theplatform, or both disagree — compare againstengine_duckdb_versionandengine_platform. An extension is locked to both, and both fail the same way atLOAD. Howduckdb_versionis read depends onabi: forCPPandC_STRUCT_UNSTABLEit is a DuckDB version that must equal the engine’s; forC_STRUCT(behavioral v0.10.0 and later) it is a C API version, and any engine of the same major at or above it accepts the extension — sov1.2.0against av1.5.6engine is correct. Rebuild againstcommunity-extensions.duckdb.org/<engine_duckdb_version>/<engine_platform>/.self_testother than"ok"whileextension_loadedistrue— the extension loaded but one of its functions answered a fixed check differently from the version this build’s queries were written for; the message names the function. The behavioural cards may show wrong numbers rather than errors, so treat it as broken.birdnet-behavior --verify-extensionruns the same checks.detections: 0against a station with history — the SQLite → DuckDB sync has not run or did not complete.detections: null— the count itself could not be read from the store, which is a store fault rather than a sync that has not run.unplaceable_detectionsabove zero — that many rows carry aDate/Timenaming no point in time (usually from a BirdNET-Pi import). They count toward the station’s detection total but cannot appear in any date- or time-based analytic, which is why a dashboard total can sit below the raw count.store: null— this binary was built without analytics.
WebSocket — live detections
ws://localhost:8502/api/v2/ws/detections
Connect and you receive a JSON message for each new detection as it happens — the same stream that drives the dashboard live feed, the spectrogram and kiosk mode. Behind a reverse proxy, make sure the Upgrade/Connection headers are forwarded (Remote Access).
const ws = new WebSocket("ws://localhost:8502/api/v2/ws/detections");
ws.onmessage = (e) => console.log(JSON.parse(e.data));
Live audio
GET /stream is the live audio as MP3 (?source_id=, ?freq_shift_hz=),
outside /api/v2. The HLS variant uses birdnet-go’s routes and fields, so a
client written for either station reads both:
| Request | Answer |
|---|---|
POST /api/v2/streams/hls/{source_id}/start | {status, source, playlist_url, active_clients, playlist_ready, freq_shift_hz, stream_token} once the playlist lists two segments, or with status: "starting" after 20 s |
GET /api/v2/streams/hls/t/{token}/playlist.m3u8 | the live playlist; its segments are …/t/{token}/seg<n>.ts |
POST /api/v2/streams/hls/heartbeat | {"status": "ok"}; with ?disconnect=true the session leaves |
POST /api/v2/streams/hls/{source_id}/stop | the session in the body leaves; the stream ends with its last |
GET /api/v2/streams/hls/status | {count, streams}, without tokens |
source_id is a source’s id or default. Start, stop and heartbeat take an
optional {"session_id": "…"} body naming the caller — a value of its own
choosing, up to 64 characters — and start and stop take
?freq_shift_hz= as /stream does; each shift is a stream of its own. The
playlist URL is relative to the station’s root, so a station under a base
path serves it beneath that path.
url=$(curl -s -X POST -H 'Content-Type: application/json' \
-d '{"session_id":"cli"}' \
http://localhost:8502/api/v2/streams/hls/default/start | jq -r .playlist_url)
ffplay "http://localhost:8502$url"
The errors are /stream’s: 404 for an unknown source, and 503 when no
source is configured, the source is not recording, all four live-stream slots
are in use, or ffmpeg is not installed.
Errors
An unmatched path under /api/ returns a machine-readable JSON 404 (not the
HTML “page not found” the browser UI serves), so a script that mistypes a route
or hits a removed endpoint sees the failure instead of silently parsing a web
page:
{ "error": "not found", "path": "/api/v2/nope" }
Export
CSV/JSON/eBird export of the full detection history is available from the Backups page (and a BirdNET-Pi-compatible CSV for tooling that expects that format).
For verification tools: GET /api/v2/detections/export/raven?from=&to= is a Raven selection table over every detection with a clip, in BirdNET-Analyzer’s column layout with Begin Path naming the clip; GET /api/v2/recordings/{clip}/raven.txt is one clip’s table and GET /api/v2/recordings/{clip}/labels.txt the same selections as an Audacity label track. All three read the reviewed set, so a rejected detection is in none of them; a clip no detection names is 404. See Export for how each selection is placed.
Changing a station
Ten method-and-path pairs, and they are the only ones in /api/v2 that change
anything.
They exist so Home Assistant, Node-RED or a shell script can act on a station
rather than only read it — before them, every state change in the product was an
HTMX form post returning HTML, which is not a contract anyone can build on.
Eight reads live behind the same token, because what they return is not public:
GET /api/v2/settings (the configuration, credentials taken out),
GET /api/v2/system/capture and GET /api/v2/system/jobs (operational detail
about the station and its sources), GET /api/v2/system/network and
GET /api/v2/system/processes (the network it sits on, and what it is running),
GET /api/v2/system/database/overview (where its database is and what is in
it), GET /api/v2/models/catalog, and GET /api/v2/detections/comments.
Turning them on
They are off by default. Set BNB_API_TOKEN in the config file or the
environment and restart:
openssl rand -base64 48
Until you do, all nineteen answer 404: the write surface does not exist rather
than existing unprotected. Note this is the opposite default from CADDY_PWD,
where an unset password leaves /admin open — an unset token leaves the write
API closed. A token shorter than 32 bytes is refused and leaves the API off,
with a warning in the log and a warning from birdnet-behavior --doctor.
Identifying a detection
There is no surrogate id. A detection is identified the way the database identifies it — by date, time, scientific name and clip:
{ "date": "2026-09-03", "time": "06:12:44", "sci_name": "Erithacus rubecula",
"file_name": "European_Robin-91-2026-09-03-birdnet-06:12:44.wav" }
file_name is the detection’s file_name as GET /api/v2/detections
returns it ("" for a detection with no clip). It is optional, and worth
sending: on a station with more than one microphone or camera, two sources
that hear the same bird in the same second record two detections with the
same date, time and species, and only the clip tells them apart. Without it,
lock and unlock act on every such detection, and delete removes a lone
one but leaves any that are locked. A review applies to all of them either
way: verdicts are still recorded per date, time and species.
A malformed key is 400; a well-formed key matching no row is 404.
The endpoints
| Method | Path | Does |
|---|---|---|
POST | /api/v2/detections/review | Record confirmed / rejected, or clear a verdict by omitting status |
POST | /api/v2/detections/lock | Protect the clip from the disk-full purge and retention sweep |
POST | /api/v2/detections/unlock | Return it to the ordinary purge rules |
POST | /api/v2/detections/delete | Remove the detection row |
POST | /api/v2/detections/batch | Apply one of the four above to up to 500 detections |
GET | /api/v2/detections/comments | Read one detection’s comment thread, oldest first |
POST | /api/v2/detections/comments | Add a comment (1–2000 characters) |
POST | /api/v2/detections/comments/delete | Withdraw one comment by id |
POST | /api/v2/dynamic-thresholds/reset | Forget what the station learned about one species ({"species": "Strix aluco"}) or all of them ({"all": true, "confirm": true}) |
GET | /api/v2/settings | Read every setting, with credentials redacted |
PUT | /api/v2/settings | Change one or more settings |
POST | /api/v2/control/restart | Restart the station |
POST | /api/v2/control/restart-source | Restart one audio source’s capture |
GET | /api/v2/system/capture | Per-source capture health, as Station Health draws it |
GET | /api/v2/system/jobs | Every scheduled maintenance job and its state |
GET | /api/v2/system/network | Network interfaces: addresses with prefix, hardware address, bytes in and out; loopback last |
GET | /api/v2/system/database/overview | The database: engine settings, each table’s rows and size, the last recorded integrity check and VACUUM, mean detection-write latency, and detections in each of the last 24 UTC hours |
GET | /api/v2/system/processes | The station’s process and everything it started (capture ffmpeg, arecord, conversions), CPU sampled over 250 ms as a percentage of one core |
GET | /api/v2/models/catalog | The classifiers this build can install |
curl -X POST http://localhost:8502/api/v2/detections/review \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Erithacus rubecula","status":"confirmed"}'
curl -X POST http://localhost:8502/api/v2/detections/lock \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Erithacus rubecula"}'
Comments
A verdict records what the station decided; a comment records why. Many
comments per detection — that is the difference from a review’s notes, which
is keyed on the detection and replaces whatever was there.
A comment is never edited: a database trigger aborts any attempt to rewrite its text, author or timestamp. It can be withdrawn, which is what a note with a mistake or somebody’s name in it needs; the audit row then carries the id and the author and never the body.
curl -X POST http://localhost:8502/api/v2/detections/comments \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"date":"2026-09-03","time":"06:12:44","sci_name":"Dryobates villosus",
"body":"Call length says Downy, but the spectrogram is Hairy."}'
There is no author field, deliberately. A bearer token is not a person —
the same reason every audit row this API writes has a null user — so comments
written here are attributed to api. A name the caller supplies would be a name
the caller chose; a script with something to say about who is speaking says it
in the body. Comments written from the web UI carry the signed-in username.
curl "http://localhost:8502/api/v2/detections/comments?date=2026-09-03&time=06:12:44&sci_name=Dryobates%20villosus" \
-H "Authorization: Bearer $BNB_API_TOKEN"
Batches
One round trip instead of forty, for the case that actually comes up: triaging a night’s false positives.
curl -X POST http://localhost:8502/api/v2/detections/batch \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"op":"review","status":"rejected","detections":[
{"date":"2026-09-03","time":"02:14:07","sci_name":"Strix aluco"},
{"date":"2026-09-03","time":"02:19:51","sci_name":"Strix aluco"}
]}'
{
"op": "review",
"requested": 2,
"applied": 2,
"failed": 0,
"results": [
{ "detection": "2026-09-03 02:14:07 Strix aluco", "applied": true },
{ "detection": "2026-09-03 02:19:51 Strix aluco", "applied": true }
]
}
op is one of review, lock, unlock, delete, and applies to every
detection in the list. status and notes belong to review and are refused
with any other op: a caller who sent {"op":"delete","status":"confirmed"}
meant something by both fields, and silently dropping one would not say which.
At most 500
detections per request; a longer list is refused before anything is written
rather than quietly truncated.
Read
failed, not just the status code. The response is200whenever the request was well-formed, even if every item failed. A key that matches nothing does not stop the batch — a client working from a list a few seconds stale would otherwise have forty good deletions refused because three rows had already gone — so each detection gets its own entry inresults, andappliedandfailedare at the top level so you need not walk the array.
This is not a transaction. Each detection takes the same path as the single-detection endpoint, which is what keeps the SQLite store and the analytics copy in step; a shared transaction would mean a second implementation of “delete a detection” and is not worth that. If the process dies mid-batch, the detections already applied stay applied.
Every detection actually changed is written to the audit log individually, under the same action name a single-detection call uses — so “what happened to that recording?” is still answerable afterwards. A full batch therefore writes 500 rows, which is exactly what the admin audit view shows in one page before it says “Showing the most recent 500 matches”. That, and the cost of the writes themselves, is what the limit bounds.
Settings
GET /api/v2/settings returns every persisted setting, plus redacted (the
keys whose value was withheld) and writable_keys (what PUT will accept):
curl http://localhost:8502/api/v2/settings \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"settings": { "confidence_threshold": "0.7", "email_smtp_pass": "***REDACTED***" },
"redacted": ["email_smtp_pass"],
"writable_keys": ["alsa_device", "rtsp_url", "…"]
}
Credentials are removed two ways: by key name (email_smtp_pass,
birdweather_token) and by value shape, which catches the credential inside a
URL — apprise_url does not look like a secret and routinely carries one.
A withheld value is replaced rather than omitted, so “you may not read this”
stays distinguishable from “this was never configured”.
The by-shape rules are the support bundle’s, and they go by scheme: an RTSP
URL loses only its password (rtsp://cam:***REDACTED***@camera.local/stream),
an http(s) URL keeps its host and loses its path, because a heartbeat ping, an
Apprise endpoint and a webhook all carry their credential there
(https://hc-ping.com/***REDACTED***), and an Apprise-style notification URL
keeps only its scheme (ntfy://***REDACTED***), because those put tokens where
a URL puts its host. A value holding several URLs has each one redacted. Treat a
by-shape redaction as “the host, roughly”, not as a value you can edit and send
back.
PUT is a partial update; send only the keys you mean to change. Values may be
strings, numbers or booleans, and each goes through the same normalisation the
settings page uses, so "51,5" is stored as 51.5:
curl -X PUT http://localhost:8502/api/v2/settings \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"confidence_threshold": 0.75, "station_name": "Back Garden"}'
{ "updated": 2, "keys": ["confidence_threshold", "station_name"] }
A key already holding the value you sent is not rewritten and is not counted in
updated. Two things are refused with 400 rather than accepted quietly:
- An unknown key. A misspelled
confidence_tresholdanswering200would tell you a change had landed when none had. - The literal
***REDACTED***. It is whatGEThands back for a secret, so a client that reads the whole object, edits one field and writes it back would otherwise overwrite the station’s real SMTP password with the placeholder.
Settings that the running process reads at startup need a restart to take
effect, the same as when they are changed from /admin/settings.
Restarting
curl -X POST http://localhost:8502/api/v2/control/restart \
-H "Authorization: Bearer $BNB_API_TOKEN"
The process sends itself SIGTERM after a short delay — long enough for the
response to reach you — and systemd’s Restart=always starts a fresh instance.
Outside systemd — a bare cargo run, a container without an init — nothing
would bring the station back, so the endpoint answers 503 and signals nothing
rather than reporting a restart that would in fact be a shutdown.
Restarting one source
Restarting the station to recover a single wedged RTSP camera takes every other microphone down with it, and loses the audio in flight. This restarts one:
curl -X POST http://localhost:8502/api/v2/control/restart-source \
-H "Authorization: Bearer $BNB_API_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"source_id": "src_rtsp_1"}'
{
"restart_requested": true,
"source": "src_rtsp_1",
"state": "stalled",
"already_pending": false,
"note": "the supervisor stops and restarts this source on its next tick; every other source keeps recording"
}
202, not 200: nothing has restarted yet when the response is written. The
capture supervisor drains the request on its next reconcile tick, a couple of
seconds later. Asking twice before that happens still restarts the source once —
the second call answers "already_pending": true.
The restart goes through the supervisor’s own start path, so the recording
schedule and the source’s quiet window still hold. A source that is paused
when the request arrives stays paused, and the note says so rather than
reporting a restart that will not happen.
This recovers a wedged source; it does not reload that source’s settings.
The supervisor builds each source’s capture configuration once, when the service
starts, and a restarted process comes back with that same configuration. Editing
a source on /admin/audio still needs a service restart to take effect.
source_id is the supervisor’s label for the source, which is the
audio_sources row id on any station whose sources are managed from
/admin/audio. An unknown label answers 404 and lists the labels that do
exist; a process with no capture supervisor at all answers 503.
The same action is a Restart button beside each source on /admin/audio.
To read the labels — and to see whether the restart took — ask for the capture status. It is bearer-gated rather than public, because a station’s source labels and per-source fault history are operational detail about someone’s home:
curl http://localhost:8502/api/v2/system/capture \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"supervised": true,
"published_unix": 1789000000,
"sources": [
{
"label": "src_rtsp_1",
"state": "connected",
"uptime_secs": 512,
"last_audio_age_secs": 1,
"restart_attempts": 0,
"restarts_last_hour": 1,
"flapping": false,
"next_retry_in_secs": null,
"uptime_24h": ["up", "up", "…"]
}
]
}
state is one of connected, stalled, backing_off, paused;
uptime_24h is 48 half-hour cells, oldest first, each up, down or out
(no data — before the source was first seen, or intentionally paused).
supervised is false, with an empty list, when nothing in this process is
supervising capture: web-only mode, or tooling. That is an answer, not an error.
Restarts an operator asked for are deliberately not counted in
restarts_last_hour, and so never raise flapping. That number exists to spot
a source that cannot stay up on its own; clicking Restart three times while
debugging a camera is not that.
The database overview
GET /api/v2/system/database/overview answers with birdnet-go’s fields for
its SQLite engine: engine, status, location, size_bytes (the main
file; the WAL is sqlite.wal_size_bytes), total_detections,
total_tables, sqlite (version, journal mode, page size, free pages, cache
size, integrity_check, last_vacuum_at), tables (each with row_count,
size_bytes counting its indexes, and usage_pct), performance and
detection_rate_24h (detections in each of the last 24 UTC hours that had
any). It differs from upstream where upstream would report a number this
station did not measure:
integrity_checkis the last recorded check —ok,failedorunchecked— where upstream always saysok.wal_checkpoints,busy_timeoutsand everyperformancefigure exceptwrite_latency_avg_msarenull. Nothing here counts them, and upstream’s checkpoint count comes from running a checkpoint, which aGEThere does not.write_latency_avg_msis the mean detection write since the service started.- Table sizes come from reading every page of the file, so they are measured
at most every 15 minutes;
tables_measured_at(Unix seconds) says when.
A database that cannot be read answers 200 with status: "disconnected" and
empty lists, as upstream does.
Scheduled maintenance jobs
GET /api/v2/system/jobs reports every job the maintenance loop schedules —
the integrity check, the backup and VACUUM, the offsite upload, the recording
cap, the log retention pass, the summary drift check and the session prune —
with when each last completed, whether it passed, and whether it is due now.
Bearer-gated, for the same reason as /system/capture: when a station’s
backups run, and whether they are failing, is operational detail about
someone’s home.
curl http://localhost:8502/api/v2/system/jobs \
-H "Authorization: Bearer $BNB_API_TOKEN"
{
"now_unix": 1789000000,
"jobs": [
{
"job": "integrity_check",
"title": "Database integrity check",
"interval_secs": 86400,
"last_run_unix": 1788950000,
"next_due_unix": 1789036400,
"ok": true,
"reports_verdict": true,
"due": false,
"due_reason": null
},
{
"job": "backup_vacuum",
"title": "Backup and VACUUM",
"interval_secs": 604800,
"last_run_unix": null,
"next_due_unix": null,
"ok": null,
"reports_verdict": true,
"due": true,
"due_reason": "never_run"
}
]
}
The list is every job, not every job that has run. maintenance_runs
holds a row per job that has completed at least once, so an endpoint built by
listing rows would answer with a short, clean list that omitted exactly the
jobs worth asking about — a station whose backup has never run once would
simply not mention it. The second entry above is that case.
ok is tri-state; do not collapse it. null means either never run or
this job has no pass/fail to report, and reports_verdict separates those
from a recorded false. A session prune that succeeded and an integrity check
that failed both have falsy verdicts and mean opposite things.
due_reason is never_run, interval_elapsed, or clock_went_backwards —
the last meaning the recorded run is in the future, which happens on a Pi with
no real-time clock that boots at the epoch and then has NTP land. The job runs
and re-anchors its schedule rather than waiting years for real time to catch
up.
The backup’s interval_secs follows BACKUP_SCHEDULE, the one cadence an
operator sets, so a station on daily is reported on a daily cadence rather
than told it is not due for another six days. Every other job’s interval is
fixed — including the space reclaim, which stays weekly whatever the backup is
set to.
due is the scheduler’s own rule, shared with the loop that runs the jobs
rather than re-implemented here. One thing it cannot see is that loop’s
in-process floor, which stops a station with a full disk re-running a weekly
VACUUM every half hour; it only ever delays a job within one process lifetime,
so a job reported as due may already have run since its last recorded
completion. That is the honest answer from persisted state, which is the only
state you can inspect.
The audit log
Every change is written to the audit log as
detection.review / detection.lock / detection.unlock / detection.delete
/ settings.update / system.restart / audio.source.restart, with no user
and via=api in the metadata — a batch writes one such row per detection it changed, under the same
names — a token is not a person, and the log says so rather than inventing
one. A settings change records the key names only: an entry reading
birdweather_token=… would put a credential on the page that renders the log.
The restart entry is written before the decision, so a refused restart is
recorded too.
Cross-origin calls
These endpoints are exempt from the dashboard’s same-origin (CSRF) check,
because a cross-site form cannot set an Authorization header — that is the
whole premise of the check, so it has nothing to protect here. The exemption is
scoped to these paths; every other write in the product still has it.