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_PROVIDER | What it is | Needs |
|---|---|---|
open-meteo (default) | Free gridded forecast, no key, and self-hostable — point BNB_WEATHER_BASE_URL at your own instance | nothing |
met-no | The Norwegian Meteorological Institute’s Locationforecast. Keyless, and a better model over Europe | nothing |
wunderground | Your own personal weather station’s current observations | WEATHER_STATION_ID and WEATHER_API_KEY |
pirate-weather | Pirate Weather’s hourly forecast, a Dark Sky-compatible API blending several models | WEATHER_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=…
| Key | What it does | Default |
|---|---|---|
EBIRD_API_KEY | Your eBird token. Absent ⇒ the whole feature is off | (none) |
EBIRD_REGION | An eBird region code (US-MA, GB-ENG-CAM). Empty ⇒ use the station’s own coordinates | (empty) |
EBIRD_DIST_KM | Search radius when using coordinates, 0–50 | 25 |
EBIRD_BACK_DAYS | Days of history to ask about, 1–30 | 14 |
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):
| Topic | Payload | Retained | Published by |
|---|---|---|---|
birdnet/detection/<Scientific_Name> | A detection (JSON, below). Spaces in the name become underscores. | your MQTT_RETAIN setting | the station, per detection |
birdnet/status | online or offline | yes | see below |
birdnet/stats/today | {"count": N} — detections so far today, station-local | yes | the 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 happened | birdnet/status becomes | Who published it |
|---|---|---|
| The station started | online | the station |
systemctl stop, an upgrade, any clean exit | offline | the station |
| Power cut, crash, cable pulled, Wi-Fi gone | offline, within ~45 s | the broker, from the will |
| The broker itself went down | unchanged | nobody — 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 are | Meaning |
|---|---|
| timestamps | the station had heard this species before |
both null | it had not — this is the first detection ever |
| absent | the 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:
| Entity | Type | Reads |
|---|---|---|
| Last Detected Bird | sensor | birdnet/detection/# |
| Detection Confidence | sensor | birdnet/detection/# |
| Station Status | binary_sensor (connectivity) | birdnet/status |
| Detections Today | sensor | birdnet/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:
| Metric | Type | Meaning |
|---|---|---|
birdnet_detections_total | counter | Total detections since start, labeled by species and integer chunk_offset (s). |
birdnet_detections_stored | gauge | Detection 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_rejected | gauge | Of those, the ones a reviewer has marked rejected. stored - rejected is what the web UI displays. |
birdnet_species_distinct | gauge | Distinct species detected, excluding rejected detections. |
birdnet_inference_duration_seconds | histogram | Per-chunk inference latency (decode → prediction). |
birdnet_db_write_duration_seconds | histogram | SQLite insert latency for one detection row. |
birdnet_files_analysed_total | counter | Audio 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_total | counter | Seconds 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_total | counter | Raw 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_depth | gauge | Raw 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_total | counter | Raw 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_failing | gauge | 1 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_windows | gauge | Analysis windows a classifier has failed since it last answered one, labeled by model. |
birdnet_model_windows_total | counter | Analysis windows a classifier has run since it was loaded, answered or failed, labeled by model. |
birdnet_model_last_success_timestamp_seconds | gauge | When a classifier last answered a window cleanly (Unix seconds), labeled by model. Absent for one that has not. |
birdnet_audio_source_up | gauge | 1 if an audio source is producing samples, else 0, labeled by source (e.g. local, cam1). One series per capture source. |
birdnet_detection_silence_seconds | gauge | Seconds 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_depth | gauge | Store-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_total | counter | Successful systemd WATCHDOG=1 notifications sent. |
birdnet_detection_write_failures_total | counter | Detections the model produced and the database refused — a detection the station heard and could not keep. Should stay 0; see below. |
birdnet_clock_synced | gauge | 1 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_connected | gauge | 1 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_total | counter | Notifications 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_dbfs | gauge | The 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_db | gauge | How 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_info | gauge | Build information — always 1, with the release in its version label. |
birdnet_uptime_seconds | gauge | Process uptime in seconds. |
birdnet_cpu_count | gauge | Number of CPU cores available. |
birdnet_process_resident_memory_bytes | gauge | Resident memory size in bytes. |
birdnet_analytics_enabled | gauge | Whether DuckDB analytics is enabled. |
birdnet_detections_dropped_total | counter | Classifications produced and then discarded, labeled by reason. |
birdnet_capture_restarts_total | counter | Capture processes restarted by the supervisor, labeled by source. |
birdnet_capture_stalls_total | counter | Capture processes found alive but producing no segments, labeled by source. |
birdnet_occurrence_filter_active | gauge | 1 when species occurrence filtering is running, 0 when every species the classifier knows is admitted. |
birdnet_occurrence_candidates | gauge | Species the occurrence filter currently admits. |
birdnet_orphaned_clips | gauge | Detections 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_total | counter | Detections the database accepted and the DuckDB analytics copy refused since process start. |
birdnet_purge_ineffective | gauge | 1 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_percent | gauge | Space 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_bytes | gauge | Bytes this user can still write on the volume, same labels. |
birdnet_cpu_temperature_celsius | gauge | CPU temperature from the board’s sensor; absent where none is exposed. |
birdnet_pi_throttled_bits | gauge | A 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_seconds | gauge | When the scheduled job last completed, seconds since the Unix epoch, labeled by job (backup_vacuum, integrity_check, offsite_backup). |
birdnet_maintenance_last_ok | gauge | Whether the job’s last run succeeded (1) or failed (0); absent for a job that records no verdict. |
birdnet_http_responses_total | counter | Web responses served, labeled by status class. |
birdnet_http_request_duration_seconds | histogram | Web request handling latency in seconds. |
Renamed. Three gauges used to carry a
_totalsuffix, 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 bypromtool check metrics, Telegraf’sinputs.prometheusand the Python client, and a Prometheus server silently merged a decreasing gauge into the counter. The gauges are nowbirdnet_detections_stored,birdnet_detections_rejectedandbirdnet_species_distinct; the counter keeps the_totalname that the convention reserves for it. Update any dashboard or alert rule that referenced the old gauge names — the bundleddocs/grafana-dashboard.jsonalready 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.