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 tenantsatelliteId— filter by satellitespecies— filter by species codespeciesSearch— search by common or scientific name (ILIKE)minConfidence— minimum confidence thresholdverificationStatus—pending_review,confirmed,rejected,unverifiedsortBy— sort field:date(default),confidence,speciessortDir— sort direction:ascordesc(default)limit,offset— paginationstart,end: time window (ISO, end exclusive); all time when absentincludeTentative:trueto include detections still waiting on the temporal-aggregation rulewithCount— set tofalseto skip the multi-secondCOUNT(*)over the visibility-filtered table when the caller doesn't need pagination (Dashboard recent-detections, Verification queue). Whenfalse,totalisnullin 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 tenantsatelliteId— filter by satellitefilter— session type:with-detections,empty, or omit for allsortBy— sort field:date(default),duration,detections,species,chunkssortDir— sort direction:ascordesc(default)limit,offset— pagination
Response: { sessions: [...], total: number }
Query params for GET /sessions/timeline:
tenantId— filter by tenantsatelliteId— filter by satellitefrom,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.nullfollows 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 } |