API Reference

The Hub API is a Fastify HTTP server. All endpoints are under /api/.

Authentication

Method Description
JWT cookie Set by POST /api/auth/login, sent automatically by browsers
Bearer JWT Authorization: Bearer <jwt> for user sessions (mobile, scripts)
Bearer API key Authorization: Bearer <hex-key> for machine-to-machine clients (tenant-scoped or satellite-scoped)
Internal API key Authorization: Bearer ${OTAVI_HUB_INTERNAL_API_KEY} for the nginx reverse proxy and trusted internal callers

The auth resolver tries internal key, JWT cookie, JWT bearer, then API key, in that order. Whichever matches sets request.auth = { userId? | apiKeyId?, satelliteId?, tenantId, role, isPlatformAdmin }. API keys are SHA-256 hashed in storage; the raw key is shown once at creation. Satellite-scoped keys are minted by registration (POST /api/satellites returns apiKey) and rotated via POST /api/satellites/:id/rotate-api-key hub 0.32.5; they carry role=member and a satellite_id FK so they can be revoked individually.

Authorization hub 0.70: every route declares the action it performs in the permissions registry (packages/shared/src/permissions.ts), and one guard applies the action's rule before the route runs: 401 when no one is signed in; 403 when the role, or the kind of caller, is not allowed (an API key where a person must act, a satellite's key outside the routes declared for it); 404 when the team or the item the route names is out of reach; 400 when a body must name its team and does not. The role counts in the team the action concerns (the item's own team, or the team the request names), whichever team the caller has active. The Auth column below gives the minimum role; Settings and permissions lists every action.

Login security:

  • Rate limiting: max 20 attempts per IP per 15 minutes (429)
  • Account lockout: 5 failed attempts locks for 15 minutes (423)
  • Last login timestamp + IP recorded on success
  • Hub admin status resolved from DB on every request (not from stale JWT)

Auth

Method Path Auth Description
POST /auth/register Public* Register user (with optional invite token)
POST /auth/login Public Login. Returns {user, token?} on success, or {mfaRequired:true, mfaToken, hasPasskeys} when MFA is enabled (the session cookie is only set after the second factor succeeds, or when the mfa-remember cookie matches a remembered device)
POST /auth/login/mfa/totp Public Complete MFA challenge with a 6-digit TOTP code. Body: {mfaToken, code, rememberDevice?}
POST /auth/login/mfa/backup Public Complete MFA challenge with a single-use backup code. Body: {mfaToken, code, rememberDevice?}
POST /auth/login/mfa/passkey/begin Public Begin WebAuthn assertion ceremony. Body: {mfaToken} → returns PublicKeyCredentialRequestOptionsJSON
POST /auth/login/mfa/passkey/finish Public Finish WebAuthn assertion. Body: {mfaToken, response, rememberDevice?}
POST /auth/forgot-password Public Request a password reset email. Body: {email}. Always returns 204 (whether or not the email exists, to avoid account enumeration). Rate-limited per IP via the login_attempts ledger. Sends a 60-minute single-use link. Requires OTAVI_SMTP_HOST to actually deliver mail; without it the request is recorded but silently dropped
POST /auth/reset-password Public Complete a password reset. Body: {token, password}. Token is the raw hex string from the email link (SHA-256 hashed in storage). On success: updates password_hash, clears failed_login_attempts and locked_until, marks the token used_at. MFA-enabled users still face their second factor on the next sign-in
POST /auth/logout Authenticated Clear session (does NOT clear the mfa-remember cookie)
GET /auth/me Authenticated Current user, tenants, preferences (as they apply: the person's own, else the hub's default), mfa.{enabled, enrolledAt, backupCodesRemaining, enrollmentRequired, available, passkeys, rememberedDevices}
PUT /auth/me/preferences Authenticated Update preferences by their field names (primaryLanguage, secondaryLanguages, dateFormatPrimary, dateFormatSecondary, timeFormat, startOfWeek, paperSize, showVoting; null = follow the hub). Checked by the settings registry since hub 0.73 (400 for a value outside its options); the account page uses PATCH /settings/effective?level=account
PUT /auth/me/profile Authenticated Update name and/or email
PUT /auth/me/password Authenticated Change password (requires current)
DELETE /auth/me Authenticated Delete own account
POST /auth/mfa/totp/begin Authenticated Begin TOTP enrollment — returns {qrDataUrl, manualKey}. Secret cached in Redis 10 min
POST /auth/mfa/totp/confirm Authenticated Confirm TOTP enrollment with a 6-digit code. Body: {code} → returns {backupCodes} (one-shot)
POST /auth/mfa/totp/cancel Authenticated Cancel pending TOTP enrollment
POST /auth/mfa/disable Authenticated Disable MFA. Body: {password}. Wipes secrets, passkeys, and remembered devices. Forbidden for hub admins
POST /auth/mfa/backup-codes/regenerate Authenticated Generate a new set of backup codes (invalidates the old set). Body: {password}
POST /auth/mfa/passkeys/register/begin Authenticated Begin passkey registration — returns PublicKeyCredentialCreationOptionsJSON
POST /auth/mfa/passkeys/register/finish Authenticated Commit a new passkey. Body: {response, name?}
GET /auth/mfa/passkeys Authenticated List the user's passkeys (id, name, createdAt, lastUsedAt)
DELETE /auth/mfa/passkeys/:id Authenticated Remove a passkey
GET /auth/mfa/remembered-devices Authenticated List remembered devices for the current user
DELETE /auth/mfa/remembered-devices/:id Authenticated Revoke one remembered device
POST /auth/mfa/admin/users/:id/reset Hub admin Reset MFA for another user (escape hatch for lost-device recovery). Wipes secrets, passkeys, remembered devices
POST /auth/invites Admin+ Generate invite link (tenant + role)
GET /auth/invites Admin+ List pending invites
DELETE /auth/invites/:id Admin+ Revoke invite
GET /auth/members Admin+ List tenant members (excludes hub admins)
POST /auth/create-tenant Logged in* Create new tenant

*Gated by hub settings (allow_self_registration, allow_tenant_creation)

Detections

Method Path Auth Description
GET /detections Viewer+ List detections (filterable)
GET /detections/summary Viewer+ Promoted detections in a window (start, end), one row per species. Also tentative_detections and tentative_species, the tentative ones it leaves out, and aggregation, the rule they wait on hub 0.77.3
GET /detections/species-timeline Viewer+ Every detection in a window, bucketed per species (buckets); a species with tentative detections also has tentative and tentative_buckets, and aggregation gives their rule hub 0.77.3
GET /detections/:id Viewer+ Detection detail with storage key
GET /detections/votes Viewer+ Get user's votes for detection IDs
POST /detections/:id/verify Member+ Submit verification vote
GET /detections/:id/comments Viewer+ List comments on a detection
POST /detections/:id/comments Member+ Add a comment
DELETE /detections/:id/comments/:commentId Member+ Delete own comment (admin can delete any)
GET /detections/:id/share Viewer+ {shareUrl, expiresAt}: a link that opens for 90 days hub 0.79.0; 409 NOT_SHAREABLE for a tentative detection or a category the team hides hub 0.78.1
GET /share/detection/:id/:token Public View shared detection (no auth). Promoted detections in a category the team shows only (the category since hub 0.78.1); species, time, confidence and badges, never the team, the satellite's name or its position hub 0.64.5
GET /share/audio/:id/:token Public Stream audio for shared detection (no auth); the same detections as the page. The page, the audio and the link preview answer 410 SHARE_EXPIRED once the link has expired, 403 for a forged one
POST /detections/:id/annotate Member+ Time-boxed spectrogram annotation + vote. Body: {vote, timeStartMs, timeEndMs, freqLowHz?, freqHighHz?, notes?}. Mirrors a verification_votes row so consensus logic still fires hub 0.30
GET /detections/:id/annotations Viewer+ List annotations for a detection

Query params for GET /detections:

  • tenantId — filter by tenant
  • satelliteId — filter by satellite
  • species — filter by species code
  • speciesSearch — search by common or scientific name (ILIKE)
  • minConfidence — minimum confidence threshold
  • verificationStatus — pending_review, confirmed, rejected, unverified
  • sortBy — sort field: date (default), confidence, species
  • sortDir — sort direction: asc or desc (default)
  • limit, offset — pagination
  • start, end: time window (ISO, end exclusive); all time when absent
  • includeTentative: true to include detections still waiting on the temporal-aggregation rule
  • withCount — set to false to skip the multi-second COUNT(*) over the visibility-filtered table when the caller doesn't need pagination (Dashboard recent-detections, Verification queue). When false, total is null in the response

Response: { detections: [...], total: number | null, aggregation }. Each detection includes an is_first_of_season boolean flag indicating whether it was the first observation of that species in the current season for the tenant. aggregation maps the team of each tentative detection listed to its rule: {enabled, count, windowMinutes, confidenceHigh, retentionHours} (retentionHours is null while the hub's retention is off, since nothing then deletes them).

Sessions

Method Path Auth Description
GET /sessions Viewer+ List recording sessions
GET /sessions/timeline Viewer+ Chunks + detections for a time range

Query params for GET /sessions:

  • tenantId — filter by tenant
  • satelliteId — filter by satellite
  • filter — session type: with-detections, empty, or omit for all
  • sortBy — sort field: date (default), duration, detections, species, chunks
  • sortDir — sort direction: asc or desc (default)
  • limit, offset — pagination

Response: { sessions: [...], total: number }

Query params for GET /sessions/timeline:

  • tenantId — filter by tenant
  • satelliteId — filter by satellite
  • from, to — ISO 8601 time range boundaries

Sessions are groups of consecutive audio chunks separated by a gap of 5 minutes or more. Each session includes its start/end time, chunk count, and detection summary.

Satellites

Method Path Auth Description
GET /satellites Viewer+ List satellites (filterable, sortable, paginated). Rows carry registered_by, registered_by_name, registered_by_me and can_manage hub 0.51
GET /satellites/:id Viewer+ Satellite detail, same ownership fields
POST /satellites Member+ / Admin+ Register satellite. Member+ when the team's device_policy is members (default), admin+ when admins (403 DEVICE_POLICY_ADMINS). Stamps registered_by. Re-registering by deviceId is for the registrant, an admin, or a member claiming a team-owned device; adoption by name only for the registrant or an admin. replaceSatelliteId (hub 0.63, with deviceId) makes the device take over that entry, keeping its id, settings and history; the caller must be able to administer it (its registrant under the members policy, or an admin), and the entry holding this device id until then is archived with its credentials revoked (audited device.satellite_replaced). Returns { id, mqtt, apiKey }, both secrets shown once
POST /satellites/:id/rotate-api-key Device rule Mint a fresh satellite-scoped API key, expire previous keys for this satellite
PATCH /satellites/:id Device rule Update satellite (rename)
POST /satellites/:id/profile Device rule Push recording profile via MQTT
POST /satellites/:id/update Device rule Request remote update via MQTT. The message, the audit entry and the reply name the latest release of the satellite's own stream (targetVersion: the Pi's or the phone's; the hub's own version for builds that report no stream; hub 0.67.4)
POST /satellites/:id/logs/request Device rule Trigger MQTT log-request, satellite responds with tail of its journal
GET /satellites/:id/config Viewer+ Get effective config (overrides merged with tenant defaults): configLocked, recordingProfile, effective, overrides. Each setting's source, default and lock: GET /settings/effective (the inherited and deviceInference fields of hub 0.64.0 to 0.64.5 moved there)
PUT /satellites/:id/config Device rule Update the lock state and/or replace the whole override row (a field left out is cleared). The values go through the settings writer: out of range or of the wrong type answers 400 with refusals, a new value under a lock from above 403 LOCKED_BY_TEAM or LOCKED_BY_PLATFORM (a stored value may be re-sent or cleared). PATCH /settings/effective?satelliteId= changes only the fields it is sent
GET /satellites/:id/config/history?limit= Viewer Effective-configuration history, newest first: `{history:[{id, changedAt, source: web
POST /satellites/:id/archive / /:id/unarchive Device administer right (admin+, or the registrant under the members policy) Archive = retire but keep: hidden from the fleet list and pickers (list takes ?includeArchived=1), no offline alert, data and credentials untouched; audited. A heartbeat or a re-registration by device id unarchives it (device.satellite_unarchived_by_heartbeat)
GET /satellites/:id/deletion-preview Viewer {detections, promoted, chunks, audioBytes, alerts, fieldNotes}: what a deletion removes or unlinks
DELETE /satellites/:id Device administer right Destructive: audio objects removed from the bucket, then rows cascade (chunks, detections and their votes, config, telemetry, keys); alerts and field notes keep their rows with the device unlinked. Optional body {confirmName} must equal the device name (400 CONFIRM_NAME_MISMATCH); the web always sends it. Audit entry carries the counts
GET /satellites/:id/schedule Viewer+ Resolved recording schedule with sun times
DELETE /satellites/:id Device rule Delete satellite (revokes MQTT)

Device rule hub 0.51. Every mutating satellite route is authorized against the device's own team, never the caller's active one: hub admin; or admin+ in that team; or the member who registered the device (registered_by) while the team's device_policy is members. A device's own satellite-scoped key may rename, rotate its key and push its config, nothing else. A device with no registrant is team-owned and admin-managed. Unknown or invisible devices answer 404, visible but not manageable ones 403.

PUT /satellites/:id/config body:

  • configLocked: boolean (admins of the satellite's team only: "Only admins can change its settings")
  • overrides: object with nullable fields: filter_enabled, filter_min_rms, yamnet_min_bird_prob, outbox_soft_size_mb, outbox_hard_size_mb, outbox_max_age_hours, heartbeat_interval_sec, drop_amphibian_at_satellite, drop_insect_at_satellite, drop_anthropogenic_at_satellite, drop_human_voice_at_satellite, drop_other_animal_at_satellite, device_inference. null follows the team (which follows the hub).

Config resolution: one rule for every setting, from the settings registry: the satellite's own value, else its team's, else the hub's, else the built-in default, with a lock at the hub or the team ending the walk there. See Settings and permissions.

Audio

Method Path Auth Description
GET /audio/:chunkId Viewer+ Stream audio WAV from MinIO

Alerts

Method Path Auth Description
GET /alerts Viewer+ List alerts (?acknowledged=true/false)
POST /alerts/:id/acknowledge Viewer+ Dismiss alert

Export

Method Path Auth Description
GET /export/detections Member+ Export CSV or JSON (?format=csv/json)
GET /export/ebird Member+ eBird checklist format
GET /export/inaturalist Member+ iNaturalist observation format
GET /export/xenocanto Member+ xeno-canto submission format

Stats

Every count below, the exports above and the PDF report cover the detections the Detections page lists: promoted, one row per stitched call, in the categories the team shows hub 0.78.0.

Method Path Auth Description
GET /stats/dashboard Viewer+ Species count, detection count, chunk count
GET /stats/trends Viewer+ Daily detection trends (?days=30)
GET /stats/top-species Viewer+ Top species ranking
GET /stats/activity Viewer+ Hourly detection activity
GET /stats/verification Viewer+ Verification progress counts
GET /stats/leaderboard Viewer+ Verification contributor ranking, by name (no email addresses since hub 0.71)
GET /stats/species Viewer+ Species catalog with rich per-species stats
GET /stats/species/:code Viewer+ Species profile (stats, trend, hourly, satellites, confidence, recent)
GET /stats/compare Viewer+ Side-by-side satellite comparison (species overlap, Jaccard index)
GET /stats/weather-correlation Viewer+ Weather + detection correlation (Open-Meteo)
GET /stats/migration Viewer+ Migration patterns (monthly species presence)
GET /stats/biodiversity Viewer+ Biodiversity indices (Shannon, Simpson, evenness per satellite)
GET /stats/expected-species Viewer+ Expected species at location (eBird integration)
GET /stats/activity-heatmap Viewer+ Hour×day heatmap (optional satelliteId filter)
GET /stats/satellite-activity Viewer+ Peak hours per satellite
GET /stats/map Viewer+ Satellite positions with detection summaries
GET /stats/map/detections Viewer+ Per-detection GPS points (filterable by days)
GET /stats/species-accuracy Viewer+ Per-species verification accuracy stats

Species

Method Path Auth Description
GET /species/languages Public Available translation languages
GET /species/translate Viewer+ Translate single species
POST /species/translate Viewer+ Bulk translate species names
POST /species/images Viewer+ Get image statuses (queues missing)
GET /species/image-gallery Viewer+ A species' photos in gallery order: display, square and full-resolution URLs (served by /species/image/by-key, which takes the same ?original=1, &maxBytes= and ?square=), the full-resolution copy's size as originalBytes hub 0.62.4, the credit and the licence
GET /species/image/proxy Viewer+ Serve species image from MinIO. ?original=1 for the full-resolution copy, with &maxBytes=N to get the display copy instead when the original is larger (the web asks with 8 MB); ?square=64|128|256|512 for a smart-cropped square positioned on the bird (JPEG; cached a week once the crop is settled, ten minutes while the photo still awaits analysis). Same ?square= on /species/image/by-key (gallery extras) and the public /species/showcase/image
GET /species/image-cache/stats Hub admin Image cache statistics
POST /species/image-cache/clear Hub admin Clear entire image cache
POST /species/image-cache/retry Hub admin Re-queue failed downloads

Webhooks

Webhook delivery is an alert-channel type; manage channels via /alert-rules/channels and see webhooks.md for payloads and X-BirdNet-Signature verification. The former standalone /webhooks routes were removed in hub 0.37.2.

Alert Rules

Method Path Auth Description
GET /alert-rules/rules Viewer+ List alert rules for tenant
POST /alert-rules/rules Admin+ Create alert rule
PUT /alert-rules/rules/:id Admin+ Update alert rule
DELETE /alert-rules/rules/:id Admin+ Delete alert rule

Trigger types: detection (on new detection matching conditions), absence (no activity for N minutes), trend (species diversity or detection count drop/increase)

Alert Channels

Method Path Auth Description
GET /alert-rules/channels Viewer+ List notification channels for tenant
POST /alert-rules/channels Admin+ Create notification channel
PUT /alert-rules/channels/:id Admin+ Update channel
DELETE /alert-rules/channels/:id Admin+ Delete channel
POST /alert-rules/channels/:id/test Admin+ Send test message

Channel types: email (SMTP), slack (Block Kit), telegram (Bot API), google_chat (Cards v2), discord (rich embeds), webhook (generic with HMAC)

Scheduled Exports

Method Path Auth Description
GET /exports/scheduled Admin+ List scheduled exports for tenant (their delivery holds addresses and webhook URLs)
POST /exports/scheduled Admin+ Create scheduled export
PUT /exports/scheduled/:id Admin+ Update scheduled export
DELETE /exports/scheduled/:id Admin+ Delete scheduled export

Formats: csv, json, ebird, inaturalist Schedule: cron expression (e.g. 0 6 * * 1 for weekly Monday 6am) Delivery: via alert channel (email attachment or webhook POST)

Calibration hub 0.30

Per-(tenant, model) isotonic-regression confidence calibration. Inputs: confirmed/rejected verification votes. Output: a monotonic mapping from raw confidence buckets to calibrated probabilities, stored in confidence_calibration (migration 069).

Method Path Auth Description
POST /calibration/refresh Admin+ Refit the curve for (tenantId, modelId). Body: {tenantId?, modelId?} (admin only). Hub admin can refresh all pairs
GET /calibration/curve Viewer+ Return the bucket array for ?tenantId&modelId
GET /calibration/coverage Admin+ Verified-sample counts per (tenant, model), so the UI can show "ready / not enough data"

GET /detections already decorates each row with calibrated_confidence (bulk-loaded once per request); single-detection callers read it directly from the row, not from this endpoint.

Mobile Releases

Method Path Auth Description
GET /mobile/latest Public Newest APK metadata {updateAvailable, version, downloadUrl, fileSize, sha256, forceUpdate, notes}. Mobile app polls this on launch
GET /mobile/download/:id Public Stream the APK. Used by both the mobile in-app updater and the hub UI download buttons
GET /mobile/releases Hub admin List all uploaded APKs (most recent 20)
POST /mobile/upload Hub admin Multipart upload. Fields: apk file, version, notes, forceUpdate
PUT /mobile/releases/:id Hub admin Toggle force_update
DELETE /mobile/releases/:id Hub admin Delete the APK from MinIO and the row

Workers

Method Path Auth Description
GET /workers Admin+ Queue stats + connected workers (IP-based dedup). Each worker reports jobsProcessed, jobsSucceeded, jobsFailed, avgDurationMs
GET /workers/jobs Admin+ Recent completed jobs

Settings

Method Path Auth Description
GET /settings/tenant Viewer+ Tenant settings
GET /settings/effective?satelliteId= | ?tenantId= | ?level=hub | ?level=account[&tenantId=] Viewer+ of the satellite's or the team's team; hub admins for the hub; a person for their own preferences (tenantId = the team they act in, for what they inherit from it) Every setting of the registry at that scope (hub 0.65+, team and hub scopes 0.66+): {level, tenantId?, satelliteId?, settings:[{key, value, source: hub|team|satellite|default, inherited:{value, from}, lockedBy: hub|team|null, own, mayOverride?}]} in the registry's order. inherited is what the scope gets by setting nothing, own its own value (null = follows the level above), mayOverride its own lock where it can lock one. Satellite keys are refused (403)
PATCH /settings/effective?satelliteId= | ?tenantId= | ?level=hub | ?level=account The device rule and admin-only settings; admin+ of the team; hub admins; a person for their own preferences Change only what is sent at that scope hub 0.66: {values?: {key: value | null}, mayOverride?: {key: boolean}}, null = follow the level above. All or nothing: 400 with refusals for an unknown key, a level that cannot hold it, the wrong type or a value outside the registry's range; 403 LOCKED_BY_PLATFORM / LOCKED_BY_TEAM for a new value under a lock (keeping or clearing the stored one is allowed). Audited; history for every satellite reached and a push to those whose configuration changed. Answers like the GET
PUT /settings/tenant Admin+ Update team settings; only the fields sent change. Registry fields (the team's columns of the settings registry, null = follow the hub) go through the settings writer first, with its checks and locks; device_inference_devices_may_override is still accepted as the team's lock on on-satellite inference. The GET no longer carries device_inference_platform (hub 0.66+; read GET /settings/effective?tenantId=)
GET /settings/platform Hub admin Hub settings
PUT /settings/platform Hub admin Update hub settings. Keys that are the settings registry's hub values go through the settings writer and reach every satellite of every team; device_inference_teams_may_override is still accepted as the hub's lock on on-satellite inference

Tenants

Method Path Auth Description
GET /tenants Authenticated List tenants
POST /tenants/:id/leave Session Leave a team. Owners get 400 OWNER_CANNOT_LEAVE (transfer ownership first). The leaver's devices are disconnected and become team-owned
GET /public/teams/:slug?lang= Public A team's public page hub 0.53: name, description, since, counts (species, detections, devices), top species with credited photos, recent firsts by day, 30 daily totals, towns only if the team opted in, and a join hint when the team is joinable. Promoted detections and the team's own category visibility only. 404 unless the hub switch allow_public_pages AND the team's public_page are on. Cached 10 min, dropped on any settings save
GET /public/teams/:slug/og Public Link-preview HTML for bots (nginx rewrites /t/:slug here for bot user agents); every value escaped
GET /tenants/directory?search= Session Teams that accept newcomers (listed, request or open). Name, description, policy, member and device counts, plus the caller's is_member / has_pending. { enabled: false, teams: [] } while the hub switch allow_public_teams is off hub 0.52
GET /tenants/:id/join Session One team's public card, for a shared link (works for unlisted teams). 404 when the team is invite-only, the caller is blocked, or the hub switch is off
POST /tenants/:id/join Session open: join at once with the team's default_join_role (200 {status:"joined"}). request: create a pending request and mail the team's admins (202 {status:"requested"}). 409 ALREADY_MEMBER / ALREADY_REQUESTED. Body { message? } (500 chars). Rate-limited
DELETE /tenants/:id/join Session Withdraw the caller's pending request
GET /tenants/:id/join-requests Admin+ of that team Pending requests with name, email, message
POST /tenants/:id/join-requests/:rid/approve Admin+ of that team Body { role?: "viewer" | "member" } (default: the team's default_join_role; never admin). Mails the requester
POST /tenants/:id/join-requests/:rid/deny Admin+ of that team Body { block?: boolean }. No mail is sent on a refusal
GET / DELETE /tenants/:id/blocks[/:userId] Admin+ of that team People blocked from asking or joining; lift a block. An admin's invite also lifts it

Admin (hub admins only)

Method Path Description
GET /admin/users List all users (search, status, tenant filters)
GET /admin/users/:id User detail
PUT /admin/users/:id Update user (name, email, admin flag)
POST /admin/users/:id/block Block/unblock user
POST /admin/users/:id/reset-password Reset password
DELETE /admin/users/:id Delete user: the same erasure as self-deletion (satellites released and disconnected, email and IP removed from the audit log). 409 when the person is a team's sole owner; hub admins cannot be deleted
POST /admin/users/:id/tenants Add user to tenant (409 if already member). A team has one owner: owner only for a team without one, else 409 TEAM_HAS_OWNER (hand it over with the transfer below)
DELETE /admin/users/:id/tenants/:tid Remove from tenant. ?devices=keep leaves the person's devices running for the team; the default disconnects them (keys expired, broker login dropped, device becomes team-owned)
PUT /admin/members/:id/role Admins of the member's team (not only hub admins): viewer, member or admin. An admin's role is the owner's to change (403 OWNER_ONLY, hub 0.71+); the owner's never changes here (400)
DELETE /admin/members/:id Admins of the member's team: remove a member. An admin: the owner only (403 OWNER_ONLY); the owner: never (400)
POST /admin/members/:id/transfer-ownership The team's owner, or a hub admin. The previous owner becomes admin and the member owner, in one transaction (a unique index keeps one owner per team, migration 102). 400 if the member already owns the team
GET /admin/audit-log Audit log (paginated)

User list query params: ?search=, ?status=active|blocked, ?tenantId=

Audit Log

All audit log entries include the real client IP address (via X-Forwarded-For, with trustProxy enabled on Fastify).

Each route that changes something declares the entries it writes (config.audit, hub 0.72+) and writes them through request.audit(), which records who acted, from which address, and in which team; a team's entries also feed its activity page. The list below is generated from services/audit.ts and the audit log page's labels.

user

Action Label
user.login Login
user.login_failed Login failed
user.logout Logout
user.register Registered
user.profile_updated Profile updated
user.password_changed Password changed
user.password_reset_requested Password reset requested
user.password_reset_completed Password reset completed
user.password_reset_failed Password reset failed
user.account_deleted Account deleted
user.preferences_updated Preferences updated
user.mfa_challenge_issued MFA challenged
user.mfa_login_failed MFA login failed

admin

Action Label
admin.user_blocked User blocked
admin.user_unblocked User unblocked
admin.user_deleted User deleted
admin.password_reset Password reset
admin.user_edited User edited
admin.user_added_to_tenant Added to team
admin.user_removed_from_tenant Removed from team
admin.member_role_changed Role changed
admin.member_removed Member removed
admin.join_request_approved Join request approved
admin.join_request_denied Join request declined
admin.member_unblocked Block lifted
admin.invite_created Invite created
admin.invite_revoked Invite revoked
admin.tenant_created Team created
admin.image_cache_cleared Image cache cleared
admin.image_recrop_started Crops rerun
admin.satellite_deleted Satellite deleted
admin.satellite_archived Satellite archived
admin.satellite_unarchived Satellite unarchived
admin.satellite_renamed Satellite renamed
admin.satellite_transferred Satellite moved
admin.satellite_profile_changed Profile changed
admin.satellite_config_changed Config changed
admin.satellite_update_requested Update requested
admin.satellite_api_key_rotated Satellite key rotated
admin.alert_rule_created Alert rule created
admin.alert_rule_updated Alert rule updated
admin.alert_rule_deleted Alert rule deleted
admin.alert_channel_created Alert channel created
admin.alert_channel_updated Alert channel updated
admin.alert_channel_deleted Alert channel deleted
admin.tenant_settings_updated Team settings
admin.platform_settings_updated Hub settings
admin.scheduled_export_created Scheduled export created
admin.scheduled_export_updated Scheduled export updated
admin.scheduled_export_deleted Scheduled export deleted
admin.retention_run Retention run
admin.mobile_release_uploaded Mobile release uploaded
admin.mobile_release_deleted Mobile release deleted
admin.birdnet_model_uploaded Model installed
admin.birdnet_model_set_default Default model changed
admin.birdnet_model_deleted Model deleted
admin.api_key_created API key created
admin.calibration_refreshed Calibration refitted
admin.mobile_release_updated Mobile release changed
admin.birdnet_model_updated Model changed
admin.satellite_logs_requested Satellite logs fetched

member

Action Label
member.left_team Member left the team
member.joined_team Joined a team
member.join_requested Asked to join
member.join_request_withdrawn Join request withdrawn

device

Action Label
device.satellite_unarchived_by_heartbeat Satellite back from the archive
device.satellite_replaced Satellite replaced
device.satellite_config_requested Satellite changed its settings
device.satellite_config_rejected_locked Satellite settings refused
device.satellite_registered Satellite registered
device.satellite_reregistered Satellite registered again

detection

Action Label
detection.voted Voted
detection.annotated Annotated
detection.pinned Pinned
detection.unpinned Unpinned
detection.comment_deleted Comment deleted

auth

Action Label
auth.mfa_enabled MFA enabled
auth.mfa_enrollment_failed MFA enrollment failed
auth.mfa_disabled_self MFA disabled
auth.mfa_backup_code_used Backup code used
auth.mfa_backup_codes_regenerated Backup codes regenerated
auth.mfa_passkey_added Passkey added
auth.mfa_passkey_removed Passkey removed
auth.mfa_reset_by_admin MFA reset (admin)
auth.mfa_device_revoked Remembered device revoked

field_note

Action Label
field_note.deleted Field note deleted

satellite

Action Label
satellite.listened_live Listened live

WebSocket

Connect to /ws/events?tenantId=<id> for real-time events (signed in; a socket stays within its team, hub admins excepted).

Live listening: ?listenSatelliteId=<id> at connection, or a {"type":"live-start","satelliteId":"<id>"} message, streams that satellite's microphone. It needs member or above in the satellite's own team, which must be the socket's team (hub 0.64.5; before, the client named the team and a member of another team could join the session). Any team the client sends is ignored.

Event types:

  • detection — new bird detected (species, confidence, satellite, rare flag)
  • alert — system alert (rare species, satellite offline, etc.)

Toast format: Rare species notifications show translated names (primary + secondary languages).

System

Method Path Auth Description
GET /system/status Hub admin Full hub status: all service instances, infrastructure, data summary
GET /system/versions Viewer+ Latest SemVer per stream: {hub, satellite, mobile}. Satellites + SatelliteDetail pages compare a satellite's heartbeat-reported version against the matching stream's target

Response includes:

  • Services: each registered instance (API, dispatcher, web, workers, satellites) with status, version, uptime, memory usage, IP address, and service-specific details (e.g., per-worker job stats: processed, succeeded, failed, avg duration)
  • Infrastructure: PostgreSQL, Redis, MQTT, MinIO connectivity and status
  • Data summary: detection counts, inference queue stats, species image totals, tenant and user counts

Service instances are discovered via Redis service registry (birdnet:registry:{service}:{instanceId}, 30s TTL).

Health

Method Path Auth Description
GET /health Public Returns { status, version, timestamp }