Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 answer 401 without a session (the health check, and /api/v2/metrics when metrics is 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 no BNB_API_TOKEN answers 404 to 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 at crates/birdnet-web/openapi.json). Load it into Swagger UI, Redoc, Postman, or openapi-generator to 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_documented reads the routes from the router itself (axum’s Debug listing 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_served fails on one the document names that no route serves; and the_document_is_structurally_valid checks that every $ref resolves, every path placeholder is a declared path parameter, and every operation has a unique operationId, 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/detections and /api/v2/ws/spectrogram, are described by the status that upgrades them, 101, with the messages they carry. The API root is /api/v2 exactly, and /api/v2/ is a 404; 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.yaml is in the repository root); run redocly lint crates/birdnet-web/openapi.json to 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.

EndpointPurpose
GET /api/v2/healthJSON liveness/health check (use for monitoring).
GET /api/v2/health/conditionsWhat 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/metricsPrometheus metrics (text format) — see Integrations.
GET /api/v2/statsSummary counts (detections, species, today).
GET /api/v2/system/diskDisk 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"
    }
  ]
}
EndpointCommon query params
GET /api/v2/detectionsdate, 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/recentlimit — most recent first
GET /api/v2/detections/dailydays — per-day counts over the last N days
GET /api/v2/detections/exportformat (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/birddbfrom, to — BirdNET-Pi’s semicolon-separated BirdDB.txt (BirdDB.txt), the twelve BirdNET-Pi columns and nothing more
GET /api/v2/detections/export/ebirddate, 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

EndpointCommon query paramsPurpose
GET /api/v2/species/toplimitMost-detected species with counts
GET /api/v2/species/searchq, limitSearch by name
GET /api/v2/species/detailnamePer-species stats
GET /api/v2/species/activitydateHourly activity (all species) on a given day
GET /api/v2/species/occurrenceweek (1–48), q, admitted (true/false), limit (default 100, at most 1000), offsetWhat 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/exportformat (csv or json), limitPer-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/recordingsspecies (matched against the file name), limit (default 50, at most 500), offsetList 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.

EndpointPurpose
GET /api/v2/timeseries/daily · weekly · hourlyCounts over time
GET /api/v2/timeseries/diversitySpecies diversity over time
GET /api/v2/timeseries/trend · year-over-yearTrends and year-on-year comparison
GET /api/v2/timeseries/peak-windows · gaps · anomaliesPeak activity, quiet gaps, outliers
GET /api/v2/timeseries/accumulationLife-list accumulation curve
GET /api/v2/timeseries/heatmap · sessionsHour×day heatmap, activity sessions
GET /api/v2/timeseries/statusWhether the time-series engine is available
GET /api/v2/analytics/sessions · retention · funnel · next-species · previous-species · patternsDuckDB behavioral analytics
GET /api/v2/analytics/funnel-eventsThe 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-eventsThe sequence_count and sequence_match_events variants of patterns, with the same parameters (species, max_gap, hour_start, hour_end)
GET /api/v2/analytics/statusAnalytics 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/migrationSpecies 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-speciesSpecies 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-todaySpecies 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/regionalWhat 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/kpisSpecies 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. Check embedded_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. Its property says whether the DuckDB version, the platform, or both disagree — compare against engine_duckdb_version and engine_platform. An extension is locked to both, and both fail the same way at LOAD. How duckdb_version is read depends on abi: for CPP and C_STRUCT_UNSTABLE it is a DuckDB version that must equal the engine’s; for C_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 — so v1.2.0 against a v1.5.6 engine is correct. Rebuild against community-extensions.duckdb.org/<engine_duckdb_version>/<engine_platform>/.
  • self_test other than "ok" while extension_loaded is true — 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-extension runs the same checks.
  • detections: 0 against 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_detections above zero — that many rows carry a Date/Time naming 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:

RequestAnswer
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.m3u8the 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}/stopthe 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

MethodPathDoes
POST/api/v2/detections/reviewRecord confirmed / rejected, or clear a verdict by omitting status
POST/api/v2/detections/lockProtect the clip from the disk-full purge and retention sweep
POST/api/v2/detections/unlockReturn it to the ordinary purge rules
POST/api/v2/detections/deleteRemove the detection row
POST/api/v2/detections/batchApply one of the four above to up to 500 detections
GET/api/v2/detections/commentsRead one detection’s comment thread, oldest first
POST/api/v2/detections/commentsAdd a comment (1–2000 characters)
POST/api/v2/detections/comments/deleteWithdraw one comment by id
POST/api/v2/dynamic-thresholds/resetForget what the station learned about one species ({"species": "Strix aluco"}) or all of them ({"all": true, "confirm": true})
GET/api/v2/settingsRead every setting, with credentials redacted
PUT/api/v2/settingsChange one or more settings
POST/api/v2/control/restartRestart the station
POST/api/v2/control/restart-sourceRestart one audio source’s capture
GET/api/v2/system/capturePer-source capture health, as Station Health draws it
GET/api/v2/system/jobsEvery scheduled maintenance job and its state
GET/api/v2/system/networkNetwork interfaces: addresses with prefix, hardware address, bytes in and out; loopback last
GET/api/v2/system/database/overviewThe 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/processesThe 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/catalogThe 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 is 200 whenever 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 in results, and applied and failed are 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_treshold answering 200 would tell you a change had landed when none had.
  • The literal ***REDACTED***. It is what GET hands 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_check is the last recorded check — ok, failed or unchecked — where upstream always says ok.
  • wal_checkpoints, busy_timeouts and every performance figure except write_latency_avg_ms are null. Nothing here counts them, and upstream’s checkpoint count comes from running a checkpoint, which a GET here does not. write_latency_avg_ms is 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.