# APS Reg-Dashboard — Public REST API

**Version:** 4.1
**Base URL (prod):** `https://reg-dashboard.austrianpharmaservices.com/api/index.php`
**Base URL (staging):** `https://staging.reg-dashboard.austrianpharmaservices.com/api/index.php`
**Auth:** `Authorization: Bearer aps_<prefix>_<secret>` (recommended) or session cookie (browser)

---

## 1. Issuing an API key

API keys are machine credentials issued by the dashboard owner per integration. Each key has:
- A **name** (e.g. *eQMS-Pharmosan-prod*)
- A **scopes** string (default `read:events,read:stats`)
- A **rate limit** (`rate_limit_per_min`, default 60)
- An optional **expires_at**

Issue a key via the Admin Panel → API Keys, or programmatically:
```bash
curl -X POST "$BASE/api/index.php?action=api_keys_create" \
  -H "Authorization: Bearer $JWT_USER_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"eQMS-Pharmosan-prod","scopes":"read:events,read:stats"}'
```
The response includes the **plaintext token exactly once** — store it. The hash is persisted; the plaintext is never recoverable.

Revoke:
```bash
curl -X POST "$BASE/api/index.php?action=api_keys_revoke" \
  -H "Authorization: Bearer $JWT_USER_TOKEN" -d '{"id":42}'
```

---

## 2. Endpoints

### 2.1 Events listing
```
GET ?action=events_public
Authorization: Bearer aps_xxxxxxxx_xxxxxxxx...
```
Query parameters:
| Name | Type | Description |
|------|------|-------------|
| `limit` | int (1–5000) | Page size, default 100 |
| `offset` | int | Skip N rows |
| `page` | int | Alternative to offset (1-based) |
| `agency` | string | Filter by agency code (e.g. `MAUDE`, `MHRA-DSU`) |
| `category` | string | Filter by `product_category` |
| `severity` | string | `critical` / `high` / `medium` / `low` |
| `event_type` | string | Filter by event type |
| `search` | string | Substring across title, description, product_name |
| `days` | int | Limit to last N days by `published_date` |
| `since` | ISO-8601 timestamp | Only events scraped after this timestamp (incremental pull pattern; recommended for eQMS sync). Example: `since=2026-04-28T00:00:00Z`. |

Response:
```json
{ "events": [ { "id":"...", "agency":"MAUDE", "title":"...", "severity":"high", ... } ],
  "total": 90638, "limit": 100, "offset": 0 }
```

### 2.2 Single event
```
GET ?action=event_public&id=<uuid>
```

### 2.3 Stats
```
GET ?action=stats_public
```
Returns aggregate counts by agency, severity, and category for the last 30 / 90 days.

### 2.4 Reserved (Phase 4.2)
- `events_search` (full-text) — currently routed but tier-gated
- `udi_lookup` — JSON_SEARCH on raw_data, requires `read:devices` scope

---

## 3. Authentication

Three equivalent ways to authenticate machine traffic:

**A) Bearer token (recommended)** — issue via `api_keys_create`. Used in the `Authorization` header:
```
Authorization: Bearer aps_a1b2c3d4_<48 hex chars>
```

**B) OAuth2 client_credentials grant** (Sprint Day 10 / P2.3) — for clients that prefer the OAuth2 flow:
```bash
# Step 1: exchange client_id + client_secret for an access token
curl -X POST $BASE/api/index.php?action=oauth_token \
  -H "Content-Type: application/json" \
  -d '{"grant_type":"client_credentials",
       "client_id":"a1b2c3d4",
       "client_secret":"aps_a1b2c3d4_<48 hex chars>"}'
# → {"access_token":"aps_a1b2c3d4_…","token_type":"Bearer","expires_in":86400,"scope":"read:events,read:stats"}

# Step 2: use the access_token like any Bearer
curl -H "Authorization: Bearer $TOKEN" "$BASE/api/index.php?action=events_public&limit=3"
```
- `client_id` is your API key's prefix (the 8-char block after `aps_`).
- `client_secret` is the full token. The endpoint just verifies and echoes it back as the access_token (RFC 6749 §4.4.3).
- expires_in = 86400 (24h). Re-mint daily.

**C) Session cookie** — for browser-driven traffic, set automatically on login. Not suitable for server-to-server.

Without any, public endpoints fall back to **IP-based rate limiting** (free tier: 500 calls/day per IP).

---

## 4. Rate limiting

Headers returned on every response:
- `X-RateLimit-Limit` — daily quota
- `X-RateLimit-Remaining` — remaining calls today
- `X-RateLimit-Reset` — epoch seconds until counter resets

| Tier | Daily limit | Burst |
|------|-------------|-------|
| Public IP (anonymous) | 500/day | 20 |
| Free user JWT | 200/day | 20 |
| Starter | 5,000/day | 50 |
| Pro | 30,000/day | 200 |
| Enterprise | 100,000/day | 500 |
| **API key (machine-to-machine)** | **10,000/day** | **100** |

429 responses include a `retry_after` (seconds).

---

## 5. Outbound webhooks

Dashboard owners can subscribe a downstream URL to receive events as they're scraped.

### 5.1 Subscribe
```bash
curl -X POST "$BASE/api/index.php?action=webhook_subs_create" \
  -H "Authorization: Bearer $JWT" -H "Content-Type: application/json" \
  -d '{
    "name": "eQMS Pharmosan CAPA",
    "target_url": "https://eqms.pharmosan.example.com/webhooks/capa-candidates",
    "preset": "eqms_capa",
    "event_filter": { "severity": ["critical","high"], "product_category": ["medical_device","ivd"] }
  }'
```
Response includes a one-time `secret` — used to verify HMAC signatures on inbound deliveries.

### 5.2 Delivery format
Every POST carries:
```
X-APS-Event:     <event-id> | "test"
X-APS-Timestamp: <epoch-seconds>
X-APS-Signature: <hex(hmac_sha256(secret, timestamp + "." + body))>
X-APS-Preset:    <generic|eqms_capa|slack|teams|msteams_card>
```

### 5.3 Verify a delivery (Python)
```python
import hmac, hashlib, json, time
def verify(req_body: bytes, ts: str, sig: str, secret: str, max_skew=300) -> bool:
    if abs(int(time.time()) - int(ts)) > max_skew:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + req_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sig)
```

### 5.4 Presets

#### `generic` (default)
Raw event JSON: `{event_id, agency, event_type, severity, product_category, product_name, title, description, event_date, source_url, language, delivered_at, aps_version}`.

#### `eqms_capa` (eQMS connector)
Pre-shaped for eQMS-Future CAPA candidate creation:
```json
{
  "kind": "capa_candidate",
  "source_system": "aps-reg-dashboard",
  "source_event_id": "...",
  "priority": "high",         // mapped from severity
  "subject": "[MAUDE] ...",
  "description": "...",
  "reference_url": "https://...",
  "product_scope": "...",
  "category": "medical_device",
  "origin_date": "2026-04-26",
  "raw": { /* the generic payload */ }
}
```

#### `slack`
Slack-compatible message with attachment fields and severity-coloured bar.

#### `msteams_card`
MessageCard `@type: MessageCard` payload for Teams incoming-webhooks.

### 5.5 Reliability
- **Timeout:** 5s connect, 10s total
- **Retry:** Phase 4.2 will add exponential backoff. Phase 4.1 = single attempt; downstream MUST be idempotent on `event_id`.
- **Auto-pause:** subscriptions with 10 consecutive failures auto-flip `is_active=0`.
- **Audit:** every delivery is logged in `outbound_webhook_deliveries`. Query via `webhook_subs_deliveries&id=<sub_id>`.

---

## 6. PRRC workflow API (alert_*)

Events carry a per-user PRRC state. All require user JWT (no API-key path yet).

| Action | Method | Required params |
|--------|--------|-----------------|
| `alert_ack` | POST | `event_id` |
| `alert_snooze` | POST | `event_id`, `hours` ∈ {1,4,24,168} |
| `alert_dismiss` | POST | `event_id` |
| `alert_assign` | POST | `event_id`, `assignee` |
| `alert_close` | POST | `event_id`, `reason` |
| `alert_status` | GET/POST | `event_ids` (comma-sep or JSON array, max 500) |

Status response per event:
```json
{ "action": "assign", "assignee": "qm@firma.de", "snooze_until": null,
  "snoozed_active": false, "note": null, "close_reason": null,
  "created_at": "2026-04-27T10:42:11Z" }
```

---

## 7. Source SLA & freshness

See [/docs/SLA.md](./SLA.md) for per-agency cron cadence, target latency, and incident-response policy. Public API consumers should treat events as "data on hand" — verify against the source URL before any regulatory action.

---

## 8. Versioning

Breaking changes will bump the major version. Phase 4.1 → `aps_version: "4.1"` in every webhook payload. Read endpoints currently have no version path; this will be introduced as `/api/v2/...` in Phase 5.
