Webhooks
BirdNET-NG sends HTTP notifications to external services through the alerts system: a webhook is an alert channel, attached to one or more alert rules. This is the delivery path used for every alert type (rare species, first of day/season, satellite offline, and any custom rule).
Setup
Create a channel of type Webhook in the web UI under Alerts > Channels, give it a target URL and (recommended) a signing secret, then reference the channel from the alert rules that should fire it.
Via the API:
curl -X POST https://birdnet.example.com/api/alert-rules/channels \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{
"tenantId": "<uuid>",
"type": "webhook",
"name": "My receiver",
"config": {
"url": "https://example.net/birdnet-hook",
"secret": "my-webhook-secret"
}
}'
The signing secret is encrypted at rest (AES-256-GCM, same key as MFA secrets: MFA_ENCRYPTION_KEY). It is write-only: no API response ever returns it.
Payload
Default JSON body (a custom payload_template on the channel overrides it):
{
"event": "rare_species",
"timestamp": "2026-03-22T10:30:00Z",
"data": {
"species": "Loxia curvirostra",
"commonName": "Red Crossbill",
"confidence": 0.82,
"satelliteName": "Garden Pi",
"detectionId": "uuid"
}
}
Signature verification
When the channel has a secret, every request carries a GitHub-style HMAC-SHA256 signature of the raw request body:
X-BirdNet-Signature: sha256=<hex-encoded-hmac>
Always compare with a constant-time function, against the raw bytes of the body (not a re-serialized parse).
Python receiver:
import hmac, hashlib
def verify(secret: str, body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header)
# Flask example
# verify(SECRET, request.get_data(), request.headers["X-BirdNet-Signature"])
Node.js receiver:
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, rawBody, header) {
const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
Reject the request (401) when the header is missing or the comparison fails.
Testing
Alerts > Channels > Test sends a sample payload through the full pipeline, including the signature header, and reports the receiver's HTTP status.