# REST & WebSocket API

Base path: `/api/v1`  
Auth: `Authorization: Bearer <access_or_service_jwt>`  
Correlation: `X-Request-Id` (generated if absent; UUID v7)

Error body:

```json
{
  "code": "validation",
  "message": "…",
  "request_id": "…"
}
```

## Health & metrics

| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/health` | no | Aggregate health |
| GET | `/ready` | no | Dependency readiness |
| GET | `/live` | no | Process liveness |
| GET | `/metrics` | no* | Prometheus scrape |

\* Restrict at ingress in production.

## Notifications

### `POST /notifications`

Create + enqueue fan-out.

```json
{
  "source": "exam-engine",
  "event_type": "exam.completed",
  "title": "Exam graded",
  "body": "You scored 92",
  "data": { "score": 92 },
  "priority": "normal",
  "channels": ["in_app", "toast", "realtime"],
  "recipient_ids": ["018f…"],
  "scheduled_at": null,
  "expires_at": null
}
```

Scopes: user (self recipients) or service `notifications:write`.

### `GET /notifications?cursor=&limit=`

In-app feed for current user (cursor on receiver id).

### `POST /notifications/{receiver_id}/read`

### `POST /notifications/read-all`

### `GET /notifications/unread-count`

→ `{ "count": 3 }`

## Events

### `POST /events`

Ingest domain event onto the bus.

```json
{
  "event_type": "wiki.updated",
  "source": "wiki",
  "version": 1,
  "data": { "article_id": "…" },
  "priority": "normal",
  "archive": true
}
```

Required scope: `events:publish` (service token).

## Topics & subscriptions

| Method | Path |
|--------|------|
| POST | `/topics` |
| GET | `/topics/{name}` |
| POST | `/subscriptions` |
| GET | `/subscriptions` |
| DELETE | `/subscriptions/{id}` |

## Presence

| Method | Path |
|--------|------|
| GET | `/presence/{user_id}` |
| GET | `/presence?scope=room:abc` |

## Devices

| Method | Path |
|--------|------|
| POST | `/devices` |
| DELETE | `/devices` body `{ "token": "…" }` |

## Webhooks

| Method | Path |
|--------|------|
| POST | `/webhooks` |
| GET | `/webhooks` |
| DELETE | `/webhooks/{id}` |

Create body includes `url`, `secret` (≥16), `event_filters`, `name`.

## Broadcast

### `POST /broadcast`

Service/admin only. Pushes a frame to a channel or all local connections.

```json
{
  "channel": "topic:system",
  "payload": { "type": "announcement", "text": "Maintenance 02:00 UTC" }
}
```

---

# WebSocket

Connect: `GET /ws?token=<jwt>`  
Or connect without token and send:

```json
{ "type": "auth", "token": "<jwt>" }
```

## Client → server

| type | fields |
|------|--------|
| `auth` | `token` |
| `subscribe` | `channel` (`user:…` / `room:…` / `topic:…`) |
| `unsubscribe` | `channel` |
| `heartbeat` | — |
| `presence.update` | `status` |
| `join.room` | `room_id` |
| `join.topic` | `topic` |
| `reconnect` | `session_id` |

## Server → client

| type | purpose |
|------|---------|
| `heartbeat.ack` | keepalive |
| `notification` | toast / realtime notification |
| `event` | bus/realtime event frame |
| `presence` | presence diff |
| `error` | protocol/auth error |

Heartbeat interval: client every ~15–20s; server may evict after ~45s (aligned with Redis presence TTL).

## QMF mapping

| QMF module | API surface |
|------------|-------------|
| qm-toast | WS `notification` frames + `channels: ["toast"]` |
| qm-notification-center | REST notifications + unread |
| qm-realtime | WS subscribe / rooms / topics |
| qm-presence | REST presence + WS presence.update |
