Files
Notizarr/docs/phase1/04-api-design.md
T
2026-09-07 09:54:30 +00:00

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.

Auth

  • GET /api/v1/auth/oidc/login → redirects to IdP (PKCE)
  • GET /api/v1/auth/oidc/callback → validates, creates session, redirects to PUBLIC_BASE_URL
  • POST /api/v1/auth/logout
  • GET /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, pushEnabled for this user
  • GET /api/v1/topics/:slug
  • PATCH /api/v1/topics/:slug/subscription { "pushEnabled": true }

Notifications

  • GET /api/v1/notifications?topic=&since=&before=&unread=&starred=&q=&limit=&cursor=
  • GET /api/v1/notifications/:id
  • PATCH /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-key
  • POST /api/v1/push/subscribe { endpoint, keys: { p256dh, auth }, userAgent? }
  • GET /api/v1/devices → the current user's push_subscriptions
  • DELETE /api/v1/devices/:id
  • GET /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/:id
  • GET/POST /api/v1/admin/tokens, POST /api/v1/admin/tokens/:id/rotate, DELETE /api/v1/admin/tokens/:id
    • POST response includes the plaintext token once: { token: "nzr_...", ...record }
  • GET /api/v1/admin/users, PATCH /api/v1/admin/users/:id { role? }
  • GET /api/v1/admin/devices
  • GET /api/v1/admin/stats/dashboard → today's count, 24h count, push attempts/failures, active devices, users, topics, top topics, noisiest sources, recent errors
  • GET /api/v1/admin/sources → per-source volume + noisy-source flags
  • GET /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 /ready
  • GET /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 /health
  • GET /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.