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

Integrations Reference

Wiring BirdNet-Behavior into the rest of your stack — MQTT, Home Assistant, Prometheus, BirdWeather and webhooks.

Weather

Off by default — a fresh install contacts no weather service until BNB_WEATHER_ENABLED=1. Once on, a background task polls every 30 minutes, stores hourly rows, and paints them as a signal-context band behind the day-strip and dawn-chorus charts.

Four upstreams, chosen with the WEATHER_PROVIDER key in birdnet.conf:

WEATHER_PROVIDERWhat it isNeeds
open-meteo (default)Free gridded forecast, no key, and self-hostable — point BNB_WEATHER_BASE_URL at your own instancenothing
met-noThe Norwegian Meteorological Institute’s Locationforecast. Keyless, and a better model over Europenothing
wundergroundYour own personal weather station’s current observationsWEATHER_STATION_ID and WEATHER_API_KEY
pirate-weatherPirate Weather’s hourly forecast, a Dark Sky-compatible API blending several modelsWEATHER_API_KEY (free tier available)
# birdnet.conf — read the anemometer in the garden, not a forecast for the county
WEATHER_PROVIDER=wunderground
WEATHER_STATION_ID=KMAHANOV10
WEATHER_API_KEY=…

The last one is the interesting one. A gridded forecast is a model’s opinion about a cell several kilometres across; a personal weather station is an instrument ten metres from the microphone. For asking why the birds were quiet on Tuesday, the instrument wins.

The API key can be mounted from a file rather than written into the config, the same way the other credentials can: BIRDNET_WEATHER_API_KEY_FILE=/run/secrets/weather_api_key.

eBird

“Has anybody else seen this bird around here lately?”

The station already has a geographic opinion about which species are plausible — the BirdNET range model. That opinion is climatological: it knows a Common Swift is expected here in July, and it has no idea the first one of the year arrived last Tuesday. eBird’s recent observations are the other kind of evidence entirely: a person stood near here within the last fortnight and wrote down what they saw.

Off unless you give it a key. There is no enable flag — eBird requires an API key for every endpoint, so the key is the opt-in, and a station without one never contacts eBird. Free keys: https://ebird.org/api/keygen.

# birdnet.conf
EBIRD_API_KEY=…
KeyWhat it doesDefault
EBIRD_API_KEYYour eBird token. Absent ⇒ the whole feature is off(none)
EBIRD_REGIONAn eBird region code (US-MA, GB-ENG-CAM). Empty ⇒ use the station’s own coordinates(empty)
EBIRD_DIST_KMSearch radius when using coordinates, 0–5025
EBIRD_BACK_DAYSDays of history to ask about, 1–3014

Leaving EBIRD_REGION empty is usually right: “reported within 25 km” says far more than “reported somewhere in this state”, and the station already knows where it is. Set a region only if your area genuinely has no eBird observers within 50 km — a county or state will find some.

Your coordinates are sent rounded to two decimal places, roughly a kilometre. That is all eBird documents that it accepts, and all it needs.

The key can be mounted from a file like the other credentials: BIRDNET_EBIRD_API_KEY_FILE=/run/secrets/ebird_api_key. BNB_EBIRD_BASE_URL points the client at a caching proxy instead of api.ebird.org.

Where it shows up

Two places, both of them corroboration rather than filtering:

  • A detection’s detail page gains a Reported nearby card when somebody reported that species in the station’s neighbourhood recently.
  • The suspect-species report (Settings → Data) marks a flagged species that somebody reported nearby, and its Exclude confirmation says so — because that is a strong reason not to exclude it.

What it will never do

eBird’s silence is not evidence against a species, and the station never treats it as such. eBird coverage follows birdwatchers, not birds: a well-watched county produces hundreds of checklists a week and a quiet valley produces none, ever, for anything. If absence from eBird counted against a species, the station with nobody nearby to confirm anything — the one that most needs an automated check — would have its entire list flagged.

So corroboration only ever argues for a species. A species eBird says nothing about is displayed exactly as it was before you configured this, and no verdict anywhere changes.

When eBird is unreachable

The last answer is cached to disk and survives a restart. A station whose uplink is down keeps showing the corroboration it had, and pages label an answer older than a day as the last one eBird gave rather than hiding it or passing it off as current. The snapshot refreshes every six hours.

A WEATHER_PROVIDER nobody implements does not start the poll. That is deliberate: a station configured to read its own anemometer and quietly served a county forecast instead looks exactly like a station that is working.

Two things to know about what each provider can and cannot fill in:

  • MET Norway reports no weather code. It describes the sky with a symbol string (partlycloudy_day) rather than a WMO number, and mapping one onto the other would be a table of guesses sitting in a column that reads as fact. The column stays empty.
  • A personal weather station reports no cloud cover, because it measures the air rather than the sky. Its precipitation is the hourly rate — not Wunderground’s precipTotal, which accumulates since local midnight and would read as a downpour by evening after one morning shower.

MQTT

A pure-Rust MQTT 3.1.1 client publishes detections to any broker (Mosquitto, EMQX, Node-RED, …). Enable it with at least:

BIRDNET_MQTT_HOST=192.168.1.10
# optional: port, username/password, QoS, retain, and a topic prefix

Topics

Topics are built from a configurable prefix (default birdnet):

TopicPayloadRetainedPublished by
birdnet/detection/<Scientific_Name>A detection (JSON, below). Spaces in the name become underscores.your MQTT_RETAIN settingthe station, per detection
birdnet/statusonline or offlineyessee below
birdnet/stats/today{"count": N} — detections so far today, station-localyesthe station, every 5 minutes

Subscribe to everything with birdnet/detection/#.

How the station reports that it is gone

birdnet/status is not published by the detection stream. The station holds a second, otherwise idle connection to the broker whose only job is to carry an MQTT last will, and the two connections use different client identifiers (<client_id> and <client_id>-presence) because a broker must disconnect an existing session when a second one claims its identifier.

What happenedbirdnet/status becomesWho published it
The station startedonlinethe station
systemctl stop, an upgrade, any clean exitofflinethe station
Power cut, crash, cable pulled, Wi-Fi goneoffline, within ~45 sthe broker, from the will
The broker itself went downunchangednobody — see below

The last row is the honest limit, and it is why birdnet_mqtt_connected exists as a Prometheus gauge: a station cannot report on a broker that is not there, so that case needs a signal that does not travel through the broker.

The keepalive is 30 seconds and the broker publishes the will after 1.5 of those, which is where the ~45 s comes from.

Detection payload

{
  "timestamp": "2026-05-22T21:10:31",
  "scientific_name": "Melospiza melodia",
  "common_name": "Song Sparrow",
  "confidence": 0.83,
  "confidence_pct": 83,
  "file_name": "Song_Sparrow-...wav",
  "species_first_detected_at": "2026-03-14T06:02:11",
  "species_last_detected_at": "2026-05-21T19:47:03"
}

file_name is left out when no clip was saved, and rtsp_id (the stream a detection came from) appears only for an RTSP source.

species_first_detected_at and species_last_detected_at are the species’ earliest and latest detections before this one, in the same local wall-clock form as timestamp; rejected detections are not counted, nor are imported ones when imports are excluded from analytics. The two keys say three different things:

The keys areMeaning
timestampsthe station had heard this species before
both nullit had not — this is the first detection ever
absentthe history could not be read, so nothing is claimed

An automation for “a species never heard here before” should test for null, not for a missing key: a missing key is a read failure, not a first.

Home Assistant

BIRDNET_MQTT_HOST=192.168.1.10
BIRDNET_MQTT_HA_DISCOVERY=1     # or the --mqtt-ha-discovery flag

With discovery enabled, the station publishes Home Assistant MQTT discovery config under the homeassistant/ prefix, so it registers itself automatically — no YAML to write. Four entities appear:

EntityTypeReads
Last Detected Birdsensorbirdnet/detection/#
Detection Confidencesensorbirdnet/detection/#
Station Statusbinary_sensor (connectivity)birdnet/status
Detections Todaysensorbirdnet/stats/today

Drop them on a dashboard or trigger automations from them — “flash the porch light when an owl is heard after dark”, or, from Station Status, “tell me when the garden station stops answering”.

If you ran an earlier version. Station Status and Detections Today were registered but never fed, so both showed as unknown for the life of the station and no automation could be built on either. Discovery configs were also published unretained, which meant Home Assistant lost all four entities every time it restarted, until the station was restarted too. Both are fixed; the entities repopulate the next time the station starts.

Prometheus metrics

GET /api/v2/metrics returns the standard Prometheus text format. The exported series:

MetricTypeMeaning
birdnet_detections_totalcounterTotal detections since start, labeled by species and integer chunk_offset (s).
birdnet_detections_storedgaugeDetection rows currently in the database, rejections included. Falls when rows are deleted or purged, which is why it is a gauge and does not wear a _total suffix.
birdnet_detections_rejectedgaugeOf those, the ones a reviewer has marked rejected. stored - rejected is what the web UI displays.
birdnet_species_distinctgaugeDistinct species detected, excluding rejected detections.
birdnet_inference_duration_secondshistogramPer-chunk inference latency (decode → prediction).
birdnet_db_write_duration_secondshistogramSQLite insert latency for one detection row.
birdnet_files_analysed_totalcounterAudio files the pipeline finished analysing, labeled by source. The series that separates “the model is answering nothing” from “the pipeline is not running” — every other signal is downstream of a prediction the model made, so both states leave them flat and empty. A 15-second segment length gives about 5 760 a day per source.
birdnet_audio_analysed_seconds_totalcounterSeconds of audio the pipeline finished analysing, labeled by source: the data rate. With nothing waiting (birdnet_analysis_queue_depth), rate() of it is about 1 for a source that delivers continuously, above 1 while a backlog is analysed, and below 1 when audio is missing. That last case is what birdnet_files_analysed_total cannot show: a camera that drops packets still yields a 15-second segment file, with less audio in it. Read from each file’s header after analysis.
birdnet_segments_dropped_totalcounterRaw segments the watcher announced that were gone before the pipeline read them, labeled by source: recorded audio that was never analysed. Rising means the stream directory is being drained faster than inference keeps up. A segment the pipeline is reading is claimed and never the one the purge takes.
birdnet_analysis_queue_depthgaugeRaw segments waiting for analysis after the daemon’s last sweep. Climbing means inference is slower than real time; above 40 the daemon sheds (below). Absent until the daemon reports.
birdnet_segments_shed_totalcounterRaw segments the shed policy chose not to analyse, labeled by reason: backlog (more than 40 waiting) or thermal (the board at or above 80 °C, or throttling). One segment in two is skipped while either holds, so the queue drains at twice the rate and the skipped audio is counted rather than lost.
birdnet_model_failinggauge1 while a classifier is failing — at least 10 analysis windows failed in a row, none answered since — else 0, labeled by model. A window fails when the classifier returns an error or answers with NaN or infinite scores. Absent until the daemon has analysed a file.
birdnet_model_consecutive_failed_windowsgaugeAnalysis windows a classifier has failed since it last answered one, labeled by model.
birdnet_model_windows_totalcounterAnalysis windows a classifier has run since it was loaded, answered or failed, labeled by model.
birdnet_model_last_success_timestamp_secondsgaugeWhen a classifier last answered a window cleanly (Unix seconds), labeled by model. Absent for one that has not.
birdnet_audio_source_upgauge1 if an audio source is producing samples, else 0, labeled by source (e.g. local, cam1). One series per capture source.
birdnet_detection_silence_secondsgaugeSeconds since the most recent stored detection — the end-to-end “is it actually detecting?” freshness signal (see System Health). Absent until the first measurement / on a station with no detections yet.
birdnet_outbound_queue_depthgaugeStore-and-forward uploads parked for replay after a network failure, labeled by kind (e.g. birdweather). A depth that only grows means the uplink or token has been broken for a while.
birdnet_watchdog_pings_totalcounterSuccessful systemd WATCHDOG=1 notifications sent.
birdnet_detection_write_failures_totalcounterDetections the model produced and the database refused — a detection the station heard and could not keep. Should stay 0; see below.
birdnet_clock_syncedgauge1 when the system reports its clock as synchronised to a time source, 0 when it does not. Absent when nothing can answer — every container without systemd, where the clock belongs to the host. A 0 here is the quiet failure: the dates stay plausible, so every count and chart looks healthy while the hours drift, and a season filed under the wrong times only shows up when someone tries to compare it against another station.
birdnet_mqtt_connectedgauge1 when the station’s MQTT presence session is connected to the broker, 0 when it is not. Absent when MQTT is not configured. This is the one MQTT signal that does not travel through the broker, so it is what tells you the broker is down rather than the station — while it reads 0, the broker has already published the station’s last will and Home Assistant shows it offline, but nobody has told you which of the two failed.
birdnet_notifications_dropped_totalcounterNotifications that never left the station, labeled by reason: circuit_open (the destination is considered down after three consecutive failures), rate_limited (over NOTIFY_RATE_PER_MINUTE), send_failed (the destination refused or was unreachable), no_destination (nothing configured that this station can deliver to). Detection notifications dominate this on a busy station; an alert about the station itself is exempt from the rate limit and is retried at every poll until it lands, so circuit_open rising while a health condition is open means the operator is not being told.
birdnet_noise_floor_dbfsgaugeThe station’s measured background noise floor per capture source, averaged over the last 7 days. Typical quiet outdoor background is −60 to −40 dBFS.
birdnet_noise_floor_drift_dbgaugeHow far a source’s noise floor has moved against its own preceding 30-day average, in dB. Absent for a source with no baseline yet — “never measured” is not “unchanged”.
birdnet_infogaugeBuild information — always 1, with the release in its version label.
birdnet_uptime_secondsgaugeProcess uptime in seconds.
birdnet_cpu_countgaugeNumber of CPU cores available.
birdnet_process_resident_memory_bytesgaugeResident memory size in bytes.
birdnet_analytics_enabledgaugeWhether DuckDB analytics is enabled.
birdnet_detections_dropped_totalcounterClassifications produced and then discarded, labeled by reason.
birdnet_capture_restarts_totalcounterCapture processes restarted by the supervisor, labeled by source.
birdnet_capture_stalls_totalcounterCapture processes found alive but producing no segments, labeled by source.
birdnet_occurrence_filter_activegauge1 when species occurrence filtering is running, 0 when every species the classifier knows is admitted.
birdnet_occurrence_candidatesgaugeSpecies the occurrence filter currently admits.
birdnet_orphaned_clipsgaugeDetections whose clip the disk no longer had, found and stamped by the last daily reconciliation pass; absent until one has run.
birdnet_analytics_mirror_failures_totalcounterDetections the database accepted and the DuckDB analytics copy refused since process start.
birdnet_purge_ineffectivegauge1 when the last disk-full purge removed recordings without lowering usage: the card is full of something the purge cannot reach, and it has stopped deleting until usage falls.
birdnet_disk_used_percentgaugeSpace used on the volume, labeled volume="data" (the database’s directory) and, when it is a different filesystem, volume="scratch" (the temporary directory).
birdnet_disk_available_bytesgaugeBytes this user can still write on the volume, same labels.
birdnet_cpu_temperature_celsiusgaugeCPU temperature from the board’s sensor; absent where none is exposed.
birdnet_pi_throttled_bitsgaugeA Raspberry Pi firmware’s get_throttled mask: bits 0–3 now (under-voltage, frequency capped, throttled, soft temperature limit), bits 16–19 since boot. Absent off a Pi.
birdnet_maintenance_last_run_secondsgaugeWhen the scheduled job last completed, seconds since the Unix epoch, labeled by job (backup_vacuum, integrity_check, offsite_backup).
birdnet_maintenance_last_okgaugeWhether the job’s last run succeeded (1) or failed (0); absent for a job that records no verdict.
birdnet_http_responses_totalcounterWeb responses served, labeled by status class.
birdnet_http_request_duration_secondshistogramWeb request handling latency in seconds.

Renamed. Three gauges used to carry a _total suffix, and one of them — birdnet_detections_total — collided with the per-species counter above. A metric name may have only one type, so the exposition was rejected outright by promtool check metrics, Telegraf’s inputs.prometheus and the Python client, and a Prometheus server silently merged a decreasing gauge into the counter. The gauges are now birdnet_detections_stored, birdnet_detections_rejected and birdnet_species_distinct; the counter keeps the _total name that the convention reserves for it. Update any dashboard or alert rule that referenced the old gauge names — the bundled docs/grafana-dashboard.json already is.

GET /api/v2/health?strict=1 is the probe to point a pager at. The plain /api/v2/health answers 200 whenever the database is serving, which is right for the container health check — Docker restarts an unhealthy container, and a station whose detection daemon is down is exactly the one that must stay up to be diagnosed. The strict form additionally returns 503 when the detection daemon is not running, when the data disk is critically full or df cannot read it, or when the admin-password bootstrap failed at start, so a monitor that should wake a human can get a red out of the same endpoint. A data volume that is not taking writes at all — a read-only remount, a card full to the byte, a mount that has gone away — is 503 on both forms. Both report detection_daemon, detection_silence_secs and data_volume in the body either way.

The freshness and queue-depth gauges are the two you want alerts on for an unattended station: birdnet_detection_silence_seconds > <your quiet period> catches a station that has gone deaf even though every process looks healthy, and a steadily climbing birdnet_outbound_queue_depth catches a broken uplink before a season’s uploads pile up. Alert on any increase in birdnet_detection_write_failures_total as well: it is zero on a healthy station, and non-zero means a full or read-only disk, a locked database, or the one local hour daylight-saving repeats each autumn (see Time synchronisation).

The two noise-floor series answer the question no other gauge here can. A microphone that fails outright is caught by birdnet_audio_source_up and by the detection deadman. A microphone that merely goes deaf — water in the capsule, a spider’s web across the port, a connector loosened by a year of thermal cycling — keeps its process alive and its gauge at 1, and shows up only as fewer detections. So does the end of the breeding season. The background noise floor does not stop when the birds do, so a large, sustained negative birdnet_noise_floor_drift_db on one source, with nothing else changed, points at the equipment.

Measured on this project’s own test recording, a capsule at 2 % sensitivity reads 35 dB lower on the noise floor (−77.3 against −42.5 dBFS) while its SNR barely moves (2.9 against 2.7 dB) — attenuation scales signal and background together, so SNR is blind to it and the noise floor is not.

No threshold is shipped, deliberately: a noise floor moves for real reasons — weather, season, a road, leaf-out — and a number picked without a season of real recordings behind it would fire on all of them. Watch the series for a few weeks, then set an alert from what your own site actually does. A starter Grafana dashboard lives at docs/grafana-dashboard.json.

# prometheus.yml
scrape_configs:
  - job_name: birdnet
    metrics_path: /api/v2/metrics
    static_configs:
      - targets: ["birdnet.lan:8502"]

BirdWeather

Share detections with the BirdWeather network using your station token:

BIRDNET_BIRDWEATHER_TOKEN=your-station-token

Each detection goes up with its audio: the saved clip is uploaded first as a soundscape, and the detection post carries that soundscape’s id and the detection’s start and end inside it, so what BirdWeather shows for this station can be listened to. The id is kept on the detection row (birdweather_soundscape_id). If the clip cannot be uploaded the detection still goes, without it. Posts are stamped with the station’s local time and its UTC offset. algorithm is sent only for a model BirdWeather has a name for (2p4, the V2.4 classifier), and the code decides that from the classifier file’s name without its extension: it must contain V2.4, as BirdNET-Pi’s BirdNET_GLOBAL_6K_V2.4_Model_FP16 does. The V3.0 preview is left unnamed rather than mislabelled. The file --install-model birdnet-v24 writes is birdnet-v24.onnx, whose name does not contain V2.4, so by that rule a station on the default classifier sends no algorithm. That is read from algorithm_for_model and model_name_of, not observed against BirdWeather.

Uploads are resilient to a flaky uplink: a post that fails after its in-flight retries is parked in the local database and replayed oldest-first when the network returns (bounded so a months-long outage can’t fill the disk). Detection never blocks on the network. Watch the birdnet_outbound_queue_depth{kind="birdweather"} gauge and the “Queued Uploads” figure on Settings → Health; full mechanics are in the Field Deployment Runbook.

Self-hosted ingest (sensitive species)

Programmes tracking rare or endangered species — where a public observation map is a poaching risk — can keep observation locations under their own governance by pointing uploads at their own endpoint that implements the BirdWeather station API shape:

# config key BIRDWEATHER_URL, or env BIRDNET_BIRDWEATHER_URL
BIRDNET_BIRDWEATHER_URL=https://ingest.example.org/api/v1

Only the host changes — the /stations/<token>/... path shape is preserved, and the offline queue and ordered replay above come with it. The active endpoint is logged at startup so a misdirected station is visible in the first journal lines, not after a silent season.

Apprise & webhooks

For Discord, Slack, Telegram, ntfy, Gotify, Pushover or a plain JSON webhook, set BIRDNET_NOTIFY_URLS — Apprise URL syntax, delivered in-process. For the remaining Apprise services, point BIRDNET_APPRISE_URL at an Apprise API server or BIRDNET_APPRISE_CONFIG at an apprise config file. For fully custom integrations, the alert-rules engine can fire a webhook with a configurable method and body when a detection matches your conditions (species pattern, confidence range, hour, day-of-week).

See Notifications & Integrations for the channel walkthrough.