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.