# 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 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`: ```json { "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.