5.8 KiB
04 — API Design
Two listeners, two auth methods, never overlapping. JSON everywhere except the ntfy-compat endpoint, which also accepts headers/query params for compatibility. All responses use UTC ISO-8601 timestamps.
Listener A — Web API — :8080 — session-cookie auth
Auth
GET /api/v1/auth/oidc/login→ redirects to IdP (PKCE)GET /api/v1/auth/oidc/callback→ validates, creates session, redirects toPUBLIC_BASE_URLPOST /api/v1/auth/logoutGET /api/v1/session→{ user: { id, email, displayName, role }, authMode }
In AUTH_MODE=proxy, there is no login/callback — the session is derived
per-request from trusted headers (see 02-threat-model.md), with a
lightweight session record still created so read/star state etc. behave the
same.
Topics
GET /api/v1/topics→ list of topics visible to the current user, each with unread/total counts, latest timestamp,pushEnabledfor this userGET /api/v1/topics/:slugPATCH /api/v1/topics/:slug/subscription{ "pushEnabled": true }
Notifications
GET /api/v1/notifications?topic=&since=&before=&unread=&starred=&q=&limit=&cursor=GET /api/v1/notifications/:idPATCH /api/v1/notifications/:id{ "read"?: bool, "starred"?: bool }GET /api/v1/search?q=&topic=&source=&from=&to=&unread=&starred=&limit=&cursor=GET /api/v1/stream— SSE,text/event-stream, events:notification.created,notification.updated
Push / devices
GET /api/v1/push/vapid-public-keyPOST /api/v1/push/subscribe{ endpoint, keys: { p256dh, auth }, userAgent? }GET /api/v1/devices→ the current user'spush_subscriptionsDELETE /api/v1/devices/:idGET /api/v1/push/diagnostics→ server-known state for the current user's devices (last delivery attempt status per device, VAPID configured: bool)
Admin (role = admin)
GET/POST /api/v1/admin/topics,PATCH/DELETE /api/v1/admin/topics/:idGET/POST /api/v1/admin/tokens,POST /api/v1/admin/tokens/:id/rotate,DELETE /api/v1/admin/tokens/:idPOSTresponse includes the plaintext token once:{ token: "nzr_...", ...record }
GET /api/v1/admin/users,PATCH /api/v1/admin/users/:id{ role? }GET /api/v1/admin/devicesGET /api/v1/admin/stats/dashboard→ today's count, 24h count, push attempts/failures, active devices, users, topics, top topics, noisiest sources, recent errorsGET /api/v1/admin/sources→ per-source volume + noisy-source flagsGET /api/v1/admin/audit-log?...GET/PATCH /api/v1/admin/settings→ auth mode status, OIDC status, trusted proxies, public base URL, retention defaults, VAPID public key + configured status, rate-limit config
Meta
GET /health,GET /readyGET /docs— OpenAPI/Swagger UI (generated from Fastify schemas)
Listener B — Ingestion API — :8081 — Authorization: Bearer nzr_... only
No cookies accepted or set. No static assets served. Rejects any request
whose peer IP is outside INGESTION_ALLOWED_CIDRS (default: RFC1918) before
even checking the token, to fail closed cheaply.
Canonical publish
POST /api/v1/notifications
Authorization: Bearer nzr_xxxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
{
"topic": "homeassistant",
"title": "🏠 Home",
"message": "Office window is open",
"source": "homeassistant",
"url": null,
"image_url": null,
"deduplication_key": null
}
201 Created:
{ "id": "b6e2...", "status": "accepted" }
Also:
POST /api/v1/topics/{slug}
same body, topic field omitted (taken from the path).
ntfy-compatibility
POST /:topic
Accepts, in order of precedence: JSON body (ntfy-shaped: topic implied
by path, title, message, tags, click, attach, filename,
priority), headers (Title, Tags, Click, Attach, Filename,
Priority), or query params. Priority/priority is accepted and stored
in raw_priority for compatibility but never affects push behavior —
only the topic's push setting does. Plain-text request body (as ntfy
supports) is treated as message.
Webhook
POST /api/v1/webhook/:source
MVP mapping: JSON body { topic, title, message, url? }. :source becomes
the notification's source. Unrecognized shapes return 422 with a
description of the expected shape (configurable field-mapping rules are a
documented future enhancement, not MVP).
Meta
GET /healthGET /metrics— Prometheus exposition, LAN-only by design (see below)
Rate limiting (listener B)
In-memory token bucket per api_tokens.id and per source IP, e.g.:
- sustained: 60 requests/minute
- burst: 20 requests/10 seconds
Exceeding either returns 429 Too Many Requests with
{ "error": "rate_limited", "retryAfterSeconds": N } and a matching
Retry-After header. Never silently drops — always logged and, if the
window is exceeded by a wide margin (configurable multiplier), an
audit_log entry with action: "rate_limit.rejected" is written so it
surfaces as a "noisy source" warning on the admin dashboard.
Deduplication
If deduplication_key is present and a notification with the same
(topic_id, deduplication_key) exists within the configured window
(default 5 minutes), the existing row's occurrence_count is incremented
and updated_at bumped instead of inserting a new row. Whether the bumped
duplicate re-triggers a push is a per-topic behavior documented in Phase 5
(default: suppress duplicate push, always update history) — kept simple and
optional per the spec.
Metrics (Prometheus, listener B only)
notizarr_notifications_received_total{topic="..."}
notizarr_notifications_by_source_total{source="..."}
notizarr_push_attempts_total
notizarr_push_failures_total
notizarr_active_push_subscriptions
notizarr_rate_limited_requests_total
No message bodies or user-identifying values ever appear in a metric label.