# Worksonar REST-API v1

Programmgesteuerter Zugriff auf Kampagnen, Antworten, Teilnehmer und Auswertungen. JSON, Bearer-Auth, **Premium-Tarif**.

> Diese Datei ist die offline-Variante der Online-Anleitung unter `/docs/api`. Beide werden aus derselben Quelle gepflegt.

---

## Inhalt

1. [Einleitung](#1-einleitung)
2. [Base-URL & Versionierung](#2-base-url--versionierung)
3. [Authentifizierung](#3-authentifizierung)
4. [Scopes](#4-scopes)
5. [Rate-Limits](#5-rate-limits)
6. [Fehler-Format](#6-fehler-format)
7. [Paginierung](#7-paginierung)
8. [Anonymitäts-Schutz](#8-anonymitaets-schutz)
9. [Endpoints](#9-endpoints)
10. [End-to-End-Beispiele](#10-end-to-end-beispiele)
11. [Clients in 3 Sprachen](#11-clients-in-3-sprachen)
12. [Changelog](#12-changelog)

---

## 1. Einleitung

Die Worksonar-API ist eine schlanke REST-HTTP-API. Sie spricht ausschließlich JSON und nutzt Bearer-Tokens. Alle Daten sind **mandantenspezifisch isoliert**: ein Token kann nur auf die Daten des Mandanten zugreifen, für den er ausgestellt wurde.

Verfügbar im **Premium**-Tarif. Tokens erzeugen Sie unter **Tenant-Portal → API** (`/tenant/api`).

## 2. Base-URL & Versionierung

| Umgebung | Base-URL |
|---|---|
| Self-hosted | `<APP_BASE_URL>/api/v1` |
| Hosted (Beispiel) | `https://app.worksonar.de/api/v1` |

Versionierung erfolgt über das URL-Präfix (`/v1`). Breaking Changes erscheinen ausschließlich in einer neuen Major-Version mit mindestens 6 Monaten Parallelbetrieb.

## 3. Authentifizierung

Jeder Request muss einen Bearer-Token im `Authorization`-Header tragen. Tokens haben das Format `wsk_` + 32 base64-url-Zeichen.

```
Authorization: Bearer wsk_kpYHa3-Z9c1m...x9F
```

Token erzeugen Sie unter `/tenant/api`. Der **Klartext-Token wird nur einmal angezeigt** — danach kennt das System nur einen SHA-256-Hash. Vergessen Sie den Token, erzeugen Sie einen neuen und widerrufen den alten.

## 4. Scopes

Pro Token wählen Sie eine oder mehrere Scopes:

| Scope | Was erlaubt |
|---|---|
| `read` | Alle GET-Endpoints (Lesezugriff) |
| `write` | POST · PATCH · DELETE (Schreibzugriff) |

Fehlt ein erforderlicher Scope, antwortet der Server mit `403 insufficient_scope`.

## 5. Rate-Limits

Pro Token gelten standardmäßig **60 Requests / Minute** in einem rollenden Fenster. Bei Überschreitung:

```
HTTP/1.1 429 Too Many Requests
Retry-After: 60

{"error":{"code":"rate_limited","message":"Rate limit exceeded (60 requests / minute)."}}
```

Höhere Limits sind im Premium-Tarif vertraglich verhandelbar.

## 6. Fehler-Format

Alle Fehler folgen einem einheitlichen Schema:

```json
{
  "error": {
    "code":    "validation",
    "message": "Required: title, module.",
    "details": { "missing": ["title"] }
  }
}
```

| HTTP | Typische Codes |
|---|---|
| 400 | `validation` |
| 401 | `unauthenticated` · `invalid_token` · `expired_token` |
| 402 | `plan_required` (kein Premium-Tarif) |
| 403 | `insufficient_scope` · `plan_restriction` · `plan_limit` · `tenant_inactive` |
| 404 | `not_found` |
| 422 | `anonymity_protection` (n<5) · `campaign_inactive` |
| 429 | `rate_limited` |

## 7. Paginierung

List-Endpoints akzeptieren `?limit=N&offset=M` und liefern:

```json
{
  "items":  [],
  "total":  427,
  "limit":  50,
  "offset": 100
}
```

Defaults: `limit=50` (Antworten/Kampagnen) bzw. `100` (Teilnehmer), Maximum `500` bzw. `1000`.

## 8. Anonymitäts-Schutz

Der Endpoint `/analysis/{campaign_id}` liefert **keine** aggregierten Ergebnisse, wenn nach Anwendung der Filter weniger als 5 Antworten übrig bleiben. Statt eines Teilergebnisses kommt:

```
HTTP/1.1 422
{"error":{"code":"anonymity_protection","message":"Less than 5 responses (n=3). Analysis withheld per anonymity rule n>=5."}}
```

Der `/responses`-Endpoint liefert dagegen alle Rohdaten — die Verantwortung für die Anonymisierung liegt dort beim Konsument.

## 9. Endpoints

### `GET /me` (scope `read`)

Identifikation des Tokens und Mandanten. Ideal zum Smoke-Testen.

```bash
curl -H "Authorization: Bearer $WSK" $BASE/me
```

```json
{
  "tenant":   { "id":"6f...","name":"Mustermann Logistik AG","slug":"mustermann-logistik","industry":"Logistik & Transport","plan":"enterprise" },
  "token":    { "name":"CRM-Sync","prefix":"wsk_kpYHa3","scopes":["read","write"],"created_at":"2026-05-29 09:12:00","last_used_at":"2026-05-29 09:33:11" },
  "api_version":"v1"
}
```

### `GET /campaigns` (scope `read`)

Query: `module` · `status` · `limit` · `offset`.

```bash
curl -H "Authorization: Bearer $WSK" "$BASE/campaigns?status=active"
```

### `GET /campaigns/{id}` (scope `read`)

### `POST /campaigns` (scope `write`)

Body:

```json
{
  "title":                 "GBU Q3 2026",
  "module":                "gbu_psych",
  "description":           "Quartals-Erhebung",
  "start_date":            "2026-07-01",
  "end_date":              "2026-08-15",
  "access_code":           "GBU-Q3-2026",
  "expected_participants": 150,
  "requires_login":        false
}
```

Status der neuen Kampagne ist immer `draft`. Aktivieren mit PATCH.

### `PATCH /campaigns/{id}` (scope `write`)

Erlaubte Felder: `title`, `description`, `status`, `start_date`, `end_date`, `access_code`, `expected_participants`.

```bash
curl -X PATCH -H "Authorization: Bearer $WSK" -H "Content-Type: application/json" \
  -d '{"status":"active"}' $BASE/campaigns/CID
```

### `GET /responses` (scope `read`)

Query: `campaign_id` · `module` · `department` · `location` · `from` · `to` · `limit` · `offset`.

### `GET /responses/{id}` (scope `read`)

### `POST /responses` (scope `write`)

Antwort programmatisch einreichen (z.B. aus externem Befragungs-Tool):

```json
{
  "campaign_id":       "CID",
  "department":        "Lager",
  "location":          "Düsseldorf",
  "is_anonymous":      true,
  "participant_email": null,
  "answers":           {
    "zd_1": 4, "zd_2": 3, "zd_3": 4, "zd_4": 4,
    "vollst_1": 3, "vollst_2": 2, "vollst_3": 3
  }
}
```

Die Kampagne muss `status: "active"` haben, sonst `422 campaign_inactive`.

### `GET /analysis/{campaign_id}` (scope `read`)

Vorab berechnete Auswertung. Filter: `?department=…&location=…`. Anonymitäts-Schwelle n≥5 gilt.

```json
{
  "campaign_id": "CID",
  "module":      "gbu_psych",
  "n":           126,
  "analysis": {
    "dimensions": [
      { "key":"zeitdruck","label":"Zeitdruck","type":"burden","mean":3.04,"n":504,"ampel":"red" },
      { "key":"arbeitsumgebung","label":"Arbeitsumgebung","type":"burden","mean":2.79,"n":1260,"ampel":"red" }
    ],
    "findings": [],
    "measures": [
      { "dimension":"Zeitdruck","ampel":"red","measure":"Aufgabenanalyse und Personalplanung prüfen" }
    ]
  }
}
```

### `GET /participants` (scope `read`)

Optional: `?invite_status=pending|invited|completed`.

### `POST /participants` (scope `write`)

```json
{ "email":"mitarbeiter@firma.de", "name":"Anna A.", "department":"Lager", "location":"Düsseldorf", "campaign_id":"CID" }
```

Nutzt das MA-Limit des Mandanten-Tarifs. Bei Überschreitung: `403 plan_limit`.

### `DELETE /participants/{id}` (scope `write`)

Antwortet mit `204 No Content`.

### `GET /benchmarks` (scope `read`)

Liefert Branchen-Benchmark-Werte. Default: Branche des Token-Mandanten. Optional: `?industry=Logistik+%26+Transport&module=gbu_psych`.

## 10. End-to-End-Beispiele

### A — Antwort aus externem Tool importieren

```bash
# 1. Aktive Kampagne finden
curl -H "Authorization: Bearer $WSK" "$BASE/campaigns?status=active&module=gbu_psych" | jq '.items[0].id'

# 2. Antwort posten
curl -X POST -H "Authorization: Bearer $WSK" -H "Content-Type: application/json" \
  -d '{"campaign_id":"CID","department":"Vertrieb","location":"Berlin","answers":{"zd_1":4,"zd_2":3}}' \
  $BASE/responses
```

### B — Analyse für Dashboard ziehen

```bash
curl -H "Authorization: Bearer $WSK" \
  "$BASE/analysis/CID?department=Lager" | jq '.analysis.findings[] | select(.ampel=="red")'
```

### C — Mitarbeiter aus HR-System synchronisieren

```bash
# Existierende holen
existing=$(curl -s -H "Authorization: Bearer $WSK" "$BASE/participants?limit=1000" | jq -r '.items[].email')

# Neue anlegen
while read email name dept loc; do
  if ! grep -q "$email" <<< "$existing"; then
    curl -X POST -H "Authorization: Bearer $WSK" -H "Content-Type: application/json" \
      -d "{\"email\":\"$email\",\"name\":\"$name\",\"department\":\"$dept\",\"location\":\"$loc\"}" \
      $BASE/participants
  fi
done < mitarbeiter.tsv
```

## 11. Clients in 3 Sprachen

### curl / Bash

```bash
WSK="wsk_…"
BASE="https://app.worksonar.de/api/v1"

# GET mit JSON-Pretty-Print
curl -s -H "Authorization: Bearer $WSK" $BASE/me | jq

# POST mit Body
curl -X POST -H "Authorization: Bearer $WSK" -H "Content-Type: application/json" \
  -d @campaign.json $BASE/campaigns
```

### JavaScript / Node 18+

```js
const BASE = 'https://app.worksonar.de/api/v1';
const WSK  = process.env.WSK;

async function ws(path, init = {}) {
  const res = await fetch(BASE + path, {
    ...init,
    headers: { 'Authorization': `Bearer ${WSK}`, 'Content-Type': 'application/json', ...(init.headers || {}) },
  });
  const data = await res.json().catch(() => null);
  if (!res.ok) throw Object.assign(new Error(data?.error?.message || res.statusText), { status: res.status, data });
  return data;
}

// Listen
const { items } = await ws('/campaigns?status=active');

// Posten
const newCamp = await ws('/campaigns', {
  method: 'POST',
  body: JSON.stringify({ title: 'Q3 2026', module: 'gbu_psych' }),
});

// Aktivieren
await ws(`/campaigns/${newCamp.id}`, {
  method: 'PATCH',
  body: JSON.stringify({ status: 'active' }),
});
```

### Python

```python
import os, requests

BASE = "https://app.worksonar.de/api/v1"
WSK  = os.environ["WSK"]
H    = {"Authorization": f"Bearer {WSK}", "Content-Type": "application/json"}

def ws(method, path, **kw):
    r = requests.request(method, BASE + path, headers=H, timeout=30, **kw)
    if r.status_code >= 400:
        raise RuntimeError(f"{r.status_code} {r.json().get('error', {}).get('message', r.text)}")
    return r.json() if r.text else None

# Lesen
me = ws("GET", "/me")
print(me["tenant"]["name"])

# Schreiben
camp = ws("POST", "/campaigns", json={"title": "Q3 2026", "module": "gbu_psych"})
ws("PATCH", f"/campaigns/{camp['id']}", json={"status": "active"})

# Analyse
analysis = ws("GET", f"/analysis/{camp['id']}")
for f in analysis["analysis"]["findings"]:
    if f["ampel"] == "red":
        print(f["label"], f["mean"])
```

## 12. Changelog

| Version | Datum | Änderungen |
|---|---|---|
| `v1.0` | 2026-05 | Erstveröffentlichung. Endpoints für me / campaigns / responses / analysis / participants / benchmarks. Bearer-Auth mit Read/Write-Scopes, 60 req/min Rate-Limit, n≥5 Anonymitätsschutz im Analysis-Endpoint. |

---

**Roadmap** (geplant): Webhooks für Submit-Events, OAuth 2.0 Authorization Code Flow für Third-Party-Apps, Bulk-Import-Endpoint. Kontakt: `api@worksonar.de`.
