# Rankion.ai API Reference

> **Stand:** 2026-11-07 · **Version:** v1 · **771 Endpoints**

Single Source of Truth für die komplette REST-API. Diese Datei wird via `CLAUDE.md` auto-importiert und serviert `/api-reference` + `/api-docs/markdown`.

> **Vollständigkeits-Gate:** Jeder `/v1`-Route MUSS hier eine Zeile haben — erzwungen durch `php artisan api:doc-coverage` (hart im Deploy: Pipeline-Stage `test:static` in `.gitlab-ci.yml` + server-seitig in `deploy/release.sh` [5/10] vor der OPcache-Aktivierung, analog zu `schema:lint`/`mcp:catalog-coverage`). Neuer Endpoint ohne Doku-Zeile ⇒ Deploy bricht ab. Skelett-Zeilen: `php artisan api:doc-coverage --emit`.

## Authentifizierung

```bash
# Token im Frontend erstellen: /settings/api-tokens
TOKEN="<dein-sanctum-token>"
BASE="https://rankion.ai/api/v1"

curl -H "Authorization: Bearer $TOKEN" \
     -H "Accept: application/json" \
     "$BASE/projects"
```

Alle Endpoints sind Team-scoped via `auth:sanctum` Middleware. Cross-Team-Zugriff → `403`.

## Antwort-Formate

- **2xx** Success — JSON
- **202 Accepted** — Job dispatched (async). Response enthält `keyword_id`, `article_id` etc. zum Pollen.
- **400/422** Validation Error — `{message, errors:{field:[reason]}}`
- **403** IDOR / Team-Mismatch
- **402** Insufficient credits
- **404** Resource not found
- **409** Conflict (z.B. Article bereits am publishen)

## Credit-Mechanik

Jobs/Aktionen mit `credits:N` Middleware prüfen vor dem Job-Dispatch, ob das Team-Guthaben N Credits deckt (sonst 402); gebucht wird im Job bzw. in der Aktion selbst. Bei `cache=false` Refresh (Explorer) prüft die Anfrage das Guthaben; die 50 Credits bucht der Hintergrund-Job einmal vor dem Anbieter-Abruf (ohne Guthaben kein Abruf, `processing_status=failed`; scheitert der Abruf endgültig, wird erstattet).

---

## 🎯 AI Visibility Tracking

### Projekte

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/tracking-projects` | alle Team-Projekte | — |
| POST   | `/tracking-projects/analyze` | Wizard Step 1+2: Domain/Keyword analysieren, dispatcht `AnalyzeDomainJob`. Body: `{mode:"domain"\|"keyword", domain?, keyword?, name?, language?, country?, own_domains[]?}` → 202 + `project_id`. `language` = Erzeugungssprache der Prompts: `de, en, fr, es, it, nl, pt, pl, sv, da, no, fi` (sonst 422) | — |
| POST   | `/ai-visibility/ingest` | **Guided Prompt-Ingest** (skill/agent/MCP front door). Schickt eine Prompt-Liste; State-Machine `status`: needs_input (fragt domain+market) → analyzing → needs_confirmation → activated. Body: `{prompts[], domain?/brand?, language?, country?, project_id?, confirm?, auto_confirm?, platforms?, frequency?, start_run?}`. Orchestriert analyze→activate→classify; team-scoped, idempotent `(team,domain)`. Doku: `docs/modules/ai-visibility-ingest.md` | analyze 5 / activate 0 |
| GET    | `/ai-visibility/ingest/{id}` | Poll des Ingest-/Analyse-Status (gleiche Envelopes). Team-scoped (403 cross-team) | — |
| GET    | `/tracking-projects/{id}` | Detail mit KPIs | — |
| PUT    | `/tracking-projects/{id}` | Settings: `{name?, brand_name?, brand_aliases[], competitor_domains[], own_domains[], tracking_frequency?, llm_platforms[], llm_with_search?, llm_without_search?, track_aio?, reality_check_enabled?}` — `llm_platforms[]` erlaubt: chatgpt, perplexity, claude, gemini, copilot, grok, google_ai_mode — `own_domains` = weitere eigene Domains (Full-Replace, normalisiert, schließt Hauptdomain aus, reklassifiziert Cited Sources sofort) **K#10928:** kostenwirksame Felder (`rank_tracker_enabled`, `gap_enabled`, `backlinks_enabled`, `reality_check_enabled`, `tracking_frequency`, `llm_platforms`, `llm_with_search`, `llm_without_search`, `track_aio`, `track_serp`, `track_llm`) ändert nur das Besitzer-Team — ein Gast mit Projektfreigabe bekommt bei einem geänderten Wert 403 `{error:"not_owner", message, fields[]}` und es wird nichts gespeichert; unverändert mitgeschickte Werte zählen nicht. | — |
| DELETE | `/tracking-projects/{id}` | ⚠ Projekt **samt aller Daten** löschen (Keywords, Prompts, Runs, Scores, Cited-Sources, LLM-Results + alle CASCADE-Children). Irreversibel. Team-gescoped (403 cross-team). **K#10929:** ein Gast mit Projektfreigabe bekommt 403, nichts wird gelöscht. → **204** No Content | nur Besitzer-Team · 0 |
| GET    | `/tracking-projects/{id}/analysis-status` | Wizard polling: `{analysis_status, analysis_phase, analysis_error, suggested_keywords, suggested_competitors, competitor_discovery, competitor_rediscovery_status, competitor_rediscovery_error, suggested_prompts, suggested_personas, classification}`. `competitor_discovery` unterscheidet `ok` \| `empty` \| `failed`; die beiden `rediscovery`-Felder tragen den Lebenszyklus von `POST .../competitors/discover` | — |
| POST   | `/tracking-projects/{id}/activate` | Wizard Step 5 atomic: erstellt TrackingKeyword/Prompt/Competitor + flippt status=active. Body: `{keywords[], competitors[], prompts[], platforms[], frequency, with_search?, without_search?, track_aio?, reality_check_enabled?, personas[]}` — `platforms[]` erlaubt: chatgpt, perplexity, claude, gemini, copilot, grok, google_ai_mode **K#10928:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. | — |
| POST   | `/tracking-projects/{id}/pause` | Projekt pausieren (Plan #244): kein geplanter Lauf, kein Export, keine KI-Nachholung, alle Daten bleiben; nur aus `active`, sonst 409. Antwort `{tracking_project_id, status}` **K#10928:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. | 0 |
| POST   | `/tracking-projects/{id}/resume` | Pausiertes Projekt fortsetzen: Zeitplan läuft wieder; nur aus `paused`, sonst 409. Antwort `{tracking_project_id, status}` **K#10928:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. | 0 |
| POST   | `/tracking-projects/{id}/run` | Tracking-Run starten (async) | — |
| GET    | `/tracking-projects/{id}/runs` | Run-Historie (`?limit=&offset=`) | — |
| GET    | `/tracking-projects/{id}/visibility-trend` | Wochenreihen je AI-Engine, BEIDE Größen in einer Antwort: `visibility` (Erwähnungen ÷ Calls) und `cited` (eigene Seite als Quelle ÷ Calls), je ISO-Woche. `?window=4w|12w|26w` (Vorgabe 12w, unbekannter Wert fällt darauf zurück). Je Engine `label`, `visibility[]`, `cited[]`, `calls[]`, `cited_measurable`, `measurement_note`. **`cited` trägt `null` statt `0`, wo Zitate nicht erhoben werden** (Copilot dauerhaft, Gemini vor dem 17.09.2026) — `calls[]` trennt »0 Prozent gemessen« von »nichts gelaufen«. Dieselben Zahlen wie die Chart im Browser, per Paritätstest belegt. Seit 24.10.2026: `?llms=chatgpt,claude` (Engine-Auswahl), `?von=&bis=` (freier Zeitraum, dann Achse `points` mit `from, to, resolution`), `?takt=tag\|woche\|monat`; 422 `unknown_engine`, `unknown_resolution`, `incomplete_range`, `invalid_range`, `range_too_large`. Seit 26.10.2026: `?competitors=12,19` legt die Wettbewerber-Reihen desselben Projekts auf dieselbe Achse (nur mit `von`/`bis`, mit `window` 422 `competitors_need_range`); Block `competitors` je Nummer `label, visibility[], cited[], calls[], measured, measurement_note`, `measured=false` heißt »nicht erhoben« und die Werte sind `null`. 422 `invalid_competitor` (nicht numerisch oder leere Liste), `unknown_competitor` (fremd, unbekannt oder stillgelegt — geprüft gegen die AKTIVEN Wettbewerber), `too_many_competitors` (mehr als sechs auf einmal, dieselbe Grenze wie die Oberfläche). Ohne den Parameter fehlt der Block. Seit 29.10.2026: `?partial_measurements=1` ergänzt den Block `partial_measurements`, parallel zur Achse je Eimer `null` oder `{measuring_days, partial_days[]}` mit `date, measured_prompts, expected_prompts` je Teilmesstag (weniger als 80 % der erwarteten Prompts geprüft, vor dem 10.10.2026 der Normalfall); ohne den Parameter bleibt die Antwort unverändert, ein ungültiger Wert gibt 422. **Seit 31.10.2026 (K#11097):** die Antwort trägt auch `amazon_rufus` (Amazon Rufus), auch als Wert für `?llms=`; `visibility` = Antworten mit Marke im Produkttitel oder bejahenden Satz, `cited` dauerhaft `null` (Produkte statt Quell-URLs) | — |
| GET    | `/tracking-projects/{id}/topic-clusters` | Sichtbarkeit je Themencluster (Tags der System-Dimension `topic`, K#9242). Je Cluster `tag_id`, `slug`, `label`, `deleted`, `prompts`, `responses`, `mentions`, `visibility`, `citations`, `citation_rate`, `trend` (Sichtbarkeit je ISO-Woche, Schlüssel = Montag, `null` in Wochen ohne Antwort). Dazu `without_topic` (Prompts ohne Thema als eigene Zeile, nie im Durchschnitt versteckt), `total` (jede Antwort genau einmal) und `prompts_in_multiple_clusters` — ein Prompt in zwei Clustern zählt in beiden Zeilen, die Summe der Cluster-`responses` ist deshalb NICHT `total.responses`. `?from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe die letzten 30 Tage, `422 range_too_large` über 366 Tage. Dieselbe Rechnung wie der Reiter »Themen« im Cockpit, per Paritätstest belegt | — |
| GET    | `/tracking-projects/{id}/potential-gaps` | **Potenziallücken** (K#9243): Antworten, die mindestens eine andere Marke nennen und die eigene nicht — je Prompt × Plattform im Fenster. Je Zeile `prompt_id`, `prompt_text`, `clusters` (Themencluster-Labels), `platform`, `responses` (alle Antworten der Zeile), `gap_responses`, `gap_rate` (%), `competitors[]` (`name`, `responses`, `tracked` = gepflegter Wettbewerber, höchstens 5), `search_volume`, `priority_score` (gespeicherter Prompt-Prioritätsscore, `null` ohne Suchvolumen). Antworten ohne jede Marke zählen nie; eigene Marke, Aliasse und Domains in der Markenliste zählen nicht als Wettbewerber. `counting_basis` = `mentioned_brands`, `has_tracked_competitors`, Filteroptionen `platforms[]` und `clusters[]` (`slug`, `label`). `?platform=&cluster=<slug>&search=&from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe 30 Tage, `422 invalid_range` / `range_too_large` über 366 Tage. Dieselbe Rechnung wie der Reiter »Potenziallücken«, per Paritätstest belegt | — |
| GET    | `/tracking-projects/{id}/potential-gaps/briefing` | **Briefing aus einer Potenziallücke** (K#9296): für EINE Lücke (`?prompt_id=&platform=` Pflicht, `?from=&to=` wie `potential-gaps`) die Frage, `competitors[]` (`name`, `responses`, `tracked`), `sources[]` (meistzitierte Seiten zu Prompt × Plattform ohne eigene Domain, höchstens 8: `url`, `domain`, `origin` = `competitor`\|`third_party`, `competitor`, `content_type_label`, `citing_responses`), den Briefing-Text `briefing` und `article` (`project_id` des verknüpften Content-Projekts nur mit Recht darauf, sonst `null`, `keyword`, `language`). `404 gap_not_found`, wenn die Zeile im Fenster keine Lücke ist. Read-only; weiter mit `POST /projects/{id}/articles` und `POST /articles/{id}/generate` (Feld `briefing`) | — |
| GET    | `/tracking-projects/{id}/competitor-sources` | **Wettbewerber-Quellenprofil und Matrix Frage gegen Quelle** (K#9244), ohne neue Messung — aus `all_brands_mentioned` × `cited_urls` × `tracking_cited_sources.content_type`. Je gepflegtem Wettbewerber `id`, `name`, `domain`, `responses` (Antworten mit Nennung = Stichprobe), `responses_with_citations`, `citations` (Paare Antwort × Seite), Zitate je Herkunft aus Sicht des Wettbewerbers: `self_citations` (seine Domain oder Subdomain), `own_domain_citations` (Projektdomain), `other_competitor_citations` (ein anderer gepflegter Wettbewerber), `third_party_citations` (alle übrigen), `shared_citations` (Zitate aus Antworten, die mehrere Wettbewerber nennen — dort zählt die Quelle bei jedem), `domains[]` (`domain`, `origin` = `self`/`own`/`competitor`/`third_party`, `citations`, `share` %) und `content_types[]` (`content_type`, `label`, `citations`, `share`; ohne Einordnung steht in `content_type` der Grund aus `content_type_reason` — `own_domain`, `fetch_failed`, `pending` oder `unclassified` — und in `label` dessen Text). `total` zählt jedes Zitat genau einmal (Anteile ergeben 100 %). Dazu `tracking_project_id`, `from`, `to`, `has_tracked_competitors` und die Filteroptionen `platforms[]` und `prompts[]` (`id`, `text`). `matrix[]` je Prompt × Plattform × kanonischer Seite (`prompt_id`, `prompt_text`, `platform`, `url`, `domain`, `origin`, `competitor`, `content_type`, `content_type_label`, `citing_responses`, `responses`), seitenweise `?page=&per_page=` (Vorgabe 100, höchstens 500; zurück kommen `page`, `per_page`, `matrix_total`). URLs kanonisiert: `www.`, Schlussstrich, Fragment und Tracking-Parameter (`utm_*`, `gclid`, `fbclid`, `msclkid`, `mc_cid`, `mc_eid`) fallen weg; Weiterleitungen werden nicht aufgelöst. `?prompt_id=&platform=&from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe 30 Tage, `422 invalid_range` / `range_too_large` über 366 Tage. Dieselbe Rechnung wie die Karte im Reiter »Wettbewerber«, per Paritätstest belegt | — |
| GET    | `/tracking-projects/{id}/competitor-trend` | **Verlauf je Wettbewerber über die Messtage** (K#8974), ohne neue Messung, 0 Credits — dieselbe Rechnung wie die Karte im Reiter »Marken und Wettbewerber«. `measurement_days[]` sind die Tage MIT Antworten (ISO), nicht Wochen und keine Lücken: ein Tag ohne Messung taucht nicht auf. `series[]` je Marke mit `brand`, `own` (die eigene Marke steht zuerst), **`measured` und `measurement_note` (seit 26.10.2026, K#10808)** und `points` — Schlüssel ist der Messtag, Wert trägt `responses`, `mentions`, `mention_rate` (%), `citations`, `citation_rate` (%) und `rank`. **`rank` ist `null`, nie `0`**, wenn die Marke an dem Tag in keiner Antwort vorkam: kein Treffer ist kein Rang, und eine 0 läse sich als bester Platz. **Seit dem 24.10.2026 sind `mention_rate` und `citation_rate` ebenfalls nullbar, und aus demselben Grund:** `tracking_competitor_mentions` ist eine Ableitung, die `tracking:wettbewerber-nennungen` nachträgt. Hat der Nachtrag die Antworten des Fensters nie verarbeitet, sind beide Quoten `null` statt `0` — eine 0 wäre eine Aussage, die niemand gemessen hat. Lief er, bleibt eine echte 0 % stehen, auch für eine nie genannte Marke: er schreibt je Wettbewerber bedingungslos eine Zeile. Die absoluten Zahlen `responses`, `mentions` und `citations` bleiben in jedem Fall gefüllt; `null` trifft nur die abgeleiteten Quoten. Die eigene Marke ist davon nie betroffen — ihre Zahlen kommen aus `llm_results`, nicht aus der Ableitung. **Ob die Quoten einer Reihe gemessen sind, sagt seit dem 26.10.2026 die Reihe selbst (K#10808 #7742):** `measured` ist `true` oder `false`, `measurement_note` ist `null` oder der Grund im Klartext. Bis dahin musste ein Abnehmer aus `mention_rate: null` raten, ob niemand genannt wurde oder niemand gemessen hat — die Flagge trug der Dienst seit dem 24.10.2026, das Mapping gab sie nicht heraus. Die Namen folgen `visibility-trend` (`cited_measurable` / `measurement_note`); eine dritte Schreibweise für dieselbe Sache wäre die Drift, gegen die K#9369 die Dienste zusammengelegt hat. Der Rang mittelt nur über Antworten MIT Treffer — kleiner ist besser. **Was er misst, sagt `rank_meaning` (seit 27.10.2026, Eigentümer-Entscheid):** ohne Plattformfilter `first_mention_sentence`, der Satz, in dem die Marke zuerst auftritt (`brand_position` ist auf JEDER Plattform ein Satzindex, gemessen bis 48); mit `?platform=amazon_rufus` `recommendation_list`, der Platz in Amazons Empfehlungsliste, je Messtag gemittelt über die Antworten mit Treffer (`products[].position`, dieselbe Rechnung wie `rufus-recommendations`, nur je Tag); `rank_hits` und `rank_denominator` tragen dann je Messtag den Nenner — Antworten mit Treffer und Antworten mit Produktliste —, auf Textplattformen sind beide `null`. Bis dahin trug `rank` unter dem Rufus-Filter die Satzposition — bei Rufus hatten 10 von 14 Nennungen gar keine (Titeltreffer), Wettbewerber nie. `prompts_per_day` nennt je Messtag die Zahl der gestellten Fragen; weichen sie voneinander ab, trägt `notices[]` den Eintrag `prompt_set_changed` — eine Bewegung in der Reihe kann dann am veränderten Fragensatz liegen und nicht an der Sichtbarkeit. `?platform=` filtert auf eine Plattform und ist kein Zusatz: über alle Plattformen gemittelt verschwinden entgegengesetzte Lagen (gemessen für ein Projekt: ein Wettbewerber mit 21 seiner 29 Nennungen auf einer einzigen Plattform). Dazu `tracking_project_id`, `from`, `to`, `platform`. `?from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe 30 Tage, `422 invalid_range` / `range_too_large` über 366 Tage | — |
| GET    | `/tracking-projects/{id}/competitor-matrix` | **Vergleichsmatrix aller Marken plus Mitnennungen, aus Sicht EINER Bezugsmarke** (K#10597), ohne neue Messung, 0 Credits — dieselbe Rechnung wie die Matrix im Reiter »Marken und Wettbewerber« (`App\Support\Tracking\VergleichsMatrix`, von Maske und API gemeinsam genutzt, damit beide nicht auseinanderlaufen). **Nicht zu verwechseln mit `competitor-trend`:** dort stehen Punkte je Messtag, ohne `visibility`, und nur für HINTERLEGTE Wettbewerber; hier stehen die Fensterwerte je Marke samt Bewertung gegeneinander, und die Marken kommen aus derselben Quelle wie die Oberfläche — auch die bloß genannten. `columns[]` = `visibility`, `mentions`, `citation_rate`, `citations`; bei allen vier ist MEHR besser. **`rank` ist absichtlich NICHT dabei:** die Größe existiert nur je Messtag (siehe `competitor-trend`), und dort wäre weniger besser — in dieser Liste würde dieselbe Vergleichsregel sie falsch bewerten. `rows[]` je Marke mit `brand`, `own`, `reference`, `metrics` (die vier Werte) und `verdicts` je Spalte: `reference` (die Bezugszeile selbst — die Bezugsmarke), `better` (**die Bezugsmarke steht besser da** — in der Oberfläche die grüne Zelle), `worse`, `equal`, `not_measurable`. **`not_measurable` ist nicht `equal`:** `citations` und `citation_rate` sind `null`, wenn für die Zeile keine Domain hinterlegt ist, und eine 0 stünde dort für ein Messergebnis, das es nicht gibt — solche Zellen werden nicht bewertet. `co_mentions` nennt aus Sicht der Bezugsmarke, welche anderen Marken in DENSELBEN Antworten stehen (`brand`, `count`, `pct`, `framing_score`) plus `total_results` als Grundgesamtheit; die ist je Marke sehr verschieden (gemessen: eigene Marke 146.409 Textbausteine gegen 2.345 beim stärksten Wettbewerber), eine Quote ohne ihre Basis verführt deshalb zum Fehlvergleich. `source` = `maintained` (hinterlegte Wettbewerberliste) / `mentioned` (keine Liste: alle genannten Marken) / `none` (keine Antworten im Fenster — `rows` ist dann leer, `columns` steht trotzdem). Dazu `tracking_project_id`, `from`, `to`, `basis`, `reference_brand`, `total_responses`. `?perspective=<Marke>` wählt die Bezugsmarke; eine Marke, die im Projekt nicht vorkommt, wird mit `422 unknown_perspective` ABGEWIESEN und nicht stillschweigend durch die eigene ersetzt. `?basis=all|same_basis`, `?from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe 30 Tage, `422 invalid_range` / `range_too_large` über 366 Tage | — |
| GET    | `/tracking-projects/{id}/competitor-kpis` | **Die sechs Kopfkacheln aus der Sicht EINER wählbaren Marke** (T-791), read-only, 0 Credits. Nicht zu verwechseln mit `competitor-matrix`: dort stehen vier Spalten je Marke im Vergleich, hier die sechs Kacheln EINER Sicht samt ihrer Messbarkeit. Drei Werte sind markenneutral übertragbar und kommen aus derselben Rechnung wie die Maske: `visibility` und `citation_rate` aus `all_brands_mentioned` bzw. `cited_urls`, `total_queries` unverändert — die Zahl der Abfragen hängt am Fragensatz, nicht an einer Marke. Dazu `mentions` und `citations` als absolute Zahlen. **`avg_position` trägt seinen Nenner mit, und das ist der wichtigste Hinweis dieses Endpunkts:** der Rang kommt aus den nachgetragenen Nennungen (`tracking_competitor_mentions`, kostenlos aus dem gespeicherten Antworttext abgeleitet), und `avg_position_basis` sagt, auf wie vielen Werten der Schnitt beruht. Gemessen auf der Produktion am 22.10.2026 für Projekt 155: von 7.202 (Antwort × Marke)-Zeilen tragen **17** einen Rang — ein Schnitt ohne seine Basis liest sich wie eine Markeneigenschaft und ist eine Einzelbeobachtung. In der Eigensicht ist `avg_position_basis` `null`, weil dort `brand_position` je Antwort vorberechnet ist. **`sentiment` und `recommendation_rate` sind GEKAUFTE Werte:** beide sind KI-Urteile GEGEN EINE MARKE und für eine fremde Marke nicht vorberechnet; sie stehen auf `null`, solange für diese Marke nichts bewertet wurde, und `evaluated_responses` nennt die Zahl der bewerteten Antworten. **Dieser Endpunkt löst KEINE Bewertung aus** — das tut nur der Knopf in der Oberfläche, und er nennt den Preis vorher; ein Lesezugriff, der Geld kostet, wäre eine Falle (festgenagelt in `WettbewerberKpiApiTest::test_ein_lesezugriff_kauft_nichts`). **`null` ist hier nie `0`:** eine 0 wäre eine Aussage (»Position null, nie empfohlen«), und zwar eine falsche. Dazu `tracking_project_id`, `from`, `to`, `basis`, `reference_brand`, `own_view`. `?perspective=<Marke>` wählt die Bezugsmarke; die eigene Marke ist gleichbedeutend mit der Eigensicht, eine im Projekt nicht vorkommende wird mit `422 unknown_perspective` ABGEWIESEN statt stillschweigend ersetzt. `?basis=all|same_basis`, `?from=YYYY-MM-DD&to=YYYY-MM-DD`, Vorgabe 30 Tage, `422 invalid_range` / `range_too_large` über 366 Tage | — |
| GET    | `/tracking-projects/{id}/measurement-coverage` | Vollständigkeit je Messung (K#9292). `coverage`: je Plattform `planned` (geplante Zellen Prompt × Suchmodus, beim Laufstart festgehalten; `null` bei Läufen vor K#9292), `measured`, `missing`, `coverage_pct` (`null` ohne Soll), dazu `total`. `comparison`: Vergleich mit der Vormessung NUR über Zellen, die in beiden Läufen eine Antwort haben — je Plattform `common_cells`, `only_in_previous`, `only_in_run`, `visibility_previous`, `visibility_run`, `delta` (Prozentpunkte, Lauf minus Vormessung); dazu `run_id` und `previous_run_id`. `previous_coverage` = Abdeckung der Vormessung. `?run_id=` (Vorgabe: letzter abgeschlossener LLM-Lauf), `?compare_run_id=` (Vorgabe: der Lauf davor); fremder oder unbekannter Lauf → `404 run_not_found`. Dieselbe Rechnung wie die Karte »Vollständigkeit der letzten Messung« im Cockpit, per Paritätstest belegt | — |
| GET    | `/tracking-projects/{id}/period-comparison` | **Zeitraumvergleich A gegen B** (K#9240). `rows[]` je Kennzahl (`dimension=metric`: `visibility`, `citation_rate`, `recommendation_rate`, `avg_position`, `sentiment`), Plattform (`platform`, Sichtbarkeit und Zitierungsrate), Themencluster (`topic_cluster`, Schlüssel = Tag-Slug, `without_topic`) und Wettbewerber (`competitor`, `own_brand` plus die zehn stärksten, gepflegte Liste wie im Cockpit): `value_a`, `value_b`, `delta` (Prozentpunkte bzw. Wertdifferenz, B minus A), `count_a`, `count_b`, `count_delta` (`null` bei ungleich langen Fenstern), `sample_a`, `sample_b` (Antworten je Fenster; bei `avg_position`/`sentiment` die Nennungen, über die gemittelt wird), `status` = `ok` / `not_collected` (Fenster ohne Antworten, nie 0 %) / `not_measurable` (Zitatwerte vor dem Stichtag aus `LlmPlatformRegistry`, z. B. Gemini vor dem 17.09.2026). Kopf `a`/`b` mit `from`, `to`, `days`, `measurement_days`, `responses`; `equal_length`; `competitor_source` = `tracked` (gepflegte Wettbewerberliste) oder `mentioned` (keine Liste: alle genannten Marken, im Cockpit »Häufig genannte Marken«); `notices[]` = `unequal_length`, `empty_a`, `empty_b`, `different_rhythm` (mittlerer Abstand der Messtage weicht um Faktor 3 ab, nur bei je zwei Messtagen), `citations_partly_measurable` (eine Plattform ist nur in einem Fenster zitat-messbar). `?a_from=&a_to=&b_from=&b_to=` (YYYY-MM-DD; Vorgabe: die beiden letzten Messungen), `?basis=all|same_basis` (K#9237, Stichtag = späteres Fensterende für beide). `422 overlap` / `invalid_range` / `range_too_large` (über 366 Tage). Dieselbe Rechnung wie die Seite `/ai-visibility/{id}/vergleich`, per Paritätstest belegt | — |
| GET    | `/tracking-projects/{id}/rufus-recommendations` | **Empfehlungsliste des Amazon-Einkaufsassistenten** (24.10.2026). Wertet `llm_results.products` aus — die Liste, die bei jedem bezahlten Rufus-Abruf entsteht. Kopf: `platform` (`amazon_rufus`), `from`, `to`, `answers_total` (**alle** Rufus-Antworten des Fensters), `answers_with_products` (**der Nenner**), `answers_without_products` (Amazon wurde bezahlt gefragt und hat nichts empfohlen — eine Messung, kein Fehlen), `answers_without_product_field` (die Spalte `products` ist `NULL`: Altzeilen von vor dem 23.10.2026, **keine** Messung der Fläche). Die Rechnung geht auf: `answers_total = answers_with_products + answers_without_products + answers_without_product_field`. Dazu `products_total`. Dazu `own_brand` und `competitors[]` (gepflegte, aktive Wettbewerber), je mit `brand`, `hits` (Antworten, in denen die Marke in einem **Produkttitel** steht), `hit_rate` (Prozent; **`null`, wenn der Nenner 0 ist** — das unterscheidet »gemessen, nie empfohlen« von »nicht gemessen«), `recommendation_rank_best`, `recommendation_rank_avg` (Mittel **nur über die Antworten mit Treffer**) und `denominator`. **`recommendation_rank` ist der Kaufempfehlungsplatz aus `products[].position` und NICHT `brand_position`**, das bei den Textplattformen die Stelle im Fließtext meint; eine Marke außerhalb der Liste liefert `null` und nie einen schlechtesten Rang (Council-Entscheid 23.10.2026). Titel-Treffer nach derselben Wortgrenzen-Regel wie `brand_mentioned`; Wettbewerber zusätzlich über Domain und Domainstamm, weil ihr `brand_name` oft eine Domain ist — abgeleitete Suchbegriffe zählen hier erst **ab 4 Zeichen**, weil Produkttitel kurz und abkürzungsdicht sind (`dm.de` → Stamm »dm« traf sonst das Mengenwort in »… 30 dm Tabletten« und erzeugte daraus einen Empfehlungsplatz 1). Der gepflegte `brand_name` selbst wird ungefiltert gesucht. `?from=&to=` (YYYY-MM-DD; Vorgabe: die letzten 30 Tage), `422 invalid_range`, `422 range_too_large` (das Fenster spannt höchstens 366 Tage — derselbe Riegel wie bei den Schwester-Endpunkten dieser Familie). Verbraucht **kein** Guthaben | — |
| GET    | `/tracking-projects/{id}/measures` | **Maßnahmen-Zeitleiste** (K#9294): vom Team eingetragene Maßnahmen mit Datum, je Eintrag `id`, `title`, `note`, `date` und `effect` (»Veränderung nach Maßnahme« auf gleicher Basis — was sich danach verändert hat, kein Ursachennachweis: die 14 Tage vor dem Datum gegen die 14 Tage ab dem Datum, nur Prompt × Plattform × Pass-Zellen, die in beiden Fenstern gemessen wurden). `effect.status` = `measured` / `too_early` (Nachher-Fenster noch nicht voll, `evaluable_from` nennt den Tag) / `insufficient_data` (unter 25 gemeinsamen Zellen); `before`/`after` mit `from`, `to`, `rate`; `delta` in Prozentpunkten; `shared_cells`. `?from=&to=` (YYYY-MM-DD). `GET …/period-comparison` trägt dieselben Einträge als `measures[]` mit `position` = `in_a` / `in_b` / `between` | — |
| POST   | `/tracking-projects/{id}/measures` | Maßnahme eintragen: `title` (Pflicht, max. 255), `date` (YYYY-MM-DD, höchstens 366 Tage vor oder nach heute), `note` (optional, max. 800). Nur das Eigentümer-Team **mit Projektrechten** auf das Projekt (Gast einer Freigabe oder Redakteur eines anderen SEO-Projekts: 403). Antwort 201 mit dem Eintrag | — |
| PUT    | `/tracking-projects/{id}/measures/{measure}` | Maßnahme korrigieren (`title`, `date`, `note` wie beim Anlegen). Die gespeicherte Veränderung wird verworfen und neu berechnet (`effect` sofort live, der Nachtlauf speichert neu). Rechte wie beim Anlegen | — |
| GET    | `/tracking-projects/{id}/measures/{measure}/effect` | Veränderung nach einer Maßnahme (Form wie `effect` oben). 404 für eine Maßnahme eines anderen Projekts | — |
| DELETE | `/tracking-projects/{id}/measures/{measure}` | Maßnahme entfernen. Nur das Eigentümer-Team | — |
| POST   | `/tracking-projects/{id}/sync-sources` | Cited-Sources sync mit externem Tool (refresht zusätzlich `in_portfolio`-Flags via SyncPortfolioDomainsJob) | — |
| POST   | `/tracking-projects/{id}/own-domains` | Domain als eigene übernehmen (Claim, Pendant zur Cited-Sources-Hover-Aktion). Body: `{domain}` — entfernt sie ggf. aus den Wettbewerbern, reklassifiziert bestehende Citations sofort. Returnt `{domain, own_domains[]}`; 422 bei ungültiger Domain | — |
| GET    | `/ai-visibility/{id}/business-profile` | Business-Profil (Geo-Scope, Standort, Einzugsgebiet, Zielgruppen-Linse mit `likely_queries`) — `{profile: null}` solange keins gebaut | — |
| PATCH  | `/ai-visibility/{id}/business-profile` | Profil korrigieren. Body: `{geo_scope?, city?, service_area_cities[]?}` — 422 ohne vorhandenes Profil; synct Legacy-Spalten | — |
| POST   | `/ai-visibility/{id}/business-profile/refresh` | Profil neu analysieren (Multi-Page-Crawl + Klassifikation, async). 202; Poll via GET `refresh_status` **K#10894:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. | 5 |
| GET    | `/ai-visibility/{id}/article-requests` | Partner-Outreach-Anfragen des Projekts (`?per_page=`) | — |
| POST   | `/ai-visibility/{id}/article-requests` | Artikel-Platzierung beim Partner anfragen. Body: `{domain, prompts[], keywords[], message?, consent:true}`. 422 ohne `consent`, 409 wenn bereits aktive Anfrage für die Domain | 0 |
| DELETE | `/ai-visibility/{id}/article-requests/{requestId}` | Anfrage-Status entfernen (status=withdrawn) | — |
| GET | `/tracking-projects/{}/keyword-suggestions` | Listet Sitemap-basierte Keyword-Vorschläge der aktuellen Projektdomain (nach einem Domainwechsel sind die der alten ausgeblendet, Karte #7409), paginiert; `?language=en` filtert nach der Sprachversion der Fundseite (Plan #181) · `?sort=volume\|keyword\|source` · `?dir=asc\|desc` · `?q=` (Keyword, Fundseite) · `?per_page=30` (max 200), unbekannter `sort`/`dir` → 422 (Plan #183). Jede Zeile trägt `discovered_from_url` (die Fundseite, die das Keyword am stärksten trägt) und `discovered_page_count` (auf wie vielen Seiten das Thema gefunden wurde; `null` = vor Karte #8662 angelegt) | — |
| POST | `/tracking-projects/{}/keyword-suggestions/convert` | Nimmt mehrere Vorschläge in EINEM Aufruf ins Tracking (Plan #194); nur Vorschläge der aktuellen Projektdomain (Karte #7409). Body `{keyword_ids:[…]}` oder `{all:true}`, optional `language`; ohne beides → 422. Antwort `{requested, matched, converted, skipped_existing}`. Idempotent je Prompt-Text; fremde Kennungen fallen still heraus (`matched < requested`). **Jeder Prompt kostet bei jedem Lauf Credits** — erst listen, dann übernehmen | — |
| POST | `/tracking-projects/{}/prompts/bulk-classify-intent` | Klassifiziert alle unklassifizierten Prompts asynchron nach Funnel-Stage (202) | — |
| POST | `/tracking-projects/{}/prompts/bulk-monitor` | Setzt Monitor-Status für mehrere Prompts per ID-Liste | — |
| GET | `/tracking-projects/{}/prompts/cost-preview` | Schätzt monatliche Monitoring-Kosten für aktive/ausgewählte Prompts; `monthly_credits` (bis zu ca., aus derselben Planung wie die Abbuchung), `credits_per_run`, `credit_breakdown`, seit 22.09.2026 `cost_warning` (`balance`, `quota`, `estimate`, `exceeds_balance`, `exceeds_quota` — dieselbe Schwelle wie Assistent und Projekteinstellungen; `quota: 0` = kein Kontingent hinterlegt, also unbegrenzt; warnt, sperrt nicht), seit 24.09.2026 `rank_tracker_monthly_credits` (bis zu ca. je Monat für die geplanten Rank-Tracker-Läufe bei der gespeicherten Frequenz, 0 wenn der Rank-Tracker aus ist; `cost_warning.estimate` ist die Summe beider) — die älteren Felder (`monthly_cost_eur`, `monthly_call_count` …) sind eine Anbieterkosten-Schätzung und bleiben nur für bestehende Aufrufer | — |
| POST | `/tracking-projects/{}/prompts/refresh-volumes` | Aktualisiert Prompt-Suchvolumina asynchron per Job (202) | — |
| POST | `/tracking-projects/{}/prompts/{}/classify-intent` | Klassifiziert einzelnen Prompt synchron nach Funnel-Stage | — |
| PATCH | `/tracking-projects/{}/prompts/{}/monitor` | Setzt Monitor-Status eines Prompts (active/paused/excluded) | — |
| GET | `/tracking-projects/{}/sitemap-pages` | Listet gescannte Sitemap-Seiten der aktuellen Projektdomain paginiert (Karte #7409). `wartet_auf_domain_seit` + `wartet_auf_domain_bis` (v1 oben im Rumpf, v2 unter `meta`): gesetzt, solange die Seiten-Kette wartet, weil ein anderer Lauf die Domain abruft | — |
| POST | `/tracking-projects/{}/sitemap-scan` | Startet asynchronen Sitemap-Scan, liefert entdeckte Sitemap-URL (202, `status: queued`); **409 `scan_already_running`** (Body `error`, `message`, `status`, `tracking_project_id`), solange für das Projekt ein Scan wartet oder läuft — dann kein zweiter Auftrag und keine Abbuchung; weiter pollen statt erneut starten (Plan #287). `status` ist im Regelfall `queued` oder `running` und wird frisch gelesen; hielt ein hart abgebrochener Lauf die Sperre noch, kann er ausnahmsweise terminal sein (`completed`/`failed`/`no_sitemap`) — die Sperre läuft dann von selbst ab, ein erneuter Versuch lohnt erst danach (präzisiert 23.09.2026). **K#10894:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. | 5 bei gefundener Sitemap |

### Freigaben (Cross-Team-Sharing)

Ein TrackingProject an einen anderen **bestehenden** Rankion-Nutzer (auch in einem anderen Team) mit **Lese- UND Schreibzugriff** freigeben. Läufe, die ein Gast startet, belasten das **Eigentümer-Team**. Auth ist pro Endpoint asymmetrisch (Eigentümer vs. Eingeladener) — siehe „Auth"-Spalte.

| Method | URL | Beschreibung | Auth · Credits |
|--------|-----|--------------|----------------|
| GET    | `/tracking-projects/{id}/shares` | Freigaben eines Tracking-Projekts auflisten (Status, eingeladene E-Mail, Berechtigung) | nur Eigentümer · 0 |
| POST   | `/tracking-projects/{id}/shares` | Freigabe anlegen (Einladung). Body: `{email}` — lädt einen bestehenden Nutzer mit `permission:"write"` ein. Idempotent (reaktiviert abgelaufene/terminierte Freigaben auf derselben Zeile). Stellt via „Mit mir geteilt" + E-Mail (`TrackingProjectShared`) zu | nur Eigentümer · 0 |
| DELETE | `/tracking-projects/{id}/shares/{share}` | Freigabe widerrufen (status→`revoked`) | nur Eigentümer · 0 |
| GET    | `/tracking-projects/{id}/report-schedule` | Berichts-Zeitplan des Insights-Reports (K#9378): `active`, `interval_count`, `interval_unit` (`days`/`weeks`/`months`), `starts_at`, `next_due_at`, `locale`, `last_sent_at`, `recipients[{email, confirmed}]`. `data: null` = kein eigener Zeitplan, es gilt der Sonntagsversand an den Team-Inhaber | Projektzugriff · 0 |
| PUT    | `/tracking-projects/{id}/report-schedule` | Zeitplan anlegen/ändern. Body: `{active, interval_count (1–365), interval_unit, starts_at, locale, recipients[] (max. 20)}`. Adressen ohne Projektzugriff bekommen eine Bestätigungsmail (Double Opt-in) und bis dahin keinen Bericht. Unbekannte Einheit → 422. Gast mit Projektfreigabe → 403 (K#10148) | Schreibrecht im Besitzer-Team · 0 (jeder versandte Bericht: Kosten des Data-Mining-Exports) |
| DELETE | `/tracking-projects/{id}/report-schedule` | Zeitplan samt Empfängern löschen — danach wieder Sonntagsversand an den Team-Inhaber. Gast mit Projektfreigabe → 403 (K#10148) | Schreibrecht im Besitzer-Team · 0 |
| GET    | `/tracking-projects/{id}/analysis-report-schedule` | Zeitplan des Analyseberichts per Mail (K#11316): dieselben Felder wie `report-schedule`, dazu `report_type` (`analysebericht`) und `credits_per_send` (11). `data: null` = kein Analysebericht per Mail | Projektzugriff · 0 |
| PUT    | `/tracking-projects/{id}/analysis-report-schedule` | Zeitplan anlegen/ändern, Body wie `report-schedule`. Unabhängig vom Insights-Zeitplan. Ohne neue Messung seit dem letzten Versand wird nichts erstellt. Gast mit Projektfreigabe → 403 | Schreibrecht im Besitzer-Team · 0 (jeder Versand: 10 Bericht + 1 Versand, gebucht erst nach erzeugtem PDF bzw. zugestellter Mail) |
| DELETE | `/tracking-projects/{id}/analysis-report-schedule` | Zeitplan samt Empfängern löschen; der Insights-Zeitplan bleibt. Gast mit Projektfreigabe → 403 | Schreibrecht im Besitzer-Team · 0 |
| GET    | `/shared-with-me` | Mit dem authentifizierten Nutzer geteilte Projekte (Gast-Sicht) | jeder · 0 |
| POST   | `/tracking-project-shares/{share}/accept` | Freigabe annehmen (E-Mail-Match, atomarer `lockForUpdate`-Consume → status→`active`) | nur Eingeladener · 0 |
| POST   | `/tracking-project-shares/{share}/decline` | Freigabe ablehnen (status→`declined`) | nur Eingeladener · 0 |
| DELETE | `/tracking-project-shares/{share}` | Freigabe entkoppeln/verlassen (status→`detached`) | nur Eingeladener · 0 |

### Keywords / Prompts

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tracking-projects/{id}/keywords` | `?active_only=true` |
| POST   | `/tracking-projects/{id}/keywords` | Body: `{keyword, keyword_group?, tags?, search_volume?, cpc?, difficulty?, search_intent?, language?, country?, source?}` · `source` ∈ `manual\|sitemap\|suggestion\|imported` (Plan #87); `is_active` wird **nicht** validiert und daher ignoriert |
| PUT    | `/tracking-keywords/{id}` | |
| DELETE | `/tracking-keywords/{id}` | |
| GET    | `/tracking-projects/{id}/prompts` | |
| POST   | `/tracking-projects/{id}/prompts` | Body: `{prompt_text, prompt_category?, persona_label?, commercial_intent?, llm_platforms?[]}` |
| PUT    | `/tracking-prompts/{id}` | |
| DELETE | `/tracking-prompts/{id}` | |
| POST   | `/tracking-projects/{id}/prompts/extract` | Gibt ALLE (oder Filter-Subset) Prompts des Projekts im Response zurück (Backup/Export → Excel/CSV) UND löscht sie atomar — 1 Call statt N Einzel-DELETEs. Filter (optional, AND): `prompt_ids[]`, `active_only`, `category`. → `{extracted[], deleted_count}` | — |
| POST   | `/tracking-projects/{id}/prompts/import` | CSV/Excel Bulk-Import. multipart/form-data: `file`, optional `mapping[col_name]=index`. → `{imported, skipped, errors, mapping_used}` |
| POST   | `/tracking-projects/{id}/prompts/{promptId}/classify-persona` | Persona EINES Prompts per AI setzen (constrained auf `target_audiences[].label`, sync). → **200** + `{tracking_prompt_id, persona_label, classified_at}`. `is_user_edited`-Personas geschützt · 0 |
| POST   | `/tracking-projects/{id}/prompts/bulk-classify-persona` | Alle Prompts ohne `persona_label` projektweit klassifizieren (dispatcht `BackfillPersonaJob`, generiert ggf. zuerst Personas, async). → **202** + `{tracking_project_id, pending_classifications, status:"queued"}` · 0. **Mit `neu_zuordnen: true`** (KI-Persona zuordnen, 30.10.2026): Prompts ohne Persona UND frühere KI-Zuordnungen werden neu geprüft, von Hand gewählte (`persona_source = user`) bleiben. Braucht Schreibrecht (`update`). → **202** + `{run, status:"queued"}`; **409** + `{run, status:"already_running"}`, wenn schon ein Lauf aktiv ist; **422** ohne Persona im Projekt · 0 |
| GET    | `/tracking-projects/{id}/prompts/persona-assignment` | Status des letzten Laufs „KI-Persona zuordnen“: `{run: {id, status, total, processed, assigned, started_at, completed_at, error, created_at}}`; `run` ist `null`, solange kein Lauf stattfand. `status`: `pending` \| `processing` \| `completed` \| `failed` · 0 |
| GET    | `/tracking-projects/{id}/personas` | Personas des Projekts (K#8903): `[{label, description, prompts}]` — aus `target_audiences` plus Namen, die nur an Prompts stehen · 0 |
| POST   | `/tracking-projects/{id}/personas` | Persona anlegen. Body `{label, description?}` (höchstens 100 Zeichen, eindeutig ohne Groß/Klein). → **201** + Liste; 422 bei Doppelname · 0 |
| PATCH  | `/tracking-projects/{id}/personas` | Persona umbenennen. Body `{label, new_label}` — alle Prompts des Projekts mit dem alten Namen bekommen den neuen. → `{label, renamed_prompts}`; 422 `new_label`, wenn der Name vergeben ist (kein Zusammenführen) · 0 |
| DELETE | `/tracking-projects/{id}/personas` | Persona löschen. Body `{label, confirm}` — ohne `confirm: true` **409** `confirmation_required` + `affected_prompts`, nichts geändert. Mit Bestätigung → `{label, unassigned_prompts}`: die Prompts bleiben, verlieren nur die Zuordnung (nächtlicher `BackfillPersonaJob` ordnet neu zu) · 0 |

**Prompt-Liste mit mehrdimensionalem Filter** — `GET /tracking-projects/{id}/prompts` akzeptiert: `?search=` (**Volltextsuche** auf `prompt_text`, MariaDB-FULLTEXT Boolean-Mode: mehrere Wörter = UND, `+pflicht -ausschluss "exakte phrase"`; Substring-Fallback bei <3-Zeichen-Termen), `?tags[]=` (Tag-IDs; OR innerhalb Dimension, AND zwischen Dimensionen), `?category[]=` (string ODER array, back-compat), `?intent[]=`, `?funnel[]=`, `?persona[]=`, `?language[]=`, `?monitor_status[]=`, `?volume_min=`, `?active_only=1`, `?per_page=` (paginiert sonst flache Liste).

### Prompt-Tagging (team-weite Tag-Taxonomie + Zuweisungen)

Alle Ressourcen team-gescoped (Fremd-Team → 404). 4 System-Dimensionen (`topic`/`competitor`/`sub_intent`/`campaign`) pro Team auto-geseedet.

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tag-dimensions` | Team-Dimensionen (+`tags_count`) · 0 |
| POST   | `/tag-dimensions` | `{key:/^[a-z0-9_]+$/, label, color?, icon?}` → 201 · 0 |
| PATCH  | `/tag-dimensions/{id}` | `{label?, color?, icon?, sort_order?}` · 0 |
| DELETE | `/tag-dimensions/{id}` | System-Dimension → **422** · 0 |
| GET    | `/prompt-tags` | `?dimension={id}` optional · 0 |
| POST   | `/prompt-tags` | `{dimension_id, label, color?}` — slug-dedupe → 201 · 0 |
| PATCH  | `/prompt-tags/{id}` | `{label?, color?, description?}` (label → neuer slug) · 0 |
| DELETE | `/prompt-tags/{id}` | aktive Assignments vorhanden → **422** (erst mergen/lösen). Soft-Delete · 0 |
| POST   | `/prompt-tags/{id}/merge` | `{target_id}` — Assignments umhängen+dedupen, Quelle soft-delete · 0 |
| POST   | `/tracking-prompts/{id}/tags` | `{tag_id}` zuweisen → 201 · 0 |
| DELETE | `/tracking-prompts/{id}/tags/{tagId}` | Zuweisung entfernen · 0 |
| POST   | `/tracking-projects/{id}/prompts/bulk-tag` | `{prompt_ids[≤500], tag_id, operation:add\|remove}` → `{updated, skipped}` · 0 |
| POST   | `/tracking-projects/{id}/prompts/apply-single-tag` | `{tag_id}` — EINEN vorhandenen Tag im Hintergrund auf alle Prompts des Projekts anwenden (additiv: legt nichts an, aendert keine Zuweisung). Nicht KI-faehige Dimension → **422**. → **202** `{id, status, tag_id, poll_url}` + `Location` · 5 |
| GET    | `/tracking-projects/{id}/prompts/taxonomy-run/{runId}` | Lauf-Status: `{status, scope_tag_id, single_tag, proposed_count, is_running, is_single_tag_run}`. Fremder Lauf → **404** · 0 |

### Cited Sources

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tracking-projects/{id}/cited-sources` | `?domain=&outreach_status=&is_own_domain=&sort=&per_page=`. Zeile enthält `in_portfolio` (bool, „Anfrage möglich"), `citation_possibility_score` (int, persistierter Floor 95 für Portfolio-Domains) + `effective_possibility` (int, identischer Read-Time-Wert), `content_type_reason` (string\|null): Grund, warum `content_type` leer ist — `own_domain` (eigene Domain, wird nicht abgerufen), `fetch_failed` (Seite nicht abrufbar; bis zu 3 automatische Versuche im Abstand von 24 h), `pending` (Abruf ausstehend oder laufend), `unclassified` (Seitenkopie vorhanden, Einordnung ausstehend); `null`, sobald `content_type` gesetzt ist. Kein Filterparameter; berechnet aus `is_own_domain`, `deep_scan_status` und `content_type` (Plan #221). Dazu `citation_possibility_reason` (string\|null): Grund, warum `citation_possibility_score` leer ist — `deferred` (unter der Relevanzschwelle, wird ab dem zweiten Zitat bewertet), `pending` (eingereiht, Lauf steht aus), `own_or_competitor` (hart auf 0, Anfrage nicht möglich), `no_data` (Lauf ohne Ergebnis; Sammelfall); `null`, sobald ein Wert vorliegt. **Nur in den URL-Zeilen** — `?group_by=domain` baut seine Zeilen aus benannten Schlüsseln und trägt das Feld nicht. **`?group_by=domain`** (Karte #7396): eine Zeile je Domain — `url_count`, `citations`, `platforms`, `avg_value_score`, `avg_possibility_score`, `avg_reach`, `first_seen`, `last_seen`, `is_own_domain`, `in_portfolio`; `?from=&to=` (Vorgabe 30 Tage), `outreach_status`, `platform`, `domain`, `page`, `per_page` (max. 100); `sort` = `reach_desc`\|`reach_asc`\|`citations_desc`\|`citations_asc`\|`urls_desc`\|`urls_asc`\|`value_desc`\|`value_asc`\|`possibility_desc`\|`possibility_asc`\|`domain_az`\|`domain_za`\|`first_seen`\|`first_seen_desc`\|`last_seen`\|`last_seen_asc`\|`opportunity_desc`\|`portfolio_desc`, unbekannt → `422` |
| GET    | `/tracking-projects/{id}/cited-sources/export` | CSV-Stream. `?from=&to=&status=&platform=&search=&sort=` |
| GET    | `/tracking-projects/{id}/exports` | CSV-Themenkatalog der Projektseite (K#11111): `{themen: [{slug, label, beschreibung, spalten[]}], formate: [csv, xlsx], csv: {presets, delimiter[], encoding[], eol[]}}` · 0 |
| GET    | `/tracking-projects/{id}/exports/{thema}` | Ein Thema als Datei. `?format=csv\|xlsx&preset=windows\|linux\|apple&delimiter=semicolon\|comma\|tab&encoding=utf8-bom\|utf8&eol=crlf\|lf&from=&to=` (Y-m-d, Vorgabe 30 Tage). Unbekannte Formwerte fallen auf die Voreinstellung zurueck; unbekanntes Thema → **404**, falsches Datum oder Format → **422**. Ueber `export.sync_row_threshold` Zeilen → **202** `{export_id, uuid, status, status_url}`, Datei danach unter `GET /v1/exports` · 0 |
| POST   | `/tracking-projects/{id}/cited-sources/analyze` | Citation-Analyse dispatchen. Body optional `{source_ids:[]}`. Default: alle unanalyzed. → 202 + `{queued, estimated_seconds}` |
| PATCH  | `/tracking-projects/{id}/cited-sources/{source_id}` | Body: `{outreach_status?:neu\|anfrage_gestellt\|verlinkt\|abgelehnt\|ignoriert, notes?}` |

### Insights

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tracking-projects/{id}/scores` | Score-Historie (`?from=&to=&period=`), `period` = `daily` (Standard), `weekly`, `monthly` oder `all`; vor dem 10.10.2026 kamen alle Perioden gemischt |
| GET    | `/tracking-projects/{id}/platform-breakdown` | (`?from=&to=`). Je Plattform `{platform, total, visibility, avg_position, sentiment, recommendation_rate, citation_rate, citations_measurable, citations_messbar_seit, measurement_note}`. `citations_measurable=false` heißt **nicht gemessen**, nicht „null Zitate" — `citation_rate` ist dann keine Aussage über die Plattform. `citations_messbar_seit` ist der Stichtag, ab dem gespeicherte Zitatwerte belastbar sind. **`null` heißt NICHT „nicht messbar"**, sondern nur, dass es keinen Stichtag gibt — entweder waren Zitate schon immer messbar (`citations_measurable=true`, das trifft auf 6 der 8 Plattformen zu) oder die Plattform weist gar keine aus (`citations_measurable=false`). Entschieden wird ausschließlich an `citations_measurable`, nie an `citations_messbar_seit`. `measurement_note` nennt den Grund im Klartext. Die letzten drei Felder kommen aus `LlmPlatformRegistry`, die übrigen sechs sind Aggregate aus `llm_results` |
| GET    | `/tracking-projects/{id}/insights/low-hanging-fruit` | (`?days=30&min_possibility=60&limit=5`) oder freier Zeitraum `?from=YYYY-MM-DD&to=YYYY-MM-DD` (Zeitraum-Regeln unten). Returnt `{items, meta:{total, minPossibility, from, to, days, range_adjusted, dateRange}}` — `dateRange` ist der alte Name für `days` und bleibt für bestehende Aufrufer |
| GET    | `/tracking-projects/{id}/insights/platform-gap` | (`?days=30`) oder `?from=&to=`. Der verwendete Zeitraum steht in `period:{from, to, days, range_adjusted}`. `platforms[code]` trägt seit dem 24.09.2026 zusätzlich `citations_measurable`, `citations_messbar_seit`, `measurement_note` (Bedeutung wie bei `platform-breakdown`) sowie `sentiment`, `citation_rate`, `recommendation_rate`. Davor lieferte **nur** der Zweig `insufficient_platforms`/`cold_start` diese sechs Schlüssel, der Regelfall nicht — ein Leser bekam dieselbe Auskunft also je nach Datenlage oder gar nicht. **Achtung, die Antwortform unterscheidet sich je Zweig:** im Regelfall heißt die Abfragezahl `total_queries` und `avg_position` fehlt; die Zweige `insufficient_platforms` und `cold_start` reichen den Aufriss von `platform-breakdown` unverändert durch und tragen dort `total`, `avg_position` und `platform`. Lies deshalb `total_queries ?? total`, sonst zeigst du im Sonderfall 0 Abfragen an |
| POST   | `/tracking-projects/{id}/insights/platform-gap/{platform}/diagnose` | async — 1 Credit. **Gebucht wird nur bei Bestand (K#10808 #7743, 26.10.2026):** liegt für die Plattform im 30-Tage-Fenster keine Messzeile vor, antwortet der Endpunkt mit `422 platform_without_data` (`platform`, `window_days`) und zieht keinen Credit ab; eine nur *konfigurierte* Plattform genügt nicht mehr |
| GET    | `/tracking-projects/{id}/insights/potential-hero` | (`?days=30`) oder `?from=&to=`. Ab 20 Abfragen im Zeitraum `{eligible:true, total_queries, mentions_count, alias_gaps:[{variant, occurrences}], top_competitor:{brand, visibility}\|null, potenzial_score, score_breakdown:{base_score, alias_bonus, citation_bonus}, realisable_range:[min,max], top_potential_prompts:[…]}`, darunter `{eligible:false, reason:"insufficient_data", threshold:20}`; der verwendete Zeitraum steht in `period:{from, to, days, range_adjusted}` (in beiden Fällen). Bis zum 21.09.2026 kam wegen eines Schlüsselfehlers IMMER `eligible:false` (Plan #285) |
| GET    | `/tracking-projects/{id}/off-page-readiness` | Off-Page-/Demand-Achsen-Score — 0 Credits, team-scoped. Returns `{score:int\|null, data_available, components:{geo.offpage.brand_demand, geo.offpage.third_party_mentions, geo.offpage.source_affinity → {score, data_available, meta, weight}}, meta}`. `score=null`=noch keine Daten (≠0). 403 Fremd-Team, 404 unbekannt. SSOT `OffPageReadinessScorer` (bridged `community_project_id` ↔ `tracking_project_id`). Modul: `geo-criteria-catalog` |
| POST   | `/tracking-projects/{id}/prompts/{prompt}/generate-brief` | Content-Brief via Claude — 3 Credits |
| POST   | `/tracking-projects/{id}/brand-aliases` | Body: `{alias}` |
| GET    | `/tracking-projects/{id}/competitors` | Gemeinsame Wettbewerberliste des Projekts. Returnt `{competitors:[{domain, brand_name, source, source_url, is_manual, is_active}]}`, nach Domain sortiert — **inklusive der abgewaehlten** (`is_active:false`), damit ein Aufrufer die Abwahl sieht statt eine Luecke zu raten. `source`: `wizard` \| `api` \| `analysis` \| null (von Hand). Reicher als das flache `competitor_domains` aus `GET /tracking-projects/{id}` |
| PATCH  | `/tracking-projects/{id}/competitors` | Body: `{domains[]}` (max 50) — normalisiert/dedupliziert, synct `competitor_domains` + `TrackingCompetitor`. Returnt `{competitor_domains[]}`. Powert das Gap-Wettbewerber-Modal |
| POST   | `/tracking-projects/{id}/competitors/discover` | Wettbewerber neu ermitteln lassen — EIN OpenAI-Aufruf mit Websuche, ohne den ganzen Einrichtungslauf. Body optional `{limit}` (1-30, Vorgabe 10). **202** `{status:'queued', tracking_project_id, credits_required, poll_url}`; **503** `discovery_unavailable`, wenn kein OpenAI-Zugang eingerichtet ist. Fortschritt ueber `GET /tracking-projects/{id}/analysis-status` → `competitor_rediscovery_status` (`queued` \| `running` \| `completed` \| `failed`) und `competitor_discovery` (`ok` \| `empty` \| `failed` — `empty` heisst gesucht und nichts gefunden, `failed` heisst nicht suchen koennen). Das Ergebnis ist ein **Vorschlag** in `suggested_competitors`; die bestaetigte Liste bleibt unangetastet, bestaetigt wird ueber `PATCH .../competitors`. Abgezogen wird NACH Erfolg **K#10894:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. — 5 Credits |

**Zeitraum der drei Insights-Endpunkte** `low-hanging-fruit`, `platform-gap`, `potential-hero` (seit 21.09.2026, dieselben Regeln wie der Zeitraum-Knopf der Oberfläche, SSOT `App\Support\AiVisibilityZeitraum`):
- **`days=N`** (Standard 30) heißt wie bisher „N Tage zurück bis heute", also N+1 Kalendertage einschließlich heute (`days=7` am 21.09. = 14.09. bis 21.09.). Werte außerhalb 1–365 werden begrenzt.
- **`from` + `to`** (beide `YYYY-MM-DD`, nur zusammen) statt `days`: ganze Kalendertage, beide eingeschlossen, höchstens 365 Tage, nicht in der Zukunft. Ein `from` vor dem Fenster wird auf den frühesten erlaubten Tag (heute − 364) angehoben, ein `to` nach heute auf heute; liegt der ganze Zeitraum außerhalb, bleibt ein einzelner Tag am Rand des Fensters.
- Jede solche Kürzung steht als `range_adjusted: true` in der Antwort. `days` in der Antwort ist die Stufe bzw. bei freiem Zeitraum die Zahl der Kalendertage.
- **422** mit Feldfehler: nur eines der beiden Daten, kein gültiges Datum, `from` nach `to`, `days` keine Ganzzahl.

### Brand-Accuracy (Faktencheck)

Surface der Phase-0-Fact-Check-Engine: jeder `LlmResponseClaim`-Befund (Abweichung
zwischen LLM-Aussage und vouchter Wahrheitsquelle) wird hier gelistet, aufgelöst und
re-verifiziert. Alle Routen team-/share-scoped; `{claim}` ist nested-IDOR-validiert
(Claim muss zum `{id}`-Projekt gehören → sonst 404). Seed/Refresh crawlen NUR die
Eigen-Domains des Projekts (SSRF-restricted). Kosten-amplifizierende Routen: `throttle:5,1`.

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tracking-projects/{id}/accuracy` | Claim-Liste. Filter `?status=&severity=&platform=&resolution_status=&claim_type=&resolution_reason=&search=` (`search` = `claim_normalized` LIKE; `claim_type` ∈ price\|duration\|feature_availability\|technical_spec\|history\|rating\|other; `resolution_reason` ∈ RESOLUTION_REASONS — z.B. die „Zu bewerten"-Lane via `resolution_reason=inconclusive`), sortiert Severity (critical→low) + `last_seen_at`. Paginiert `?per_page=` (hard cap **100**), eager-load `atom.llmResult`. Item-Shape via `toAccuracyPayload()` (inkl. `resolution_status`, `ground_truth_source_anchor{tier,source,passage}`, `response_excerpt`). 403 Fremd-Team. — 0 |
| POST   | `/tracking-projects/{id}/accuracy/{claim}/resolve` | Body: `{resolution_status: resolved\|false_positive}`. Setzt `resolved_by_user_id` + `resolved_at`. **404** wenn `{claim}` nicht zu `{id}` gehört (IDOR-Cross-Check). Returnt das aktualisierte Payload. — 0 |
| POST   | `/tracking-projects/{id}/accuracy/reverify` | On-demand Re-Verify offener+stale Claims (`ReverifyProjectClaimsJob`, isolierte `ai-tracking`-Queue, `ShouldBeUnique`). Per-Projekt `RateLimiter` 1/h → 429 wenn zu früh; **402** bei zu wenig Credits; sonst **202** + `{data:{status:"queued", tracking_project_id, message}}`. `credits:5` (Gate; Abbuchung im Job nur bei ≥1 neu geprüftem Claim) + `throttle:5,1` **K#10894:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. — 5 |
| POST   | `/tracking-projects/{id}/accuracy/backfill` | **Erst-Prüfung** des bestehenden Antwort-Korpus: fächert pro Claim-Atom-ohne-Claim einen `ExtractAndVerifyClaimJob` (`ai-factcheck`-Queue) auf — der Observer ist forward-only und `reverify` ist auf einem frisch-armierten Projekt ein No-op, daher erzeugt **dieser** Endpoint die ERSTEN Claims (`BackfillProjectClaimsJob`, isolierte `ai-tracking`-Queue, `ShouldBeUnique`, Cap `brand_accuracy.fact_check_cap_per_run`=500). Per-Projekt `RateLimiter` 1/h → 429; sonst **202** + `{data:{status:"queued", tracking_project_id, message}}`. `throttle:5,1`. Kostenlos (kein Credit-Gate; die Cross-Checks werden nicht hier abgerechnet) — 0 |
| POST   | `/tracking-projects/{id}/truth-source/seed` | Befüllt `extracted_facts` aus Brand-Profil (`brand_name`/`aliases`/`usps`/`products`/`profile`/`own_domains`) + optional `?discover_website=true` (Eigen-Domain-Crawl, SSRF-safe). Quelle bleibt **unreviewed** (User muss bestätigen → armt erst dann das Gate). **202** + `{data:{status:"queued", truth_source_id, facts_count, human_reviewed, message}}`. `throttle:5,1` — 0 |
| POST   | `/tracking-projects/{id}/truth-source/refresh` | Re-Crawl des Website-Tiers — Eigen-Domains ONLY (SSRF). **202** + `{data:{status:"queued", truth_source_id, message}}`. `throttle:5,1` — 0 |
| GET    | `/tracking-projects/{id}/brand-kb` | **Auto-KB Coverage-Status** der Eigen-Domain-Webseiten-KB (`brand_kb_passages`). Flat-Shape (v1): `{pages, passages, last_crawled_at, verifiable_pct, fidelity_pct, indexing}`. `fidelity_pct` = Faktentreue, Anteil der geprüften Claims, die nicht `false`/`outdated` sind (`null` ohne geprüften Claim). `verifiable_pct` = Anteil Claims mit `verification_status ∈ {true,false,outdated,partial}` an allen Claims (identische Formel wie das `accuracyCoverage`-Computed). `indexing` liest seit Plan #105 A3b den GESPEICHERTEN Lauf-Zustand (`kb_run_status` in `queued`/`running`, innerhalb der Verwaist-Frist `KB_RUN_STALE_AFTER`) statt der alten Formel „KB leer + Projekt `active`" — die blieb `true`, auch wenn nie ein Crawl lief, und ein Polling darauf lief unbegrenzt. Ein Projekt ohne je gemessenen Lauf (`NULL`) faellt auf das alte Verhalten zurueck. 403 Fremd-Team. — 0 |
| POST   | `/tracking-projects/{id}/brand-kb/rebuild` | **Eigen-Domain Re-Crawl + Re-Embed** der Webseiten-KB. Der `BrandKbRunStarter` waehlt den Job: `RefreshBrandKbJob` bei vorhandener Wissensbasis (Diff + gezielte Nachpruefung), `BuildBrandKbJob` bei leerer. Beide auf der eigenen Queue `crawl` (nicht `ai`), Redirects DISABLED + `assertSafeUrl`. Hash-Dedup (`UNIQUE(project, passage_hash)`) macht den Re-Build idempotent. **202** + `{data:{status:"queued"\|"already_running", tracking_project_id, message}}` — `already_running` heisst: es laeuft bereits ein Lesevorgang, es wurde NICHTS eingereiht (Plan #105 A4c; vorher schwieg die Route darueber und der Aufrufer bekam eine Zusage ohne Lauf). `throttle:5,1`. Das Lesen der eigenen Webseite ist kostenlos; die anschliessende Nachpruefung laeuft mit `charge=true` und wird wie `accuracy/reverify` abgerechnet — kein Credit-Gate auf der Route **K#10894:** nur das Besitzer-Team — Gäste mit Projektfreigabe bekommen 403. — 0 |
| GET    | `/tracking-projects/{id}/kb-refresh` | **Auto-KB Auffrisch-Häufigkeit** (Freshness-Loop): effektive Kadenz + deren Quelle + zuletzt eingelesen. `{data:{effective_frequency, override, team_default, source, last_crawled_at}}`. `source` = `override` (pro-Projekt gesetzt) > `team` (Team-`brand_kb_refresh_default`-Settings-Row existiert) > `config` (hard-coded Default `weekly`). `effective_frequency`/`team_default` ∈ `daily\|weekly\|biweekly\|monthly\|off`; `override` = der gesetzte Wert oder `null` (= erbt). `last_crawled_at` = jüngster `brand_kb_passages.crawled_at`. team-/share-scoped (`authorizeTrackingProject` → 403 Fremd-Team) — 0 |
| PATCH  | `/tracking-projects/{id}/kb-refresh` | Setzt die **Pro-Projekt-Häufigkeit**. Body: `{frequency: daily\|weekly\|biweekly\|monthly\|off\|null}` (`present`+`nullable`) — `null` löscht den Override (erbt Team-/Config-Default). Validiert gegen `BrandKbRefreshFrequency::values()` → **422** bei ungültigem Wert. Returnt das identische GET-Payload (`kbRefreshPayload`). team-/share-scoped. `throttle:5,1` — 0 |
| GET    | `/team/kb-refresh-default` | **Team-Standard-Häufigkeit** für die Auto-KB-Auffrischung. `{data:{frequency: daily\|weekly\|biweekly\|monthly\|off}}` — `Team::getBrandKbRefreshDefault()`; fällt auf `config('brand_accuracy.kb_refresh_default')` (`weekly`) zurück, wenn keine Settings-Row existiert. Scope: `currentTeam()` — 0 |
| PATCH  | `/team/kb-refresh-default` | Setzt den **Team-Standard**. Body: `{frequency: daily\|weekly\|biweekly\|monthly\|off}` (`required`, kein `null`). Validiert gegen `BrandKbRefreshFrequency::values()` → **422** sonst; `Team::setBrandKbRefreshDefault()` (Settings-Row). Returnt `{data:{frequency}}`. Scope: `currentTeam()`. `throttle:5,1` — 0 |
| GET    | `/brand-knowledge` | **Chat-Erdung der eigenen Marke**: team-scoped Faktencheck-Paket in EINEM read-only Call. Auflösung der Marke via `?brand=` (id/name/domain) bzw. Auto-Single, sonst `disambiguation`. Query: `?brand=&q=&top_k=&include=`. `q` = KB-Query (RAG, `top_k` ∈ 1..10, Default 5); `include` = CSV-Subset aus `kb,facts,truth` (Default alle). FLAT-Shape: `{brand{id,name,domain}, armed, kb_status, kb_passages[], fact_errors[], truth_sources[], disambiguation[]}` (`kb_passages` mit `source_url`-Beleg) bzw. `{brand:null, disambiguation[], message}`. Liest `BrandKnowledgeService` (KB-Passagen + Claim-Verdicts + kuratierte Truth-Sources), kein Re-Crawl (dafür `brand-kb/rebuild`). Kuratierter Skill `ai-visibility.brand-knowledge`. `auth:sanctum`, `throttle:60,1`. 403 Fremd-Team. — 0 |

### Reality Check (AI Visibility Index)

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/tracking-projects/{id}/avi` | neuester AVI-Score, tier (red/yellow/green), 6-Dim-Scores, recommendations. 404 wenn kein Run |
| GET    | `/tracking-projects/{id}/dimensions` | alle 6 Dimension-Scores pro Plattform |
| GET    | `/tracking-projects/{id}/avi/methodik` | Methodik des letzten Reality-Check-Laufs wie im PDF: `plattformen[]` (slug, messart, modelle, antworten, zitate_messbar, im_avi, paesse[] mit Parametern), `fenster` {von, bis, lauf_start, lauf_ende}, `stichprobe`, `bewertungsmodell`, `prompts[]` (Wortlaut der gemessenen Fassung), `nicht_messbar[]` (slug, label, hinweis — Plattformen, die nie abgefragt werden, z. B. Amazon Rufus; nie in `plattformen[]` oder `stichprobe`, K#9249). 404 wenn kein Run (K#9241) |
| POST   | `/tracking-projects/{id}/reality-check-report` | PDF-Generierung async. 404 wenn kein Run |
| GET    | `/tracking-projects/{id}/reality-check-report/status` | Polling-Endpoint für die laufende Report-Generierung: `{status: generating / ready, avi_id, tracking_run_id, size_bytes?, last_modified?, download_url?}` — `download_url` trägt die Zugriffssignatur (`?sig=`) und ist direkt abrufbar (seit 21.09.2026, Plan #277) |

### Live Prompt Check (ad-hoc Multi-LLM-Fan-out)

User-Prompt geht parallel an ausgewählte LLMs (ChatGPT, Perplexity, Claude, Gemini, Copilot, Grok) je mit/ohne Web-Search. Live-Card-Grid in der UI, persistente Runs, Sentiment + Brand-Extract pro Antwort.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/prompt-check` | Liste runs des Teams. Filter: `?project_id=` · `?status=pending\|running\|completed\|partial\|failed` · `?search=` (Volltext auf prompt+title) · `?search_responses=` (Volltext im response_text) · `?sentiment=positive\|negative\|neutral\|not_mentioned` (≥1 Response) · `?platform=chatgpt\|perplexity\|claude\|gemini\|copilot\|grok` (≥1 Response) · `?per_page=25&page=1` | — |
| POST   | `/prompt-check` | Neuer Run async (202). Body: `{tracking_project_id, prompt (5-5000ch), platforms:["chatgpt","claude",…], with_search?:{platform:bool}, title?}` — freie Teilmenge (min 1) aus: chatgpt, perplexity, claude, gemini, copilot, grok. ⚠ `google_ai_mode` wird zwar validiert, aber im Fan-out STILL übersprungen (keine Response, keine Credits) — dafür TrackingProject-Pipeline nutzen | 1 pro Platform×Mode |
| GET    | `/prompt-check/{id}` | Detail: run + alle responses mit response_text, sentiment, brands_mentioned, citations, `error_code` (`permanent_upstream`\|`not_configured`\|`null` — bei gesetztem Code ist ein Retry zwecklos) | — |
| POST   | `/prompt-check/{id}/rerun` | Re-Run gleicher Settings, neue run-id (202) | 1 pro Platform×Mode |
| GET    | `/prompt-check/{id}/export` | Download `?format=csv\|json` | — |
| DELETE | `/prompt-check/{id}` | Soft-delete | — |

**Status-Lifecycle:** `pending → running → completed | partial | failed`. Polling alle 3-5s.
**Web-Search-Support:** chatgpt, perplexity, claude, gemini. Copilot/Grok ignorieren das Flag. google_ai_mode ist IMMER grounded (Google AI Mode via DataForSEO) und läuft genau 1× pro Prompt — kein with/without-search-Doppel, kein Reality-Check-Blind-Pass.
**Skills:** `prompt-check.{list-runs,get-run,create-run,export-run}` — Chatbot-fähig via Agentic-Skill-Index.

---

## 📝 Articles

### CRUD

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/projects/{project}/articles` | paginated 20 |
| POST   | `/projects/{project}/articles` | Body: `{title, slug?, content?, status?, cms_integration_id?, publish_status?(draft\|scheduled), scheduled_publish_at?, cms_publish_options?}`. `cms_integration_id` team-scoped (404 fremd); `publish_status=scheduled` braucht zukünftiges `scheduled_publish_at` (sonst 422). WordPress: `cms_publish_options.rest_base` (`posts`=Beitrag\|`pages`=Seite, regex-whitelisted) + `cms_publish_options.category_ids[]` (int, nur für `posts`). Shopify: `cms_publish_options.blog_id` (int, Ziel-Blog) + `cms_publish_options.tags` (string, kommasepariert). Der Cron publiziert sobald fällig + Content vorhanden | — |
| GET    | `/articles/{id}` | full payload — inkl. `processing_status` (`pending`/`generating`/`completed`/`failed`/`scanning`/`humanizing`), `processing_started_at`, `processing_completed_at` (ISO 8601, `null` solange nicht gesetzt); Polling-Ziel während der Generierung (Plan #258) |
| PUT    | `/articles/{id}` | Body: `{title?, slug?, content?, status?, meta_description?}` + optional Publish-Felder `{cms_integration_id?, publish_status?(draft\|scheduled), scheduled_publish_at?, cms_publish_options?}` zum nachträglichen Ändern (nur wirksam wenn `cms_integration_id` mitkommt; scheduled→422 ohne Zukunfts-Datum) |
| DELETE | `/articles/{id}` | |
| GET | `/articles/{}/optimization/runs` | Listet Score-Optimierungs-Runs eines Artikels paginiert | — |
| POST | `/articles/{}/optimize-loop` | Startet agentische Score-Optimierungsschleife asynchron (202, Poll-URL) | — |
| POST | `/articles/{}/submit-for-approval` | Artikel in einen Agentur-Freigabe-Workflow einreichen | 0 |

### Website-Import (Plan-Hub #32)

Scannt die Projekt-Domain nach vorhandenen Blog-Artikeln (Sitemap +
RSS-Autodiscovery + Blog-Pfad-Heuristik, JSON-LD `datePublished`/
`dateModified` wo vorhanden) und importiert eine ausgewählte Teilmenge als
`Article` (`source='imported'`, `imported_at`, `import_source_url`).
Vorschau+Auswahl-Workflow — kein automatischer Voll-Import.

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/projects/{project}/article-import/scan` — Body optional `{domain?}` (Default: `project.domain`). Dispatched den Scan-Job, 202 mit `{scan_id, status:"queued"}` | 5 (charge-on-success, kein Treffer → keine Abbuchung) |
| GET    | `/projects/{project}/article-import/scans/{scan}` — Status pollen (`pending\|running\|completed\|failed\|no_results`) + `candidates_found` | — |
| GET    | `/projects/{project}/article-import/scans/{scan}/candidates` — gefundene Kandidaten, paginiert (`url, title, detected_published_at, detected_modified_at, status`) | — |
| POST   | `/projects/{project}/article-import/scans/{scan}/import` — Body `{candidate_ids:[]}`. Erzeugt `Article`-Rows für die ausgewählten, noch nicht importierten Kandidaten. Returns `{imported, skipped_already_imported}` (bereits importierte werden übersprungen, kein Doppel-Import) | 0 (Kosten fielen beim Scan an) |
| POST   | `/projects/{project}/article-import/url` — Plan #131: EINE Adresse als Artikel mit Volltext einlesen. Body `{url}` (http/https, max. 750 Zeichen; Adressen außerhalb der Projekt-Domain sind erlaubt, die Antwort nennt `foreign_source:true`). Async: 202 mit `{scan_id, article_id, status:"queued", foreign_source}`. Fortschritt über `GET .../article-import/scans/{scan_id}` (`running` → `completed` \| `failed` + `error_message`, z. B. „Die Seite enthält keinen brauchbaren Text"); der Artikel über `GET /articles/{article_id}` trägt `processing_status` `generating` → `completed` (`content` = Haupttext ohne Bilder, `source='imported'`) und ist bei Misserfolg GELÖSCHT (404): es bleibt kein leerer Artikel stehen. 422 bei ungültiger Adresse, 402 ohne Credits (Gate vor dem Anlegen). | 1 (`FeatureCost::articleImportUrl`, charge-on-success: nur wenn ein Artikel mit Text entstanden ist) |

### Aktionen

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/articles/{id}/score` | — |
| POST   | `/articles/{id}/optimize` — 202 `{article_id, optimization_id, poll_url}`; Fortschritt über `GET /content-optimizer/{optimization_id}` (seit Plan #320 ein eigener Auftrag je Aufruf) | — |
| POST   | `/articles/{id}/generate` — Body: `{keyword, article_type?, outline?, target_length?, tone?, language?, style_profile_id?, content_goal_id?, use_knowledge_base?, knowledge_mode?, knowledge_document_ids?, briefing?}`. `knowledge_mode` ∈ `all`\|`specific`; bei `specific` werden `knowledge_document_ids[]` strikt auf die `ready`-Docs des Projekts gescopt (fremde IDs verworfen). `briefing` (K#9296, höchstens 5000 Zeichen) fließt in Gliederung und Text, etwa aus `GET /tracking-projects/{id}/potential-gaps/briefing` | 5 |
| POST   | `/articles/{id}/publish` — Body: `{cms_integration_id}`. Unterstützt WordPress **und Shopify** (canonical job-mapping). 202 mit `{publication_id, status:pending}`; 409 wenn bereits in flight; 422 bei nicht-unterstütztem CMS-Typ | — |
| GET    | `/articles/{id}/publications` — CMS-Veröffentlichungs-Historie des Artikels (neueste zuerst). `data[]`: `{id, cms_integration_id, cms_name, cms_type, status, external_id, external_url, error_message, published_at, created_at}`. `status` ∈ `pending\|publishing\|published\|failed` | — |
| GET    | `/articles/{id}/images` — Artikelbilder (Beitragsbild zuerst) | — |
| POST   | `/articles/{id}/images` — KI-Bild generieren. Body: `{prompt, model?, style?}`. model ∈ dall-e-2/3, gpt-image-1/2, flux-schnell/dev, flux-1.1-pro. 202 mit `image_id`; 402 bei zu wenig Credits | 3–10 (je Modell) |
| POST   | `/articles/{id}/images/from-gallery` — bestehendes Team-Galerie-Bild als Beitragsbild setzen. Body: `{file_id}` (AgenticUploadedFile-ID). Kopiert Datei auf public Disk, legt Image an, setzt exklusiv `is_featured`. 201 mit `{image_id, url, is_featured}`; 404 wenn File nicht im Team **oder kein Bild** (`mime_type`≠`image/*` — die Galerie zeigt nur Bilder, agent-generierte Dokumente wie xlsx/csv/md sind ausgeschlossen); 422 wenn Quelle weg | — |
| PATCH  | `/images/{id}/featured` — Bild als Beitragsbild markieren (exklusiv pro Artikel) | — |
| POST   | `/articles/{id}/repurpose` — Body optional `{format:linkedin\|twitter\|instagram\|newsletter\|youtube_script\|tiktok_script\|facebook}`. Ohne format → alle | 3 |

### Versions

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/articles/{id}/versions` | `?limit=50` |
| POST   | `/articles/{id}/versions/{vid}/restore` | überschreibt `article.content` |

### Zitat-Prüfung

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/articles/{id}/quote-check` | Ergebnis des Zitat-Wächters: `checked`, `current` (letzter Lauf gehört zum gespeicherten Text), `removed_count` (verschiedene entfernte Zitate über alle gespeicherten Läufe), `runs[]` (weg, am, content_hash, dokument_ids, quellen, entfernt, belegt), `verification[]` (Stufe 2: ausstehend\|wiederherstellbar\|nicht_belegt\|ungeprueft). Kostenlos. |

### Artikel-Export

| Method | URL | Notes |
|--------|-----|-------|
| GET    | `/articles/{id}/export` | Gespeicherter Artikel als Datei. Query: `format=markdown` (Standard, derzeit einziges Format). Antwort `200 text/markdown; charset=utf-8` mit `Content-Disposition: attachment; filename=<titel-slug>.md`; beginnt mit `# <Titel>`. `422` bei unbekanntem Format, `403` fremdes Team. Kostenlos. Der Inhalt ist **semantisch normalisiert** (Plan #321): Link-Platzhalter sind zu Ankertext aufgeloest, Tabellenzellen getrennt; verlöre die Normalisierung Klartext, wird der gespeicherte Stand unveraendert ausgeliefert. |

### Freshness

| Method | URL |
|--------|-----|
| GET    | `/articles/{id}/quote-suggestions?q=` — wörtliche Stellen aus der Wissensbasis für den Block „Zitat mit Quelle" (Karte #7418). `q` bis 200 Zeichen, mindestens ein Wort ab 4 Zeichen. Bei `knowledge_mode=specific` nur die gewählten Dokumente, sonst ganzes Projekt; bis zu 5 × `{quote, source, url, knowledge_document_id}`. 1 Credit (`knowledge_search`, `credits-auto:1`). Braucht Leserecht auf die Wissensbasis des Projekts (`403` sonst). `422` ohne Buchung mit `errors.q` = `no_search_word` (kein Wort ab 4 Zeichen), `no_knowledge_base` oder `selected_documents_unavailable`; im Modus `knowledge_mode=specific` nur die gewählten Dokumente |
| GET    | `/articles/{id}/freshness` |
| POST   | `/articles/{id}/freshness/check` (read-only score, 0 credits — non-destructive) |
| POST   | `/articles/{id}/freshness/refresh` (AI-rewrite → DRAFT + version backup, `contentRefresh` credits — never auto-publishes) |
| GET    | `/articles/{id}/freshness/history` |

### Internal Linking

| Method | URL |
|--------|-----|
| GET    | `/articles/{id}/link-suggestions` |
| POST   | `/projects/{project}/internal-links/analyze` (async, 202 + `run_id`; 1 Credit/Artikel im Job — kein flat charge) |
| GET    | `/internal-links/runs/{run}` (Run-Status/Progress pollen) |
| PUT    | `/link-suggestions/{id}` (`status=applied` wendet den Link an + erreicht das Live-CMS; `draft-applied` wenn Quelle unveröffentlicht) |

### Standalone

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/generate/article` | 5 |
| POST   | `/generate/image` | 5 |

### CMS-Integrations

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/cms-integrations` — CMS-Integrationen des Teams. Filter: `?project_id=`, `?type=` (`wordpress` oder `shopify`). Zugangsdaten werden nie ausgegeben | — |
| POST   | `/cms-integrations` — Integration anlegen. Body: `{type, name, site_url, credentials, project_id?, is_active?}`; `type` ist `wordpress` oder `shopify`, `credentials` bei WordPress `{username, application_password}`, bei Shopify `{access_token}`. `site_url` darf ohne Schema kommen. 201 ohne Zugangsdaten im Rumpf; 403 ohne das Recht, Einstellungen zu verwalten. Throttle 20/min | — |
| GET    | `/cms-integrations/{id}` — Detail einer Integration, ohne Zugangsdaten. 404 bei fremdem Team | — |
| DELETE | `/cms-integrations/{id}` — Integration entfernen. Die Veröffentlichungs-Historie bleibt erhalten. 403 ohne das Recht, Einstellungen zu verwalten; 404 bei fremdem Team | — |
| POST   | `/cms-integrations/{id}/test-connection` — prüft die hinterlegten Credentials live (WordPress `/users/me`, Shopify `/shop.json`). Reiner Probe-Call (keine Persistenz, idempotent), Throttle 20/min. 200 mit `{data:{ok:bool, detail}}`; 404 fremdes Team; 422 nicht-unterstützter CMS-Typ | — |

---

## 🛬 Landingpages

> Phase-1-REST-Slice: Polling einer laufenden Bulk-Generierung (read-only, team-gescopt, 0 Credits).
> Idiom/Anti-Patterns: siehe `docs/modules/landingpage-generator.md` → „Workflow (GEO-Optimizer-Perspektive)".

| Method | URL |
|--------|-----|
| GET    | `/landingpages/{designSystem}/pages` (`?status=&page=&per_page=` ≤100) — paginierte Seiten mit `progress{state,percent,eta_seconds,elapsed_seconds,expected_seconds,started_at}` + pro Seite `cta{url,status,type,suggested_label,buttons}` (`buttons`=Per-Button-Override oder `null`) + `batch{total,done,in_flight,percent}` + `expected_seconds`. Fremde/unbekannte `designSystem` → 404. |
| POST   | `/landingpages/{designSystem}/plan` Body `{keyword_ids[],keywords[]}` (mind. eins) — Seiten (`planned`) aus bestehenden Keyword-IDs und/oder Freitext-Keywords planen → 201 `{design_system_id,planned,skipped,capped}`. `keyword_ids` auf das DS-Projekt re-skopt (fremde werden verworfen); 422 ohne gültige Keywords, 409 wenn DS nicht `ready`, 404 bei fremdem Team. 0 Credits (Generierung kostet separat). |
| PATCH  | `/landingpages/{designSystem}/cta` Body `{page_ids[],cta_url}` — optionale CTA-Ziel-URL (1+ Seiten) zuweisen + async scrapen/KI-klassifizieren (202). Team-gescopt (ids auf DS+Team re-skopt), 0 Credits. |
| PATCH  | `/landingpages/pages/{landingPage}/cta-buttons` Body `{buttons:[{section,label,url}]\|null}` — Per-Button-CTA-Override EINER Seite (`null`=AUTO, `[]`=keine, sonst genau diese, je `url:http,https`, ≤4). Synchron re-gerendert; Response `{cta_buttons,effective[],rerendered}`. Team-gescopt, fremde Seite → 404. 0 Credits. |

## 🔍 SEO Suite

### Rank Tracker (Phase 1)

| Method | URL |
|--------|-----|
| GET    | `/rank-tracker` |
| GET    | `/rank-tracker/{project}` |
| GET    | `/rank-tracker/{project}/keywords` (`?device=&country=&is_active=`) · `?sort=keyword\|country\|position\|volume\|kd\|intent\|cpc\|last_checked` · `?dir=asc\|desc` · `?q=` (Keyword, Gruppe) · `?per_page=25` (max 100) — `position`/`last_checked` aus dem jüngsten SERP-Ergebnis des `device`, Keywords ohne Ergebnis zuletzt; unbekannter Wert → 422 (Plan #183) |
| POST   | `/rank-tracker/{project}/keywords` Body: `{keywords[], devices[]}` async 202 |
| POST   | `/rank-tracker/{project}/refresh` Body: `{devices[]}` → 202 `{message:"queued", run_id, dispatched_count, credits_charged}` · 202 `already_fresh_today` (`dispatched_count:0`, nichts abgebucht) · 409 `run_already_active` · 402 `insufficient_credits` |
| GET    | `/rank-tracker/keywords/{keyword}/history` (30d/90d/365d) |

### Keyword Explorer (Phase 2)

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/explorer` Body: `{seed, language?, country?, cache?}`. `cache=false` umgeht 60-min-Cache | 50 |
| GET    | `/explorer/searches` Such-Historie des Teams (paginiert, `?page=&per_page=` max 100). Items: `{id, keyword, language, country, created_at, searched_at}` | 0 |
| POST   | `/explorer/searches/{id}/refresh` Refresh eines History-Eintrags: frischer Pull (Cache-Bust), 202 + `{keyword, language, country}`. Abrechnung bei Job-Erfolg | 50 |
| DELETE | `/explorer/searches/{id}` History-Eintrag löschen (204; gecachte Ergebnisse bleiben). 404 bei fremdem Team | 0 |
| GET    | `/explorer/{keyword}` Volume + KD + Trends + SERP. `?include=related,questions` inlined die Listen. `?cache=false` erzwingt fresh fetch | 0 / 50* |
| GET    | `/explorer/{keyword}/related` `?type=related\|question`, `?sort=keyword\|volume\|type&direction=asc\|desc`. `?cache=false` | 0 / 50* |
| GET    | `/explorer/{}/visibility` Eigene KI-Sichtbarkeit je Suchwort: `{keyword, tracked, has_prompt, visibility, answers_evaluated}`, Fenster 30 Tage. `?include=related` legt die verwandten Suchwörter dazu. `tracking_project_id` Pflicht, sobald mehr als eins erreichbar ist (sonst 422). **`visibility: null` heißt NICHT gemessen, nicht 0** | 0 |
| POST   | `/explorer/{}/tracking-prompt` Suchwort als Tracking-Prompt übernehmen. Body: `{tracking_project_id, language?, country?}`. Idempotent je Prompt-Text: 201 neu, 200 vorhanden. Bezahlt wird erst jede KI-Antwort eines Laufs | 0 |

### Keyword Gap (Phase 3)

| Method | URL |
|--------|-----|
| GET    | `/gap` · `/gap/{project}` · `/gap/{project}/opportunities` (`?sort=score\|keyword\|volume\|kd\|best_competitor\|competitor_position\|our_position\|status` · `?dir=asc\|desc` · `?q=` (Keyword, Wettbewerber-Domain) · `?per_page=25` (max 100), unbekannter Wert → 422 — Plan #183; die Matrix `/gap/{id}/matrix` ist eine Pivot-Ansicht derselben Zeilen ohne eigenen Endpunkt) |
| PATCH  | `/gap/opportunities/{opportunity}` |
| POST   | `/gap/{project}/refresh` |

### Site Audit (Phase 4)

| Method | URL |
|--------|-----|
| GET/POST | `/site-audit` | POST-Body: `{start_url, max_pages?, crawl_depth?, crawl_mode?, sitemap_url?}` — `sitemap_url` optional, gleiche Domain wie `start_url` (sonst 422 `sitemap_url_host_mismatch`); ohne Angabe werden alle Sitemaps der robots.txt gelesen. Im Sitemap-Modus bestimmt die Sitemap die Seitenzahl (mit `sitemap_url` diese, sonst die gefundene), `max_pages` wird ignoriert; keine lesbare Sitemap → 422 `sitemap_unreadable`. GET `{crawl}` liefert `sitemap_url` + `sitemap_sources` (`[{url, count, parent}]` — Index mit Summe und `parent: null`, Kinder mit dem Index als `parent`) sowie `wartet_auf_domain_seit` + `wartet_auf_domain_bis` (ISO 8601): gesetzt, solange der Lauf (`status: queued`) wartet, weil ein anderer Lauf dieselbe Domain abruft; sonst `null` |
| GET    | `/site-audit/preflight?url=` | Bot-Sperre vor dem Start prüfen (Karte #7408): `{url, checked, bot_block_suspected, http_status, reason, refund_on_failure}` (`checked: false` und `refund_on_failure: null`, wenn die Seite nicht antwortete); Weiterleitungen werden je Sprung durch den SSRF-Riegel geprüft; warnt, sperrt nie. Scheitert ein Crawl an einer Bot-Sperre, wird nicht erstattet; bei Fehlern auf unserer Seite voll (`credits_refunded` im Crawl-Objekt) | 0 |
| GET    | `/site-audit/{crawl}` · `.../issues` |
| GET    | `/site-audit/{crawl}/pages` | Gecrawlte Seiten, paginiert (Plan #183, #78): `?status=200\|redirect\|404\|500` · `?sort=depth\|status\|url\|title\|h1\|words\|load` · `?dir=asc\|desc` · `?q=` (URL, Titel, H1) · `?per_page=25` (max 100); unbekannter Wert → 422. Felder `{id, site_crawl_id, url, status_code, content_type, title, meta_description, h1, word_count, load_time_ms, depth, redirect_count, is_soft_404, is_param_variant, duplicate_group, html_quelle, seitentyp, seitentyp_gruppe, seitentyp_confidence, seitentyp_model}` + `meta`; `?seitentyp=homepage\|category\|product\|article\|sonstige\|offen` (Karte #9246). Unpaginierter Volldump: `/export?dataset=pages` | 0 |
| GET    | `/site-audit/{crawl}/page-types` | Verteilung nach Seitentyp (Karte #9246): `{data: {typen: {homepage, category, product, article, sonstige, offen}, seiten, verschieden, per_muster, per_ki}}` — `typen` zählt verschiedene Seiten (Varianten, Paginierung, inhaltsgleiche Adressen einmal) | 0 |
| GET    | `/site-audit/{crawl}/structured-data` | Strukturierte Daten (JSON-LD) je Seitentyp (Karte #9247): `{data: {geprueft, gruppen: {<gruppe>: {seiten, mit_schema, kaputt, typen, befunde, stufen}}, regeln: {<regel>: {stufe, wirkung, quelle, seiten, beispiele}}, vorlauf}}` — `vorlauf` = Delta zum vorherigen abgeschlossenen Crawl auf Typ- und Befundebene (`neu`/`behoben`), `null` ohne Vorlauf, `ohne_daten: true` wenn er vor der Prüfung lief | 0 |
| GET    | `/site-audit/baseline?tracking_project_id=` | Schema-Ausgangsmessung des Projekts (Karte #9293): `{data: {tracking_project_id, status, baseline_crawl_id, crawl}}` — `status` des jüngsten Versuchs (`none` ohne einen), `baseline_crawl_id` = gesicherte Messung; neue Projekte bekommen sie beim Aktivieren automatisch | 0 |
| POST   | `/site-audit/baseline` | Ausgangsmessung für ein bestehendes Projekt anfordern, Body `{tracking_project_id}`: 202 `queued`, idempotent 200 `exists`; 409 abgeschaltet, 422 ohne Domain. Vollständiger Crawl (Sitemap, gedeckelt auf 1.000 Seiten), enthalten. `/structured-data` späterer Crawls trägt dann `ausgangsmessung` (Delta wie `vorlauf`) | 0 |
| GET    | `/site-audit/schedule?tracking_project_id=` | Takt des automatischen Site Audits (Karte #7386): `{data: {tracking_project_id, frequency, next_run_at, last_run_at, last_crawl_id, last_run_scheduled, credits_per_run, skipped}}` — `skipped` = `{reason, at}` des zuletzt übersprungenen Termins (`guthaben`, `laeuft_noch`, `keine_domain`, `adresse_gesperrt`) oder `null`. Nur eigene Projekte (sonst 404) | 0 |
| PUT    | `/site-audit/schedule` | Takt setzen, Body `{tracking_project_id, frequency: off\|daily\|weekly\|monthly}`. Der Termin startet neu: ein Takt nach dem jüngsten Crawl des Projekts, frühestens sofort. Jeder geplante Lauf übernimmt die Einstellungen des letzten Crawls (ohne Vorlauf BFS, 50 Seiten), bucht `credits_per_run` wie ein Start von Hand und wird bei einem Fehler auf unserer Seite erstattet | 0 |
| GET    | `/site-audit/{crawl}/structured-data/pages` | Geprüfte Seiten mit `typen, obertypen, bloecke, kaputte_bloecke, uebersprungene_bloecke, microdata_typen, befunde[{regel, stufe, wirkung, wert}]` (Karte #9247): `?seitentyp=` · `?regel=<katalog-id>` · `?stufe=pflicht\|richtlinie\|empfehlung` (unbekannt → 422) · `?roh=1` (gekürzter JSON-LD-Text) · `?per_page=25` (max 100) | 0 |
| PATCH  | `/site-audit/{crawl}/issues/bulk` |
| PATCH  | `/site-audit/issues/{issue}` |
| POST   | `/site-audit/{crawl}/abort` |
| POST   | `/site-audit/issues/{issue}/generate-brief` |
| GET | `/site-audit/{}/report.pdf` | Gebrandeten Site-Audit-PDF-Report herunterladen | — |
| GET | `/site-audit/{}/export` | Clean CSV/JSON-Datenexport eines Crawls: `?dataset=issues\|pages\|grounding` (default `issues`) · `?format=csv\|json` (default `csv`). `grounding` braucht zusätzlich `?batch=<id>` (team-scoped). CSV mit UTF-8-BOM; JSON = `{crawl_id, dataset, generated_at, count, records[]}`. Team-scoped, 0 Credits. | — |

**Issues — response fields.** Each item in `GET /site-audit/{crawl}/issues` returns:
`{id, site_crawl_id, page_id, url, issue_type, severity, message, context, fix_priority, status, has_ai_brief, ai_brief_generated_at}`. The `url` field is sourced from the crawl page (eager-loaded) and is `null` if the page row was deleted.

**Grouped view — `GET /site-audit/{crawl}/issues?group_by=type`.** Additive aggregation (mirrors the UI's grouped Probleme-tab): one row per `(issue_type, severity)` — `{data:[{type, label, severity, total, open, fixed, dismissed}]}`, sorted by severity rank (critical→notice) then `total` desc. `label` is the human-readable name (`IssueTypeLabeler`). Respects the `severity` and `issue_type` query filters (status is NOT pre-filtered — the counts expose the open/fixed/dismissed split). No pagination, no trend (trend is UI-only). `group_by` accepts only `type` (else 422). Without the param the per-issue shape above is unchanged.

**Bulk-mark — `PATCH /site-audit/{crawl}/issues/bulk`.** Throttle: 30/min. Body:
```json
{
  "filter": {
    "issue_type": "missing_alt_text",
    "severity": "high",
    "status": "open"
  },
  "new_status": "fixed"
}
```
All `filter.*` fields are optional. `filter.status` is the FROM-state to match (only flip rows currently in this status). `new_status` is required and must be one of `open|dismissed|fixed`. Response: `{data:{affected:int, new_status}, meta:{applied_filter:{...}}}`.

**Single-Mark — `PATCH /site-audit/issues/{id}`.** Single-Issue-Status ändern. Body: `{"status":"fixed"|"dismissed"|"open"}`. Returns `{id, status}`. **Idiom: NACH JEDEM angewendeten Fix MUSS der API-Caller diesen Endpoint mit `status=fixed` aufrufen — sonst bleiben Issues im Dashboard fälschlich als „open", und die „seit letztem Crawl behoben"-Vergleichsmetrik der nächsten Crawl-Iteration wird wertlos.** Team-scope via Parent-Crawl.

**Crawl-Summary Response — `GET /site-audit/{crawl}`** returns `{data: {id, status, pages_crawled, pages_failed, total_issues, crawl_depth, max_pages, credits_used, tracking_project_id, started_at, completed_at, bridge_dispatched_at, bridge_completed_at, created_at, error_code, error_message, error_text, deep_link}}`. **Fehlergrund seit Plan-Hub #92:** `error_code` ist die maschinenlesbare Klasse (`timeout`, `worker_restart`, `url_unreachable`, `host_invalid`, `sitemap_empty`, `aborted_by_user`, `unknown`), `error_message` der gekappte Rohtext (max. 500 Zeichen), `error_text` die Fassung für Menschen — dieselbe Quelle, die die Detailseite zeigt. Alle drei sind `null`, solange der Lauf läuft oder sauber durchlief. Bridge-Timestamps seit 2026-05-14: `bridge_dispatched_at` zeigt „Grounding-Batch dispatched, Issues kommen noch", `bridge_completed_at` zeigt „alle grounding_*-Issues final". Caller-Polling-Condition: `data.status='completed' && data.bridge_completed_at != null`.

**Abbruch — `POST /site-audit/{crawl}/abort`.** Bricht einen Lauf im Status `queued` oder `running` ab; 0 Credits. Antwort `{message, crawl_id, status:'aborted'}`. `409` wenn der Lauf bereits beendet ist, `404` bei fremdem Team. **Der Abbruch hält:** der Crawler prüft den Status zwischen zwei Seiten und beendet sich, statt ihn danach mit `completed` zu überschreiben — anders als die beiden älteren Abbruch-Muster des Projekts, die nur ein DB-Flag setzen. Verbrauchte Credits werden nicht erstattet (Erstattung: Plan-Hub #93).

**Auto-Grounding-Bridge.** When a crawl completes, the system automatically dispatches an async Grounding-Audit batch (Grounding Page Standard v1.5, E-E-A-T, People-First) over up to the first 100 crawled pages. When that batch finishes, structured findings are imported as `SiteCrawlIssue` rows with `issue_type` prefix `grounding_*`. No extra credits charged — the bridge runs piggyback on the crawl. Idempotent: re-running the same crawl will not duplicate `grounding_*` issues (dedup by `(site_crawl_id, issue_type, site_crawl_page_id)`).

Format `grounding_<framework>_<category>_<code>`. Typical codes (15 detector rules in v1.5-gp framework):
- `grounding_v1_5_gp_h1_entity_only` — H1 not pure entity name
- `grounding_v1_5_gp_lead_single_sentence_definition` — lead is not a 1-sentence definition
- `grounding_v1_5_gp_lead_retrieval_sentence` — missing retrieval-stabilization sentence
- `grounding_v1_5_gp_h2_entity_prefix`, `grounding_v1_5_gp_disambiguation_block`
- `grounding_v1_5_gp_human_notice`, `grounding_v1_5_gp_timestamps_visible`
- `grounding_v1_5_gp_anchors_stable_ids`, `grounding_v1_5_gp_facts_dl_grid`
- `grounding_v1_5_gp_jsonld_present`, `grounding_v1_5_gp_jsonld_faq_when_html_faq`
- `grounding_v1_5_gp_head_canonical_absolute`, `grounding_v1_5_gp_eeat_author_bio_schema`
- `grounding_v1_5_gp_faq_entity_repeated`, `grounding_v1_5_gp_volatile_dated_sourced`

Filter via `GET .../issues?issue_type=grounding_v1_5_gp_h1_entity_only`. Bulk-mark via `filter:{issue_type:"grounding_v1_5_gp_eeat_author_bio_schema"}`.

### Backlinks (Phase 5 + Phase 6 DFS-Erweiterungen)

| Method | URL | Beschreibung |
|--------|-----|--------------|
| GET    | `/backlinks` · `/backlinks/{project}` · `.../domains` · `.../anchors` · `.../events` | Phase 5 base. Seit Plan #259: `/backlinks` liefert je Projekt `refresh_status`, `/backlinks/{project}` den Block `refresh` (`status`, `kind`, `requested_at`, `last_refreshed_at`, `last_change.backlinks`, `last_change.referring_domains`, `error`) |
| POST   | `/backlinks` | Activate-Endpoint: Backlink-Tracking für Projekt aktivieren (Subscription required) |
| POST   | `/backlinks/{project}/refresh` | Trigger Delta-Pull Job (202, `status`: `queued` \| `already_running`) |
| POST   | `/backlinks/{project}/full-refresh` | Vollständigen DFS-Re-Pull triggern (Async 202, `status`: `queued` \| `already_running`; 402 ohne Credits) |
| GET    | `/backlinks/{project}/spam-scores` | Toxische Domains (Filter `?min_spam=60`) |
| POST   | `/backlinks/{project}/spam-scores/refresh` | Queue `EnrichSpamScoresJob` (202) |
| GET    | `/backlinks/{project}/disavow-export` | text/plain Disavow.txt (Param `?threshold=80`). Als seriös markierte Domains sind ausgeschlossen; die Kopfzeile nennt ihre Zahl |
| GET    | `/backlinks/{project}/reputable-domains` | Plan #175: die je Projekt als »seriös« markierten verweisenden Domains (`domain`, `marked_by_user_id`, `marked_at`). Credits 0. Dazu trägt jede Zeile von `/backlinks/{project}/domains` und `/backlinks/{project}/spam-scores` neu `is_reputable`; `is_toxic` ist dort das WIRKSAME Urteil (Markierung berücksichtigt), `toxic_severity` bleibt die automatische Einstufung |
| POST   | `/backlinks/{project}/reputable-domains` | Eine Domain als »seriös« markieren. Body `{"domain": "zeit.de"}`. Die Eingabe wird gegen die verweisenden Domains des Ziels aufgelöst und in deren Schreibweise gespeichert; eine Domain, die für das Ziel nicht verweist, wird mit 422 abgelehnt. Idempotent: 201 beim Anlegen, 200 beim erneuten Aufruf. Credits 0 |
| DELETE | `/backlinks/{project}/reputable-domains/{domain}` | Die Markierung zurücknehmen. 404, wenn keine besteht. Credits 0 |
| GET    | `/backlinks/{project}/timeseries` | Historische Velocity-Punkte (`?granularity=daily\|weekly\|monthly`) |
| POST   | `/backlinks/{project}/anchors/refresh` | Queue `PullDetailedAnchorsJob` (202) |
| GET    | `/backlinks/{project}/links` | TODO-050 Per-Link-Browser: einzelne Backlinks (source_url→target_url, anchor, follow). Filter `?sort=last_seen\|first_seen\|source_domain\|target_url\|anchor_text\|source_trust_flow` · `?dir=asc\|desc` · `?q=` (Quelldomain, Quell-URL, Ankertext) · `?group_by=domain` (`data` = `[{domain, count, links[]}]`) · `?follow=dofollow\|nofollow` · `?lost=1` · `?per_page=50` (max 200); unbekannter Wert → 422 (Plan #183). Credits 0 |
| GET    | `/backlinks/{project}/lost-links` | TODO-050 Verlorene/gelöschte Einzel-Links (is_deleted OR lost_at), sortiert nach lost_at desc. Credits 0 |
| GET    | `/backlinks/{project}/disavow-recommendations` | TODO-050 Toxische Referring-Domains (toxic_severity medium + high, high zuerst — dieselbe Definition wie `domains?only_toxic=1`) + Gründe (Disavow-Empfehlung; vom Action-Center/Agent zitierbar). Credits 0 |
| POST   | `/backlinks/{project}/link-gap` | TODO-050 Competitor-Link-Gap starten (Async 202). Body `{competitor_domains?:[]}` (Default = TrackingProject competitor_domains). Credits 10 (`backlink_link_gap`) |
| GET    | `/backlinks/{project}/link-gap` | TODO-050 Letzten Link-Gap-Lauf lesen (processing_status → results[]). Credits 0 |
| POST   | `/backlinks/{project}/competitor-overlap` | Wettbewerber-Overlap starten (Async 202). Body `{competitor_domains?:[] (Default = TrackingProject, max 12), min_matches?:1..5 (default 2)}`. Reicher als Link-Gap: `competitor_matches`, `links_me` („+ich"), N×N `competitor_pair_overlap`-Matrix. Credits 15 (`backlink_competitor_overlap`) |
| GET    | `/backlinks/{project}/competitor-overlap` | Letzten Overlap-Lauf lesen — server-seitig gefiltert/sortiert/paginiert. Query `?only_gaps=1` (default) `&min_matches=2&only_clean=1&min_tf=30&sort=matches\|trust_flow&per_page=50&page=1`. Kein Lauf → `{data:null}` (200, kein 404). Credits 0 |

### Backlinks by Domain (one-shot lookup, ohne Projekt-Setup)

Schneller Lookup für eine Domain ohne sie als Projekt anlegen zu müssen — z.B. Konkurrenz-Recherche, AI-Agent-Calls. `{domain}` darf Buchstaben/Ziffern/`.`/`-` enthalten, alles andere → 404.

> **Geändertes Verhalten seit 19.09.2026 (Plan #261).** Die **lesenden** `by-domain`-Endpunkte liefern Kennzahlen nur noch, wenn das eigene Team für die Domain berechtigt ist: entweder es hat sie selbst abgerufen (Zeile in `backlink_lookups`, entsteht durch `POST …/pull`) **oder** es trackt sie in einem eigenen Projekt. Sonst: **404** — bewusst nicht 403, damit die Antwort nicht verrät, ob die Domain bekannt ist.
>
> Vorher gab es diese Prüfung nicht: die Backlink-Tabellen sind teamübergreifend geteilt (Kostenersparnis), weshalb jeder Token die Kennzahlen jeder Domain lesen konnte, die irgendein anderes Team je gezogen hatte.
>
> **Was das für bestehende Aufrufe heißt:** Der übliche Ablauf `POST …/pull` → warten → `GET …` funktioniert unverändert; der Pull legt die Berechtigung jetzt selbst an. Ein `GET` auf eine Domain, die nie abgerufen und nicht getrackt wird, antwortet ab sofort mit 404 statt mit Daten. Nicht betroffen: `POST …/pull`, `…/enrich-spam-scores` und die beiden `link-gap`/`competitor-overlap`-Starter.

| Method | URL | Beschreibung |
|--------|-----|--------------|
| GET    | `/backlinks/by-domain/{domain}` | Summary (rank, total backlinks, referring domains, dofollow ratio, spam score) |
| POST   | `/backlinks/by-domain/{domain}/pull` | DFS-Pull triggern (Async 202) |
| POST   | `/backlinks/by-domain/{domain}/enrich-spam-scores` | Spam-Score-Enrichment für die Top-Domains (Async 202) |
| GET    | `/backlinks/by-domain/{domain}/domains` | Referring Domains (Filter: `?only_active`, `?only_toxic`, `?sort`) |
| GET    | `/backlinks/by-domain/{domain}/anchors` | Anchor-Verteilung |
| GET    | `/backlinks/by-domain/{domain}/spam-scores` | Toxische Domains (Filter `?min_spam=60`) |
| GET    | `/backlinks/by-domain/{domain}/disavow-export` | Google-Disavow.txt (`?threshold=80`) |
| GET    | `/backlinks/by-domain/{domain}/timeseries` | Velocity-History (`?granularity=daily\|weekly\|monthly`) |
| GET    | `/backlinks/by-domain/{domain}/links` | TODO-050 Per-Link-Browser by-domain (einzelne Backlinks; `?sort`, `?dir`, `?q`, `?group_by=domain`, `?follow`, `?lost=1`, `?per_page` wie `/backlinks/{project}/links`). Credits 0 |
| GET    | `/backlinks/by-domain/{domain}/lost-links` | TODO-050 Verlorene Einzel-Links by-domain. Credits 0 |
| GET    | `/backlinks/by-domain/{domain}/disavow-recommendations` | TODO-050 Toxische Domains (medium + high, high zuerst) als Disavow-Empfehlung by-domain. Credits 0 |
| POST   | `/backlinks/by-domain/{domain}/link-gap` | TODO-050 Competitor-Link-Gap by-domain starten (Async 202). Body `{competitor_domains:[]}`. Credits 10 (`backlink_link_gap`) |
| GET    | `/backlinks/by-domain/{domain}/link-gap` | TODO-050 Letzten Link-Gap-Lauf by-domain lesen. Credits 0 |
| POST   | `/backlinks/by-domain/{domain}/competitor-overlap` | Wettbewerber-Overlap by-domain starten (Async 202). Body `{competitor_domains:[] (Pflicht), min_matches?:1..5 (default 2)}`. Credits 15 (`backlink_competitor_overlap`) |
| GET    | `/backlinks/by-domain/{domain}/competitor-overlap` | Letzten Overlap-Lauf by-domain lesen (Query `?only_gaps=1&min_matches=2&only_clean=1&min_tf=30&sort=matches\|trust_flow&per_page=50`). Kein Lauf → `{data:null}`. Credits 0 |

### Backlinks Subscription

| Method | URL | Beschreibung |
|--------|-----|--------------|
| GET    | `/teams/current/backlinks-subscription` | Aktive Subscription des Teams (Tier, Limits, Renewal) |
| POST   | `/admin/teams/{team}/backlinks-subscription` | Admin-Override: Subscription für anderes Team setzen (Body: `{tier, limits?}`) |

---

## 🐍 Sandbox (Chat Python-Analyse)

Ad-hoc-Datenanalyse: LLM-generierter Python-Code läuft in einer doppelt-gesicherten Sandbox (bubblewrap + systemd-run, kein Netz/FS/Env, 256 MB RAM, ~20 s Hard-Kill). Die Plattform exportiert die angeforderten Datasets team-korrekt als JSON nach `data/<type>.json`; das Script hat NIE DB-/Netz-/Env-Zugang. Nur Python-Stdlib. **Letztes Mittel — wenn ein Endpoint die Frage beantwortet (insb. Link-Gap), diesen nutzen, nicht die Sandbox.**

| Method | URL | Beschreibung |
|--------|-----|--------------|
| POST   | `/sandbox/runs` | Analyse-Run starten (Async **202** → `{id, status:"pending"}`). Body: `{script, datasets?, session_id?}`. Gate `credits:5` (Balance-Check) + Flat-Deduktion 5 Credits am Trigger (kein Refund bei failed/timeout). Throttle 10/60 min. |
| GET    | `/sandbox/runs/{id}` | Run pollen (team-scoped, IDOR → 404). Response: `{id, status, stdout, stderr, exit_code, duration_ms, error, created_at}`. Credits 0 |

**Request (`POST /sandbox/runs`):**

```json
{
  "script": "import json; d=json.load(open('data/backlink_refdomains.json'))['rows']; print(sum(1 for r in d if r['toxic_severity']=='high'))",
  "datasets": [ { "type": "backlink_refdomains", "domain": "example.com", "limit": 2000 } ],
  "session_id": 123
}
```

**Response (202):** `{ "id": 42, "status": "pending" }` — dann `GET /sandbox/runs/42` bis `status ∈ {completed, failed, timeout}`; das Ergebnis steht in `stdout`.

**Dataset-Katalog** (je Run ≤ 5 Datasets, ≤ 10 MB/Dataset; jede Datei = Envelope `{type, params, count, truncated, rows[]}`):

| type | Parameter | rows enthalten |
|------|-----------|----------------|
| `backlink_summary` | `domain` | 0/1 Summary-Objekte (trust_flow, citation_flow, ref_domains, spam_score …) |
| `backlink_refdomains` | `domain`, `limit?` (≤5000, default 1000) | Referring Domains: `domain, trust_flow, citation_flow, backlinks, toxic_severity, is_lost` |
| `link_gap` | `domain` | Ergebnisse des neuesten completed Link-Gap-Laufs des Teams |
| `tracking_keywords` | `project_id` (**muss dem Team gehören** — fremd → 422) | Keyword-Rows: `keyword, keyword_group, search_volume, cpc, difficulty, search_intent` |

Limits: Script ≤ 20 KB, stdout/stderr je ≤ 64 KB (Rest abgeschnitten + markiert). Stdlib-only: `json, csv, statistics, collections, math, re, itertools, functools`.

---

## 🛠 Keywords (Research & Expansion)

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/projects/{project}/keywords` | — |
| POST   | `/projects/{project}/keywords` | — |
| POST   | `/projects/{project}/keywords/import` | 10/100 KW bei `cluster` |
| POST   | `/projects/{project}/keywords/import/csv` | 10/100 KW bei `cluster` |
| POST   | `/projects/{project}/keywords/ai-research` | 5 |
| GET    | `/projects/{project}/keyword-researches` | — |
| GET    | `/keyword-researches/{id}/status` | — |
| GET    | `/keyword-researches/{id}/results.csv` | — |
| POST   | `/keyword-researches/{id}/cancel` | — | Laufende Recherche abbrechen (Plan #219). Antwort: `{research_id, status:"cancelled", keywords_kept}`. Bereits gefundene Keywords bleiben erhalten — sie sind bezahlt. 409 `already_finished`, wenn der Lauf bereits beendet ist. |
| DELETE | `/keywords/{id}` | — |
| POST   | `/keywords/research` | 5 | A-Z-Vorschläge zu einem Seed. Optional seit 24.09.2026 (Karte #7799): `project_id` (SEO-Projekt) oder `tracking_project_id` (je des eigenen Teams, 404 sonst, 422 ohne Domain) ergänzt je Vorschlag `own_position`, `own_position_checked` und `own_position_source` (`ranked_keywords` = DataForSEO Labs, `serp_live` = Live-Google-Top-20). `exclude_ranking: true` (braucht ein Projekt, sonst 422) lässt Vorschläge weg, für die die Domain rankt, und prüft Vorschläge ohne Labs-Zeile live nach (höchstens 5 je Aufruf, nach 20 s beginnt keine weitere Prüfung; darüber `own_position_checked: false`). `own_position: null` ist nur mit `own_position_checked: true` eine Aussage. Ohne Projekt unverändert. |
| POST   | `/keywords/{id}/expand` | 10 |
| GET    | `/keywords/{id}/expansions` — je Keyword `own_position` (Top-10-Platz oder null), `competitor_positions[]` `{domain, position}`, `positions_checked_at` (aus der gespeicherten SERP) | — |

`GET /projects/{project}/keywords` liefert pro Keyword zusätzlich `source`
(`deep|quick|import|ai_search` — nur auf Recherche-Wurzeln; einzelne via POST angelegte Keywords bleiben ohne source) und `parent_keyword_id`;
Filter `?research_id={id}` liefert nur die Keywords einer Recherche.

`POST /projects/{project}/keywords/import` — Keyword-Liste importieren (async, 202).
Body: `{name?, rows: [{keyword, search_volume?, cpc?, difficulty?, intent?}], cluster?}`
(max. 2000 Rows, Dedupe case-insensitive). `cluster=true` holt die Google-Top-10 je
Keyword und fasst Keywords mit ≥50 % SERP-Überschneidung zu Clustern zusammen —
Credits: 10 je angefangene 100 Keywords (402 bei zu wenig Guthaben).
Response: `{research_id, keywords_received, cluster, credits_charged, status_url, results_csv_url}`.

`POST /projects/{project}/keywords/import/csv` — CSV-Datei-Variante: multipart `file`
(csv/txt/xlsx/xls, max 1 MB / 2000 Rows), optional `name`, `cluster`. Spalten-Mapping
auto-erkannt über Header (de/en: keyword/suchbegriff/term, volumen/search_volume, cpc,
difficulty/schwierigkeit, intent), Zahlen locale-aware („1.200" → 1200).
**Explizites Mapping:** `mapping[<feld>]=<spalten-index>` (0-basiert; Felder: keyword,
search_volume, cpc, difficulty, intent) überschreibt die Auto-Erkennung pro Feld —
nicht angegebene Felder bleiben auto-erkannt. `has_header=false` für Dateien ohne
Header-Zeile (Zeile 1 zählt dann als Daten; `mapping[keyword]` ist Pflicht). Keine
Keyword-Spalte → 422 `no_keyword_column` + `detected_headers`. Response wie oben,
zusätzlich `skipped` + `detected_mapping` (= effektives Mapping). `research_id` ist
die **Batch-ID**.

`POST /projects/{project}/keywords/ai-research` — KI-Recherche mit
Unternehmenskontext starten (async, 202). Body: `{limit?}` (1–50, Standard 30).
**Kein Saatbegriff** — das ist der Unterschied zu `POST /keywords/research`: die
Begriffe leitet eine OpenAI-Websuche aus dem Projekt-Stammsatz ab (Branche, Marke,
Zielgruppen, Leistungen, Website). Response: `{status: queued, research_id, source:
"ai_search", credits, status_url}`. **Die KI liefert KEINE Suchvolumen** — die Zahlen
holt anschliessend dieselbe DataForSEO-Messung wie beim Schnell-Pfad, die Werte sind
also gemessen, nicht geschaetzt. Je Vorschlag wird die Belegadresse gespeichert
(`source_urls`); ein leeres Array heisst „erhoben, aber ohne Beleg", `null` heisst
„nie erhoben". Fortschritt ueber `status_url` (derselbe Polling-Endpunkt wie beim
Import). 5 Credits, **beim Start reserviert** und bei einem Fehlschlag
zurückgebucht — ein Ausfall kostet also nichts, aber das Guthaben ist während
des Laufs gebunden. Reicht es nicht: **402** `insufficient_credits`. Läuft für
das Projekt bereits eine KI-Recherche, wird ein zweiter Start abgewiesen.

`GET /keyword-researches/{id}/status` — Batch-Polling: `{status: queued|processing|
clustering|completed|failed, is_finished, progress_percent, keywords_processed,
keywords_pending, keywords_total, clusters_count, clustered_keywords, error_message,
results_csv_url}`. `progress_percent` (0–100, eine Nachkommastelle) = Anteil der
Keywords mit abgeschlossenem SERP-Fetch (der zeitfressende Teil); während des
finalen Clusterings gecappt auf 99, exakt 100 nur bei `is_finished=true`.
Backoff 30s empfohlen.

`GET /keyword-researches/{id}/results.csv` — Cluster-Ergebnis als maschinenlesbare CSV
(erst bei `completed`, sonst 409). Komma-separiert, UTF-8, snake_case-Header:
`cluster_id,cluster_role,cluster_main_keyword,cluster_size,keyword,search_volume,cpc,
difficulty,intent,article_titles` — `cluster_role` ∈ `main|sub|solo`, jede Zeile trägt
`cluster_id` + `cluster_main_keyword` (selbsterklärend für LLM-Weiterverarbeitung).
Sortierung: größte Cluster zuerst, im Cluster main → subs nach Volumen.

`GET /projects/{project}/keyword-researches` — Recherche-Historie (Wurzeln mit
`source`, `processing_status`, `keywords_count`), paginiert.

---

## ✍️ Content Intelligence

### Storylines

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/projects/{project}/storylines` | Liste aller Storylines (paginiert, 25/Seite) | 0 |
| POST | `/projects/{project}/storylines` | **TODO-047 async Outline-Generator**: `keyword` (req), `target_word_count?`, `goal_id?`, `language?`, `use_knowledge_base?`/`knowledge_mode?`/`knowledge_document_ids?[]`. Erstellt `generating`-Platzhalter, dispatcht `GenerateStorylineApiJob`, liefert **202** `{storyline_id,status:'generating'}`. Job zieht die Credits **einmal** bei Erfolg. Legacy: Payload mit `structure[]` → synchroner Store (**201**, keine Generierung, keine Credits). | 5 |
| GET/PUT/DELETE | `/storylines/{id}` | Detail (inkl. `research_data` — SERP heading-gap/PAA/featured snippet), Update (Status-Enum inkl. `failed`), Löschen | 0 |
| POST | `/storylines/{id}/approve` | Review-Gate vor Artikel-Assembly. Setzt `status=approved`+`approved_at`+`approved_by`; idempotent | 0 |
| POST | `/storylines/{id}/assemble-article` | **Unified Storyline→Artikel** (gleicher Pfad wie der In-App-Button): baut Article mit Projekt-Sprache + `style_profile_id?`/`tone?`, verknüpft `articles.storyline_id`+`storyline.article_id`, dispatcht `GenerateArticleJob`, liefert **202** `{article_id}`. 422 wenn nicht approved/completed. Single-use (2. Aufruf → bestehender Artikel) | 10 (= `FeatureCost::article`) |

### Interview-Mode (Artikel aus einem Interview)

Karte #10150. Ziel nennen, KI-Fragen vorschlagen lassen, Fragen anpassen, je Frage tippen oder ein Audio hochladen, dann den Artikel erzeugen. Fragen und Antworten gehen als Briefing in `GenerateArticleJob`. Alle asynchronen Laeufe antworten **202**; Guthaben fehlt → **402** (`insufficient_credits`), fachlicher Abbruch → **422** / **409** (`interview_error`). Modul-Doku: `docs/modules/article-interview.md`.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/projects/{project}/article-interviews` | Interviews des Projekts, Filter `status` (draft\|submitted), `user_id`, `per_page` (max 100) | 0 |
| POST | `/projects/{project}/article-interviews` | Interview starten: `goal` (req, 10–2000 Zeichen), `keyword?` → **201** | 0 |
| GET | `/article-interviews/{articleInterview}` | Detail: Ziel, Fragen mit Antworten, `progress {total, answered, required_open}`, `can_generate`, `questions_status`, Transkriptionen, verknuepfter Artikel | 0 |
| PATCH | `/article-interviews/{articleInterview}` | `goal?`, `keyword?`, `step?` (1–3) | 0 |
| POST | `/article-interviews/{articleInterview}/suggest-questions` | KI schlaegt bis zu 7 Fragen vor (`SuggestInterviewQuestionsJob`), bestehende Fragen bleiben → **202**; Stand ueber `questions_status` | 2 (= `FeatureCost::articleInterviewQuestions`) |
| POST | `/article-interviews/{articleInterview}/questions` | Eigene Frage anlegen: `question` → **201** | 0 |
| PATCH | `/article-interviews/{articleInterview}/questions/{question}` | Frage aendern: `question?`, `is_required?` | 0 |
| DELETE | `/article-interviews/{articleInterview}/questions/{question}` | Frage loeschen → **204** | 0 |
| PUT | `/article-interviews/{articleInterview}/questions/order` | Neue Reihenfolge: `question_ids[]` (jede Frage genau einmal) | 0 |
| PUT | `/article-interviews/{articleInterview}/questions/{question}/answer` | Antwort speichern: `answer` (max 5000 Zeichen) | 0 |
| POST | `/article-interviews/{articleInterview}/questions/{question}/transcriptions` | Multipart `audio` (webm, ogg, m4a, mp4, mp3, wav; max 10 MB) → Transkription als Job (**202**), der Text wird an die Antwort angehaengt, die Datei danach geloescht | 1 (= `FeatureCost::articleInterviewTranscription`) |
| GET | `/article-interviews/{articleInterview}/transcriptions/{transcription}` | Stand einer Transkription: `status` (pending\|processing\|completed\|failed), `transcript`, `error` | 0 |
| POST | `/article-interviews/{articleInterview}/generate` | Artikel anlegen + `GenerateArticleJob` mit Fragen/Antworten als Briefing: `keyword?`, `article_type?`, `target_length?`, `tone?`, `address_form?`, `title?` → **202** `{article_id}`; 422 solange Pflichtfragen offen sind | wie Artikel (= `FeatureCost::article`) |

### Style Profiles

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/projects/{project}/style-profiles` | Auflisten (Response enthält zusätzlich `source_type`, `source_url`, `pending_review`, `scraped_pages`, `failure_reason`, `reviewed_at`) | — |
| POST   | `/projects/{project}/style-profiles` | Manuell erstellen | — |
| GET    | `/style-profiles/{id}` | Detail (gleiche Zusatzfelder) | — |
| PUT    | `/style-profiles/{id}` | Aktualisieren. Wenn `pending_review=true`, wird das Flag implizit auf `false` gesetzt und `reviewed_at=now()` belegt (Detail-Edit zählt als Review-Akzeptanz). | — |
| DELETE | `/style-profiles/{id}` | Löschen | — |
| POST   | `/projects/{project}/style-profiles/from-url` | URL-basierte Stilanalyse als Background-Job starten. ScraperAPI lädt bis zu 10 inhaltsstarke Seiten, Claude erstellt das Profil. Fertiges Profil hat `pending_review=true`. HTTP 202 → `{ data: { id, status: "pending", source_url, message } }` | 25 |
| GET    | `/style-profiles/{id}/scrape-status` | Polling-Endpoint für laufenden Background-Job. Liefert `{ status, source_url, scraped_pages, failure_reason, pending_review }` | — |
| POST   | `/style-profiles/{id}/accept` | Profil akzeptieren: setzt `pending_review=false`, `reviewed_at=now()`. Response: `{ data: <StyleProfile> }` | — |
| POST   | `/style-profiles/{id}/discard` | Profil verwerfen (Soft-Delete). Nur wenn `pending_review=true`, sonst 422. Response: `{ deleted: true }` | — |

### Knowledge Base

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/projects/{project}/knowledge-base` | Docs + Status auflisten (Polling) | — |
| POST | `/projects/{project}/knowledge-base` — Body: `{title, content, type?}` | Rohtext ingestieren: persistiert, chunked + embedded **async**, gibt `202 {document_id, status:"processing"}` zurück (kein 201/`active` mehr) | 5 |
| GET | `/projects/{project}/knowledge-base/search` — Query: `{q, top_k?}` | Hybride Suche (Ollama-Embedding-Cosine ∪ Keyword, dann Haiku-Rerank) über `ready`-Chunks; `{data:[{id,document_id,snippet,score}], meta}` | 1 |
| DELETE | `/knowledge-base/{id}` | Doc löschen (Cascade-Delete der Chunks) | — |
| GET | `/knowledge-base/{id}/download` | Datei herunterladen (Original-Upload bzw. extrahiertes/synthetisiertes Markdown) | — |
| POST | `/knowledge-base/url` | URL ingestieren (async) | 5 |
| POST | `/projects/{project}/knowledge-base/site-analysis` — Body: `{url, max_pages?}` (20–80, Default 40) | **Website-Analyse starten:** agentische Pipeline crawlt die Domain, klassifiziert per LLM und synthetisiert bis zu 9 KB-Dokumente (`source_type=site_analysis`, Re-Analyse ersetzt alte Docs). `202 {analysis_id, status, poll_url}`; 409 = Run für Host aktiv, 422 = SSRF | 25 |
| GET | `/projects/{project}/knowledge-base/site-analysis` | Analyse-Runs eines Projekts (paginiert, mit `progress_percent`) | — |
| GET | `/knowledge-base/site-analysis/{id}` | Run-Status pollen: `status (pending→discovering→extracting→synthesizing→completed\|partial\|failed)`, `progress_percent`, Seiten-Counter, `primary_language`, `document_ids`, `wartet_auf_domain_seit` + `wartet_auf_domain_bis` (ISO 8601, gesetzt, solange der Lauf in `pending` auf eine belegte Domain wartet) | — |

### Link Lists

| Method | URL |
|--------|-----|
| GET/POST | `/projects/{project}/link-lists` |
| DELETE | `/link-lists/{id}` |

### Orphan Scans

Kosten: `ceil(zu crawlende Seiten / 10)` Credits, abgebucht beim Start; `discover` liefert genau diesen Betrag als `estimated_credits`. Ein zweiter Start desselben Scans antwortet 422 und bucht nicht; zu wenig Guthaben ergibt 402 mit `required` und `balance`.

| Method | URL |
|--------|-----|
| POST   | `/projects/{project}/orphan-scans/discover` |
| POST   | `/orphan-scans/{id}/start` |
| GET    | `/orphan-scans/{id}` · `.../pages` — `GET {id}` liefert `wartet_auf_domain_seit` + `wartet_auf_domain_bis` (ISO 8601): gesetzt, solange ein bezahlter Scan auf eine belegte Domain wartet |
| DELETE | `/orphan-scans/{id}` |

### Overview

| Method | URL |
|--------|-----|
| GET    | `/content-intelligence` |
| GET    | `/content-freshness` |
| GET    | `/content-freshness/website-pages` |

---

## 📅 Calendar & Images

### Calendar

| Method | URL |
|--------|-----|
| GET/POST | `/projects/{project}/calendar` |
| PUT/DELETE | `/calendar/{id}` |
| POST | `/calendar/{entry}/generate-article` |

### Project Images (legacy per-project listing)

| Method | URL | Beschreibung |
|--------|-----|--------------|
| GET    | `/projects/{project}/images` | Listet alle Bilder eines Projekts (legacy — die team-weite Gallery `/v1/images` unten ist der primäre Pfad) |

### Image Gallery (permanent, team-scoped)

Cross-Team-IDs liefern `404` (nicht 403, kein Existenz-Leak).

| Method | URL | Credits | Beschreibung |
|--------|-----|---------|--------------|
| GET    | `/images` | 0 | Liste mit Filter: `?q`, `?model`, `?from`, `?to`, `?favorite`, `?tags[]`, `?sort=newest\|oldest\|edited\|name\|model\|size\|date`, `?dir=asc\|desc` (nur für name/model/size/date — spiegelt die Spalten-Sortierung der Galerie-Tabellenansicht), `?page`, `?per_page` |
| GET    | `/images/trash` | 0 | Soft-deleted Bilder |
| GET    | `/images/{id}` | 0 | Detail inkl. `variants[]` und `parent`-Tree |
| POST   | `/images/{id}/edit` | 10 | Edit-Variante via OpenAI Images-Edit-API (`openai:/images/edits`, extern). Async 202, Body: `{job_id, edit_session_id}`. Frontend pollt `/images/{id}` |
| DELETE | `/images/{id}` | 0 | Soft-delete (30 Tage Trash, dann hard-delete) |
| POST   | `/images/{id}/restore` | 0 | Wiederherstellen aus Trash |
| DELETE | `/images/{id}/forever` | 0 | Endgültig löschen (NUR aus Trash, sonst 409) |
| POST   | `/images/{id}/favorite` | 0 | Toggle `is_favorite` |
| PATCH  | `/images/{id}` | 0 | Update Metadaten: `{title?, tags?[]}` |
| POST   | `/images/bulk/delete` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/images/bulk/restore` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/images/bulk/export` | 0 | Body `{ids:[]}` max 500. Async 202 → `{job_id}` für ZIP |
| GET    | `/images/exports/{job_id}` | 0 | Export-Status: `{status: queued\|ready\|failed, download_url?}` |
| POST   | `/images/{id}/open-in-chat` | 0 | Erstellt `AiChatSession` mit `gallery_image_id={id}`. Returns `{session_id}` für Redirect zu `/chat/{session_id}` |
| POST   | `/images/{id}/share` | 0 | Idempotenter Public-Share-Token. Body: `{expires_at?}`. Returns `{token, public_url, expires_at, view_count, is_active}` |
| PATCH  | `/images/{id}/share` | 0 | Expiry ändern. Body: `{expires_at: ISO\|null}` (404 wenn kein aktiver Share) |
| DELETE | `/images/{id}/share` | 0 | Public-Share widerrufen (View-Count bleibt erhalten) |

### Dateien (Nicht-Bild-Chat-Dateien, team-scoped)

Komplement zur Image Gallery: alles mit `mime_type` ≠ `image/*` (agent-generierte xlsx/csv/md + User-Uploads). Cross-Team- ODER Bild-`id` liefert `404`. Liste zeigt nur lebende Dateien (`expires_at IS NULL OR >= now`). Items: `{id, original_name, type_label, size_bytes, mime_type, session{id,title}, expires_at, expires_in_days, pinned, is_favorite, tags, download_url}`.

| Method | URL | Credits | Beschreibung |
|--------|-----|---------|--------------|
| GET    | `/files` | 0 | Liste mit Filter: `?q` (Name), `?type` (mime-Teilstring), `?session_id`, `?expiring=1`, `?sort=newest\|oldest\|name\|size`, `?page`, `?per_page` (≤100) |
| GET    | `/files/trash` | 0 | Soft-deleted Dateien des Teams |
| GET    | `/files/{id}` | 0 | Detail inkl. `session` + `download_url` |
| POST   | `/files/{id}/keep` | 0 | Macht die Datei dauerhaft (`pinned_at`, `expires_at=NULL`) — gegen 30-Tage-Auto-Ablauf |
| POST   | `/files/{id}/favorite` | 0 | Toggle `is_favorite` |
| PATCH  | `/files/{id}` | 0 | Update Metadaten: `{title?, tags?[]}` |
| DELETE | `/files/{id}` | 0 | Soft-delete (Papierkorb) |
| POST   | `/files/{id}/restore` | 0 | Wiederherstellen aus Papierkorb |
| DELETE | `/files/{id}/forever` | 0 | Endgültig löschen (NUR aus Papierkorb, sonst 409) |
| POST   | `/files/bulk/delete` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/files/bulk/restore` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/files/bulk/export` | 0 | Body `{ids:[]}` max 500. Async 202 → `{job_id}` für ZIP |
| GET    | `/files/exports/{job_id}` | 0 | Export-Status: `{status, download_url?}` |
| POST   | `/files/{id}/share` | 0 | Idempotenter Public-Share-Token. Returns `{token, public_url, expires_at, view_count, is_active}`. `public_url` = `/share/file/{token}` |
| PATCH  | `/files/{id}/share` | 0 | Expiry ändern (404 wenn kein aktiver Share) |
| DELETE | `/files/{id}/share` | 0 | Public-Share widerrufen |

---

## 🤖 AI Tools

### Agentic Chat — Master Agent als Endpoint

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/agentic/chat` | varies (depends on tools used by the agent) |

Body:
- `message` (string, required, max 10k): User-Anfrage in natürlicher Sprache
- `session_id` (int, optional): bestehende Session fortführen

Query-Parameter:
- `wait_for_final` (bool, default `true`): bei `wait_and_recheck`-Handoff (z.B. asynchroner DataForSEO-Pull) pollt der Endpoint serverseitig bis zur finalen Resume-Antwort (max ~480 s). Auf `false` setzen um sofort die Placeholder-Nachricht zu erhalten.

Response 200: `{session_id, message_id, text, tool_calls, iterations, credits_used, duration_ms, resumed}`

`resumed: true` heißt: der Master-Agent hat mid-turn `wait_and_recheck` getriggert und der Endpoint hat erfolgreich auf den Resume-Turn gewartet. Der `text` ist dann die *finale* Antwort, nicht der Wartehinweis.

Hinweise:
- Synchron — komplette Loops können 5–10 min dauern (PHP-FPM 600 s Hardlimit, Client-Timeout entsprechend setzen)
- Triple-scoped (team_id + user_id + id) gegen IDOR
- Spart externen Clients die Orchestrierung der 12+ Meta-Tools — der Master-Agent wählt + ruft selbst auf
- Iterations-Cap 150, Wall-Clock-Cap 25 min, No-Progress-Detector 8 — alle env-overridable, alle münden bei Hit in einen tools-disabled Summarize-Call statt einer harten Fehlermeldung
- Companion Claude Code Skill: `rankion-agent`

### AI Scanner / Detection

| Method | URL | Credits | Notes |
|--------|-----|---------|-------|
| POST   | `/ai-scanner/detect` | 2 | `scan_type` (alias `mode`): `quick` = sync **200** (heuristic) · `deep` = async **202** `{scan_id,status}` → poll status. `language` (`de`/`en`/`es`/`fr`) persisted. Without `language`, an auto-detected unsupported language (e.g. `pl`) returns **422** `{message, detected_language, supported_languages}` — no scan, no credits. Every scan stored in `/ai-scanner` history. Failed deep scan auto-refunds. |
| GET    | `/ai-scanner/scans/{id}` | — | Poll an AI-detection scan: `{scan_id,scan_type,processing_status,score,details,word_count,language,credits_charged}`. Team-scoped. |
| POST   | `/ai-scanner/humanize` | dyn | Humanize an existing `article_id` (async **202**). Cost `ceil(words/1000) × {light:2,standard:4,deep:8}` — job-deducted, controller pre-flight gates. |

### Humanizer

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/humanize` | 8 |
| GET    | `/humanize/{batch}` · `.../documents/{document}` | — |
| POST | `/humanize/{}/documents/{}/rehumanize` | Re-humanize an existing document (queue another LLM rewrite, async) | 8 |
| GET | `/humanize/{}/documents/{}/rescore` | Re-score a humanize document's human-score after manual edits (free) | 0 |

### Content Optimizer

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/content-optimizer` · `/content-optimizer/{id}` · `/content-optimizer/optimizations/{id}` (alias) | — |
| POST   | `/content-optimizer/analyze` | 5 |
| POST   | `/content-optimizer/{id}/apply` · `/content-optimizer/optimizations/{id}/apply` (alias) | 5 |
| DELETE | `/content-optimizer/{id}` (Soft-Delete, 204) | — |

**Async-Vertrag (Subsystem B P0.3 — fixed 2026-05-09):**

`POST /v1/content-optimizer/analyze` returns `202` mit:
```json
{
  "message": "Content optimization started",
  "id": 6,
  "optimization_id": 6,
  "poll_url": "/api/v1/content-optimizer/6"
}
```
Plus Header `Location: /api/v1/content-optimizer/6`.

**Polling-Pfad:** `GET /api/v1/content-optimizer/6` (canonical) — der Pfad `/optimizations/6` funktioniert auch (Alias-Route, weil das Field heisst `optimization_id`). Caller können beides.

`optimization_id` bleibt für Kompatibilität, aber neue Caller sollten `id` lesen.

---

## 🥊 Competitor Analysis

| Method | URL | Credits |
|--------|-----|---------|
| GET/POST | `/competitor-analyses` — POST-Body `{project_id, domain, language?, country?, competitor_limit? (1–20, Default 5), keyword_limit?, manual_competitors?[]}`; `manual_competitors` sind höchstens `competitor_limit` Domains (dasselbe Limit wie für die automatisch entdeckten, Plan #176), normalisiert und ohne die eigene Domain, sonst 422 | POST: **mindestens 20 Guthaben** zum Start (nur Prüfung, keine Abbuchung); abgebucht wird nach dem Lauf `ceil((1 + Wettbewerber) × (0,01 + keyword_limit × 0,0001) × 100 × 1,2)` — Vorgabe (5 / 100) → 15, 10 / 100 → 27, 20 / 500 → 152 |
| GET    | `/competitor-analyses/{id}` · `.../gaps` (`?is_favorite=&is_dismissed=`; `.../gaps` je Gap `competitor_positions[]` `{domain, position}` + `positions_checked_at`) — `competitors[]` je Wettbewerber mit Majestic-Autorität `trust_flow` / `citation_flow` / `ref_domains` / `authority_checked_at` (null bis angereichert) | — |
| PATCH  | `/competitor-analyses/{id}` Body `{name}` (umbenennen) | — |
| DELETE | `/competitor-analyses/{id}` (inkl. Gaps, 204) | — |
| PATCH  | `/competitor-analyses/gaps/{id}` Body `{is_favorite?, is_dismissed?}` | — |
| POST   | `/competitor-analyses/{id}/rerank-competitors` | — |

---

## 📊 Content Audits (Phase 4 — site-wide content scanner)

Site-wide content-quality crawler. Walks a domain (sitemap-discovered), scores each page on SEO (0–100), content quality (0–100), word count, and emits `issues[] = {type:error|warning|info, category, message}` plus `recommendations[]`. Distinct from `/site-audit` — this module is the *content-quality* angle, `/site-audit` is the *crawl/issue/grounding* angle.

| Method | URL | Credits |
|--------|-----|---------|
| GET/POST | `/content-audits` | 10 (POST) |
| GET    | `/content-audits/{id}` · `.../export` · `.../pages` — Einzelabruf und Liste liefern `wartet_auf_domain_seit` + `wartet_auf_domain_bis` (ISO 8601): gesetzt, solange ein `running` Audit auf eine belegte Domain wartet | — |
| GET    | `/content-audits/{id}/compare` | — |
| PATCH  | `/content-audits/{id}/pages/bulk` | — |
| GET    | `/content-audit-pages/{id}` | — |
| PATCH  | `/content-audit-pages/{id}/solved` | — |
| POST   | `/content-audit-pages/{id}/refresh` | 1 |

**Audits-listing — filters on `GET /content-audits`.** Query params:
- `tracking_project_id=<int>` — filtert auf Audits eines Tracking-Projects (FK→`tracking_projects.id`).
- `source_url=<string>` — exact-match auf die Audit-URL (Audit ist 1:1 mit URL gescoped).
- `status=running|completed|failed`
- `type=full|quick` (UI-Pfade `sitemap`/`single` werden nicht über die API gestartet).
- **`project_id` → 422** (Variante 1 Rename 2026-05-10) — Migration-Hint im Response. `tracking_project_id` ist die kanonische FK, parallel zum /grounding-Pattern.

**Listing-Schema (`data[].keys`):** `id, tracking_project_id, source_url, type, status, total_pages, avg_seo_score, total_issues, created_at`. Detail-Schema (`/content-audits/{id}`) enthält zusätzlich `team_id, user_id, project_id (legacy), tracking_project_id, summary, …`.

**POST-Body** akzeptiert: `{source_url, type?:full|quick, tracking_project_id?}`. **`project_id` → 422** mit Migration-Hint. `tracking_project_id` wird via `exists:tracking_projects,id` validiert und persistiert.

**Pages-listing — filters on `GET /content-audits/{id}/pages`.** Query params:
- `status=open|solved` — `open` is the canonical alias for `is_solved=false`. Anything other than `solved` resolves to open.
- `priority=critical|high|medium|low`
- `min_seo_score=0..100`, `max_seo_score=0..100`
- `issue_type=<string>` — matches any `issues[].type` value (canonical severity buckets are `error`, `warning`, `info`). Implemented as JSON-LIKE on the serialized `issues` column.
- `sort=` one of `url|title|seo_score|content_quality_score|word_count|priority|is_solved`, `dir=asc|desc`, `per_page=` (default 50).

Response shape: `{progress:{total, solved, open, percent}, pages:{data:[…], current_page, last_page, …}}`.

**Audit-over-time compare — `GET /content-audits/{id}/compare`.** Read-only, **0 credits**, runs no new crawl (pure read of the persisted summary + page issues). Compares `{id}` (current) against a baseline:
- `against=<int>` — explicit baseline audit id (team-scoped; 422 if not found / foreign team).
- omitted → the most recent prior **completed** audit of the same `source_url`.
- No prior audit → **200** with `baseline: null` and null trend deltas (never 404/500).

Response shape: `{current, baseline, trend:{avg_seo_score_delta, avg_quality_score_delta, total_issues_delta, by_type_delta:{error,warning,info}}, issues:{new:[…], fixed:[…], persistent:[…], by_type_delta}}`. Deltas = current − baseline. Issue identity is normalized (url + type + message-core, numbers/brackets stripped) so a re-measured count is not flagged as "new".

**Single-page refresh — `POST /content-audit-pages/{id}/refresh`.** **1 credit** (`credits-auto:1`, SSOT `config/credits.php` flat `content_audit_refresh_page`). Async re-crawl of one page: sets row `status=pending`, dispatches `RefreshAuditPageJob`, returns **202** `{page_id, status:queued}`. Guarded on the parent audit being `completed` (else **422**) so a mid-flight batch crawl can't re-grab the page. Throttle: 30/min. Poll `GET /content-audit-pages/{id}` for the `pending→processed` transition.

**Bulk-mark — `PATCH /content-audits/{id}/pages/bulk`.** Throttle: 30/min. Body:
```json
{
  "filter": {
    "priority": "low",
    "status": "open",
    "min_seo_score": 0,
    "max_seo_score": 50,
    "issue_type": "error"
  },
  "is_solved": true
}
```
All `filter.*` fields are optional. `filter.status` is the FROM-state to match (only flips rows currently in this state). `is_solved` is required and is the new state to write. Response: `{data:{affected:int, is_solved:bool}, meta:{applied_filter:{…}}}`. Team-scoped via `audit.team_id` — cross-team access returns 403.

**`filter.project_id` → 422** (Variante 1 Rename 2026-05-10): Pages haben keinen Project-FK; das Projekt ergibt sich aus dem Parent-Audit. Vorher wurde `filter.project_id` stumm gedroppt — was Mass-Updates über alle Pages des Audits zog statt nur über die eines Projekts. Jetzt 422 mit klarem Hint.

---

## 🧑‍🤝‍🧑 Community (Mitglieder)

**Nur für Administratoren.** Die Kette ist `auth:sanctum` **plus `admin`** — ein
gewöhnlicher Kunden-Token bekommt 403. Grund: ein Community-Mitglied gehört
keinem Team an, es gibt also nichts zu scopen; ohne das Admin-Gate bekäme jeder
Token Klarnamen und Mailadressen aller Mitglieder. Dieselbe Bauart wie
`/v1/outbound/*`.

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/community/members` — Filter: `q`, `membership_level` (`std`\|`pro`), `status` (`aktiv`\|`gesperrt`\|`unbestaetigt`), `per_page` (max 100), `page`. Antwort: `{data:[…], meta:{current_page, per_page, total, last_page}}` | — |
| GET    | `/community/members/{member}` — ein Mitglied. 404 bei unbekannter Nummer. | — |
| GET    | `/community/members/{member}/membership` — die Mitgliedschaft eines Mitglieds: Stufe, Status, Laufzeitende, Abo-Kennung. `is_running` beantwortet die eigentliche Frage »läuft sie?« und schließt `active`, `trialing` und `past_due` ein. **Nur lesend** — eine Mitgliedschaft entsteht durch eine Zahlung; sie hier zu setzen ließe Katalog und Stripe auseinanderlaufen. | — |
| GET    | `/community/channels` — die Chat-Kanäle mit Lese- und Schreibrecht, Reihenfolge und Zahl der sichtbaren Nachrichten. `?active=1` beschränkt auf offene Kanäle; ohne den Parameter kommen auch stillgelegte, denn die Frage eines auswertenden Systems ist »welche gibt es?«, nicht »welche sind offen?«. **Admin-gegatet** wie `/community/members` — ein Chat steht nicht öffentlich im Web. | — |
| GET    | `/community/channels/{}/messages` — der Verlauf eines Kanals, seitenweise (`per_page`, max 200, Vorgabe 50; `page`). Verborgene Nachrichten erscheinen NICHT — eine Schnittstelle, die mehr zeigt als die Seite, wäre ein Weg an der Moderation vorbei. Als Verfasser kommt das **Pseudonym**, nie Klarname oder Mailadresse. 404 bei unbekanntem Kanal. **Admin-gegatet.** | — |
| GET    | `/community/conversations` — die Direktnachricht-Gespräche als **Zahlen, nicht als Inhalte**: Teilnehmer-Pseudonyme, Zahl der Nachrichten, Zeitpunkt der letzten. Der fehlende Nachrichtentext ist der Punkt dieses Endpunkts, nicht eine Lücke: ein Gespräch unter vier Augen wird nicht über eine Schnittstelle ausgeliefert, die ein Skript abrufen kann (Artikel 5 Absatz 1 Buchstabe c DSGVO). Wer einen einzelnen gemeldeten Satz braucht, bekommt ihn über den Meldeweg samt Vorgang und Aktenlage. Seitenweise (`per_page`, max 200). **Admin-gegatet.** | — |

### Community-Forum (Plan-Hub #116, Position 16)

**Lesen ist offen** (Drosselung 60/min, wie der Stufen-Katalog), **Schreiben und
die Meldungen brauchen `auth:sanctum` plus Admin** (120/min). Der Grund für die
Trennung: dieselben Inhalte stehen ohne Anmeldung im Web — sie hinter einen
Schlüssel zu stellen verbirgt nichts und nimmt der Auffindbarkeit in KI-Antworten
die Quelle. Schreiben geht dagegen nur über das Team, weil ein Community-Mitglied
gar keinen API-Schlüssel besitzt (eigener Guard, keine Token-Fähigkeit am Model);
`member_id` sagt, in wessen Namen geschrieben wird.

**Verborgene Themen und Antworten erscheinen nirgends** — was die Moderation
weggenommen hat, bleibt auch hier weg. Und es gibt **keine Mailadressen**: die
Meldungs-Liste sagt nur, OB ein Melder erreichbar ist, nicht wie.

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/community/forum/sections` — aktive Bereiche mit Themenzahl, Schreibrecht und Web-Adresse. Ohne Anmeldung. | — |
| GET    | `/community/forum/topics` — Filter: `section` (Kurzform), `state` (`open`\|`answered`\|`solved`\|`closed`), `q` (Titel und Text), `per_page` (max 100), `page`. Sortiert nach letzter Aktivität. Antwort: `{data:[…], meta:{…}}`. Ohne Anmeldung. | — |
| GET    | `/community/forum/topics/{}` — ein Thema mit Text und seinen sichtbaren Antworten, die Lösung als `is_solution`. **Die Antworten blättern** (`per_page`, max 100, Vorgabe 25; `page`); ihre Blätterung steht in `posts_meta`, nicht in `meta`. 404 bei unbekanntem oder verborgenem Thema. Ohne Anmeldung. | — |
| POST   | `/community/forum/topics` — Thema anlegen. Felder: `section`, `member_id`, `title` (8–200), `body` (20–20000) — dieselben Untergrenzen wie im Web. Jeder Aufruf schreibt eine Zeile ins Admin-Protokoll (`community.forum.api.thema-angelegt`). **Die Schreibrechte des Bereichs gelten auch hier**: ein PRO- oder Team-Bereich weist mit 422 ab. Antwort 201. **Admin-Token.** | — |
| GET    | `/community/forum/reports` — Meldungen nach Art. 16 DSA, ohne Filter die offenen. Je Zeile Vorgangsnummer, Grund, Entscheidung samt Begründung und die Widersprüche nach Art. 20. **Admin-Token.** | — |

Kennwort und `remember_token` verlassen die Schnittstelle nie (im Model als
`$hidden`, zusätzlich durch eine ausdrückliche Feldliste im Controller).

Die Mitgliederzeile trägt seit Plan-Hub #115 zusätzlich `membership`
(`plan_id`, `plan_label`, `status`, `ends_at`, `stripe_subscription_id`) und
`team_id`. Die Feldliste im Controller ist eine **ausdrückliche Aufzählung** —
ein neues Feld am Model erscheint dort nicht von selbst.

### Community — Skills und Videothek (Plan-Hub #118, Position 11)

Lesend **ohne Token**, Drosselung 60/min — dieselbe Begründung wie beim Forum
darüber: die Übersichten stehen ohne Anmeldung im Web.

| Methode | Endpoint | Credits |
|---|---|---|
| GET    | `/community/skills` — Skills-Datenbank. Filter: `category` (Kurzform), `per_page` (max 100, Vorgabe 25), `page`. Antwort: `{data:[…], meta:{total, per_page, current_page, last_page}}`. Ohne Anmeldung. | — |
| GET    | `/community/skills/{}` — ein Eintrag über seine Kurzform. **Bei `read_access: "pro"` fehlen `body` und `download`** — ein API-Aufrufer hat keinen Community-Guard und ist damit immer im Zustand »nicht angemeldet«; die Schnittstelle zeigt genau so viel wie die Seite in diesem Zustand. 404 bei unbekanntem oder abgeschaltetem Eintrag. | — |
| GET    | `/community/videos` — Videothek. Filter und Blätterung wie bei den Skills. Je Zeile Anbieter, Laufzeit, Kategorie und Kapitelzahl — nie die Einbettungsadresse. | — |
| GET    | `/community/videos/{}` — ein Video über seine Nummer, mit Beschreibung und Kapitelmarken. **`embed_url` erscheint seit dem 26.10.2026 (T-820) bei KEINER Lesestufe mehr**, auch nicht bei `read_access: "members"`: die Adresse IST der Zugang, nicht bloß ein Hinweis darauf, und dieser Endpunkt hat keinen angemeldeten Zustand. 404 bei unbekanntem oder abgeschaltetem Video. | — |

`read_access` steht in **jeder** Antwort, auch in den Listen. Ein Aufrufer soll
unterscheiden können zwischen »dieser Eintrag hat keinen Volltext« und »dieser
Volltext ist mir verschlossen« — sonst sieht ein PRO-Eintrag aus wie ein leerer.

Die Grenze des Zugriffsschutzes bei Videos steht in `docs/modules/community.md`:
ein nicht gelisteter Videoverweis ist kein Schutz, wer die Kennung hat, sieht das
Video. Für PRO-Inhalte gehört zusätzlich die Domänen-Sperre beim Videodienst
gesetzt.

### Community — Stufen-Katalog (OFFEN, ohne Token)

Die dritte Routengruppe dieses Bereichs und die einzige ohne Riegel
(Plan-Hub #115, Position 14). Das ist kein Versehen: die Stufen und ihre Preise
stehen ohnehin auf der öffentlichen Kaufseite unter `/community/mitgliedschaft`.
Sie hinter einem Token zu verstecken schützte nichts und machte den Katalog für
ein Fremdsystem unbrauchbar, das einen Preis anzeigen will. Gedrosselt auf
60 Anfragen je Minute — offen heißt nicht unbegrenzt.

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/community/plans` — der Stufen-Katalog. Ohne Token lesbar. `include_inactive=1` nimmt abgeschaltete Stufen mit (Vorgabe: nur aktive). Felder: `id`, `slug`, `label`, `price_cents`, `price_eur` (Zeichenkette mit zwei Nachkommastellen), `currency`, `included_rankion_plan`, `is_default`, `is_active`, `sort_order`. | — |
| GET    | `/community/plans/{plan}` — eine Stufe. 404 bei unbekannter Nummer. | — |

Die Stripe-Kennungen (`stripe_product_id`, `stripe_price_id`) verlassen diese
Endpunkte **nicht**: sie sind keine Geheimnisse, gehören aber nicht in eine
Preisliste. `price_eur` ist bewusst eine Zeichenkette — als Zahl serialisiert
macht JSON aus `35.00` die `35` und aus `35.50` die `35.5`, und ein Feld, dessen
Typ zwischen ganzzahlig und gebrochen wechselt, ist für ein Fremdsystem eine
Fehlerquelle. Die tragende Wahrheit ist ohnehin `price_cents`.

---

## 👥 Community Monitor

Kanonisch seit 03.09.2026 (Plan-Hub #114): `/v1/community-monitor/*`. Der
Wortstamm `community` gehört seither der öffentlichen Community mit eigener
Mitgliederverwaltung. Die alten Adressen bleiben als Zweitname bestehen und
antworten unverändert — kein Fremdsystem muss umgestellt werden.

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/community-monitor/mentions` · `/community-monitor/mentions/{id}` | — |
| POST   | `/community-monitor/scan` Body: `{keyword, platforms[]}` | 5 |
| GET    | `/community-monitor/alerts` | — |
| GET    | `/community/mentions` · `/community/mentions/{id}` — Zweitname, unverändert | — |
| POST   | `/community/scan` — Zweitname, unverändert | 5 |
| GET    | `/community/alerts` — Zweitname, unverändert | — |

---

## 📈 Site Monitor

| Method | URL |
|--------|-----|
| GET/POST | `/site-monitor` |
| GET/PUT/DELETE | `/site-monitor/{id}` |
| POST   | `/site-monitor/{id}/pause` · `.../resume` · `.../check-now` |
| GET    | `/site-monitor/{id}/checks` · `.../incidents` · `.../lighthouse` |
| GET/PUT | `/site-monitor/{id}/deep-seo-schedule` — Takt des automatischen Deep-SEO-Audits (Karte #7386): `{data: {monitored_site_id, frequency, is_active, last_run_at, next_run_at}}`; PUT `{frequency: daily\|weekly\|monthly\|off}`. `next_run_at` ist der erste Zeitplanertermin (täglich 04:00) nach der Fälligkeit, `null` bei `off` oder pausiert |

---

## 🚀 Automation

### Bulk Generations

| Method | URL | Credits |
|--------|-----|---------|
| GET/POST | `/bulk-generations` Body: `{project_id, keywords[], type?, length?}` | 15 (POST) |
| GET    | `/bulk-generations/{id}` | — |

### Autopilot

| Method | URL |
|--------|-----|
| GET/POST | `/autopilot` Body: `{module, frequency, config}` |
| PUT/DELETE | `/autopilot/{id}` |
| GET    | `/autopilot/{id}/runs` real per-run history (keyword, status, `geo_score`, `skip_reason`, article link). `?status=` filter. Read-only, 0 credits |

Auto-publish quality gate (TODO-059): when `settings.auto_publish=true`, each run scores the generated content with the deterministic GEOScorerService and only publishes (and pushes to a connected CMS) if `total_score >= settings.min_geo_score` (default `config('autopilot.min_geo_score')`=70). Below threshold → kept as a draft + a `skipped_low_score` run row; never published. Each run records exactly one row (`success`/`skipped_no_credits`/`skipped_no_keyword`/`skipped_low_score`/`failed`). Credit per article = `FeatureCost::article(word_count)`; the gate scoring itself is free.

### Pipelines

| Method | URL | Credits |
|--------|-----|---------|
| GET/POST | `/pipelines` Body: `{project_id, keyword, options}` | POST: je Schritt im Job (Artikel + 5 Humanisieren + 3 Faktencheck), keine Pauschale; Routen-Gate prüft ≥ 20 |
| GET    | `/pipelines/{id}` | — |
| POST   | `/pipelines/{id}/retry` | — |

---

## 🏗 Projects & Settings

| Method | URL |
|--------|-----|
| GET/POST | `/projects` |
| POST   | `/projects/bulk` |
| GET/PUT/DELETE | `/projects/{id}` |
| GET/POST | `/goals` · `DELETE /goals/{id}` |
| GET    | `/modules` |
| POST   | `/modules/{module}/activate` · `.../deactivate` |

`POST /projects` Body: `{name, domain?, language?, description?, country?, additional_countries[]?, primary_cms_integration_id?}`
— `country` ist der einzelne Primärwert (`projects.country`, unverändert). `additional_countries[]`
(max 10, je ISO-3166-1 alpha-2) legt/nutzt pro Land ein `setup`-Tracking-Gerüst
(`tracking_projects`+`tracking_keywords`-Platzhalterzeile) an, siehe `docs/modules/tracking-projects.md`.
Antwort trägt dann zusätzlich `additional_markets: {registered[], skipped[], reason}` — `reason`
ist `no_domain` (Projekt ohne Domain) oder `active_tracking_project` (Domain hat bereits aktives
Tracking, kein Platzhalter wird angelegt); das Feld fehlt ganz, wenn keine `additional_countries`
gesendet wurden.
**Projektlimit des Tarifs** (Karte #10952): ist `max_projects` erreicht, entsteht kein neues Projekt —
`422 {error:"project_limit_exceeded", message, limit{max,used,free,needed,demo_zentrale}}`, dieselbe Form wie bei
`/projects/bulk`. `limit.demo_zentrale` (bool, Karte #11402) ist `true`, wenn das aktuelle Team die Demo-Zentrale ist:
dort entstehen nie Projekte (`max` = 0), ein Tarifwechsel hilft also nicht, nur das jeweilige Demo-Team. Eine schon vorhandene Domain im Team liefert weiterhin `200` mit dem bestehenden
Projekt (Idempotenz), auch am Limit. Bestehende Projekte bleiben über dem Limit voll nutzbar.

`POST /projects/bulk` (Karte #10919, FB-183) Body: `{urls[] (1–100), language?}` — legt je gültiger
URL ein Projekt im aktuellen Team an (Name = Domain). Schreibweise wie beim Einzelanlegen
(`DomainNormalizer`): mit/ohne `https://` und `www.` ergibt dieselbe Domain. Antwort
`{created[{line,input,domain,id}], duplicates[{line,input,domain,reason,project_id?}], invalid[{line,input}],
limit{max,used,free,needed,demo_zentrale}, limit_exceeded}`; `reason` ist `existing_project` (gibt es im Team schon)
oder `repeated_line` (steht weiter oben in der Liste). `201`, wenn mindestens eines entstand, sonst `200`.
**Projektlimit des Tarifs** (`config/pricing.php` `max_projects`) gilt für die ganze Liste:
passt sie nicht vollständig, entsteht keines — `422 {error:"project_limit_exceeded", message, limit, data}`,
`limit.free` nennt, wie viele noch passen (`max`/`free` = `null` bei unbegrenztem Tarif). `limit.demo_zentrale` (bool, Karte #11402)
ist `true` im Heimatteam der Demo: dort entsteht nie ein Projekt, die `message` nennt die Demo-Zentrale statt des Tarifs. Keine Credits.

`primary_cms_integration_id` (Karte #7398 P#228) setzt das Standard-CMS für neue Artikel des
Projekts; `PUT /projects/{id}` nimmt dasselbe Feld, ausdrückliches `null` löscht die Zuordnung.
Die Regel trägt bewusst kein `exists` — sie prüfte nur, DASS die Zeile da ist, nicht wem sie
gehört. Eine Kennung aus einem fremden Team wird **verworfen, nicht abgewiesen**: das Projekt
entsteht, nur ohne das fremde CMS. Dieselbe Nachprüfung benutzen beide Dialoge
(`CmsIntegration::bestaetigteWahl()`).

---

## 🎯 Action Center

| Method | URL |
|--------|-----|
| GET/POST/PATCH | `/action-center/goal-profile` |
| GET    | `/action-center/goal-templates` · `.../industry-templates` · `.../seo-detectors` |
| GET    | `/action-center/path` · `/action-center/paths` |
| POST   | `/action-center/path/recompute` |
| GET    | `/action-center/opportunities` — Chancen: konkrete Handlungsschritte, gruppiert (`content`, `links`, `technical`), je mit `deep_link` in die gefilterte Auswertung; Filter `?group=` · 0 credits |
| GET/POST | `/action-center/todos` |
| GET/DELETE | `/action-center/todos/{id}` |
| GET    | `/action-center/todos/{id}/outcome` — measured outcome (baseline vs realized, 14d delta) · 0 credits |
| POST   | `/action-center/todos/{id}/complete` · `.../snooze` |
| POST   | `/action-center/todos/bulk-complete` — mehrere offene To-dos in einem Zug erledigen. Body `{ids: int[]}` (1–100, eindeutig). Erledigt nur offene Aufgaben des eigenen Teams, die der Nutzer sehen darf (keine Maßnahmen, keine admin-gesperrten Module); alles andere kommt als `skipped` zurück statt den Aufruf scheitern zu lassen. → `{completed: int[], skipped: int[], completed_count}` · 0 credits |
| POST | `/action-center/todos/{}/move` | To-do in Kanban-Spalte verschieben; Done setzt Status completed | — |
| POST | `/action-center/todos/{}/notes` | Team-Notiz an ein To-do anhängen | — |

**Projektfilter (Plan #272):** `GET /action-center/todos?project_id=<id>` liefert die Aufgaben dieses Projekts **plus** alle Aufgaben ohne Projektbezug — die gelten dem ganzen Team und dürfen beim Umschalten nicht verschwinden. Jede Zeile trägt `project_id`; `null` heißt »ohne Projektbezug«. Das Projekt muss dem eigenen Team gehören (sonst `422`) und für den Nutzer sichtbar sein (sonst `403`) — dieselben zwei Fragen, die auch der Projektumschalter der Oberfläche stellt. Ohne den Parameter ändert sich nichts.

**Maßnahmen (K#9294, Eigentümer-Entscheid 15.10.2026):** `GET /action-center/todos` lässt vom Kunden eingetragene Maßnahmen (Todo-Art `massnahme`, siehe `/tracking-projects/{id}/measures`) **standardmäßig weg** — sie sind keine Aufgaben. Nur mit `?kind=massnahme` kommen sie, und dann ausschließlich sie: ohne `status` in jedem Status (eine Maßnahme ist als `completed` gespeichert), beschränkt auf Tracking-Projekte, die der Nutzer sehen darf (Projektrechte inklusive). Einzelabruf und Aktionen auf eine Maßnahme verlangen die Projektrechte auf ihrem Tracking-Projekt (sonst `404`); `POST …/move` auf eine Maßnahme antwortet `422`.

**Chancen (Plan #246):** `GET /action-center/opportunities` erkennt live mit denselben Detektoren wie die To-dos, speichert nichts und verschickt keine Alarm-Mail. Antwort `data[] = {group, count, items[]}` und `meta.total`; ein Item zeigt nur, wenn sein `deep_link` auf eine registrierte Route führt und deren Modul für das Team aktiv und nicht admin-gesperrt ist. `?group=` mit unbekanntem Wert ergibt `422`.

**Admin-Modulsperre:** Für Nicht-Admins liefert `GET /action-center/todos` keine To-dos zu einem plattformweit gesperrten Modul (Modulschlüssel oder Zielroute); Einzelabruf und Aktionen auf ein solches To-do antworten mit `404`, `POST /action-center/todos` mit `422`. Globale Admins sind ausgenommen.

---

## 🔌 Matomo Integration

Die eigene Matomo-Instanz des Teams als Traffic-Quelle. Das Token wird nur geschrieben und nie zurückgegeben (`token_set`). `base_url` nimmt nur https an, sonst 422. Fehlerform und Abläufe: `docs/modules/integrations-matomo.md`.

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/matomo/connections` | — |
| PUT    | `/matomo/connection` | — |
| DELETE | `/matomo/connection` | — |
| GET    | `/matomo/sites` | — |
| POST/DELETE | `/matomo/sites/{site}/link` | — |
| POST   | `/matomo/sites/{site}/sync` | 1 |
| GET    | `/matomo/sites/{site}/metrics?from=&to=&dimension=daily\|ai_source\|page` | — |

---

## 🔌 Google Integrations

| Method | URL | Credits |
|--------|-----|---------|
| GET    | `/google/connections` | — |
| DELETE | `/google/connections/{id}` | — |
| GET    | `/google/oauth-client` | — |
| PUT    | `/google/oauth-client` | — |
| DELETE | `/google/oauth-client` | — |
| GET    | `/google/gsc/properties` | — |
| POST/DELETE | `/google/gsc/properties/{p}/link` | — |
| POST   | `/google/gsc/properties/{p}/sync` | 1 |
| GET    | `/google/gsc/properties/{p}/metrics` | — |
| POST   | `/google/gsc/properties/{p}/insights/rebuild` | 1 |
| GET    | `/google/ga4/properties` | — |
| POST/DELETE | `/google/ga4/properties/{p}/link` | — |
| GET    | `/google/ga4/properties/{p}/metrics` | — |
| GET    | `/integrations/google/reviews/status` | — |

**Eigener Google-Zugang je Team (K#11090):** `GET /google/oauth-client` liefert
`configured`, `client_id`, `client_secret_set` (nur ob ein Schlüssel hinterlegt ist — der
Schlüssel selbst verlässt den Server nie, auch nicht maskiert), `active_source` (`team` |
`shared` | `none`: welcher Zugang beim nächsten »Mit Google verbinden« gilt),
`redirect_uris.analytics` / `redirect_uris.reviews` (in der Google Cloud Console einzutragen)
und `updated_at`. Lesen darf jedes Teammitglied. `PUT` (`client_id` Pflicht, Form
`….apps.googleusercontent.com`; `client_secret` Pflicht bei neuem Zugang oder neuer
Client-ID, sonst leer = behalten) und `DELETE` verlangen `manage settings`
(Eigentümer/Administrator), sonst **403**; Formfehler **422** mit Feldname. `PUT` ist auf
10 Aufrufe je Minute gedrosselt. Nach `DELETE` oder einer neuen Client-ID müssen
Verbindungen, die über den alten Team-Zugang liefen, neu verbunden werden.

`/google/gsc/properties/{p}/insights/rebuild` fuehrt die abgerufenen Search-Console-Zahlen in
einen Auswertungs-Stapel ueber, damit die Insight-Karten auf `/search-console` greifen; ohne
diesen Schritt laeuft der Abruf gruen und die Seite bleibt leer. Nimmt optional
`window_days` (1–480, Vorgabe 90). Antwortet **202** mit `property_id`, `window_days` und
`batch_id` — letzteres ist `null`, solange noch nie abgeglichen wurde, denn der Stapel entsteht
im Hintergrund-Job. **422**, wenn die Property mit keinem Projekt verknuepft ist: dann ruft
Rankion gar keine Zahlen ab (`analytics:gsc-pull-due` waehlt nur verknuepfte Properties), und
die Auswertung waere leer. Derselbe Abgleich laeuft taeglich um 04:30 von selbst
(`analytics:gsc-rebuild-insights`, abschaltbar ueber `config/analytics.php`).

Die Zahlen liest man danach ueber `/search-console` und `/search-console/{id}`. Deren
`row_counts` traegt neben `queries`/`pages`/`daily` auch `queries_gesamt`, `pages_gesamt`,
`deckel` und `gedeckelt`: greift der Deckel, umfasst die Auswertung nur die
impressionsstaerksten Zeilen, und `gedeckelt` sagt genau das. `source` unterscheidet
`upload` von `gsc_api`.

`/google/ga4/properties/{p}/metrics` nimmt `?from=&to=&dimension=daily|page|event|ai_source|ai_source_traffic`. `ai_source` liefert die Sitzungen je KI-Quelle (`source` = Slug aus `App\Support\AiReferrerSources`: `chatgpt`, `perplexity`, `gemini`, `copilot`, `claude`, `grok`, `other_ai`; `other` = Summe aller uebrigen Quellen, der Nenner fuer den Anteil). `ai_source_traffic` (Karte #9248) liefert dieselben KI-Sitzungen je Quelle UND Verkehrsart: Zeilen `{source, traffic_type, sessions, engaged_sessions, conversions, revenue}` mit `traffic_type` = `organic`, `paid` oder `unknown` (bezahlt = GA4-Medium passt auf `^(.*cp.*|ppc|retargeting|paid.*)$`; `unknown` = Daten von vor der Trennung oder Medium `(not set)`). `other` fehlt hier; die Summe je Quelle entspricht `ai_source`.

---

## ⭐ Review Sources

| Method | URL |
|--------|-----|
| GET    | `/review-sources` · `/review-sources/{id}` |
| DELETE | `/review-sources/{id}` |
| POST   | `/review-sources/{id}/rotate-key` · `.../disconnect` · `.../reconnect` |
| POST   | `/review-sources/webhook` · `/review-sources/waitlist` |
| POST   | `/webhooks/reviews/{source}` *(public, key-auth)* |

---

## 📰 Blog (Admin)

| Method | URL |
|--------|-----|
| GET/POST | `/blog/categories` |
| PUT/DELETE | `/blog/categories/{id}` |
| GET/POST | `/blog/posts` |
| GET/PUT/DELETE | `/blog/posts/{id}` |

---

## 💾 Rankion OS

| Method | URL |
|--------|-----|
| PUT    | `/os/mode` |
| GET/POST | `/os/preferences` |
| GET/POST | `/os/files` |
| GET/PUT/DELETE | `/os/files/{id}` |
| POST   | `/os/files/{id}/move` · `.../share` |
| DELETE | `/os/files/{id}/share` |
| GET/POST | `/os/folders` |
| GET/PUT/DELETE | `/os/folders/{id}` |
| POST   | `/os/folders/{id}/move` |
| GET    | `/os/desktop` · `/os/spotlight` |

---

## 💳 Account / Meta

| Method | URL |
|--------|-----|
| GET    | `/team` |
| GET    | `/credits` · `/credits/history` |
| GET    | `/dashboard` |
| GET    | `/admin/api-costs` *(Admin only)* |
| GET    | `/admin/usage-analytics` | **Systemnutzung** — Aufrufe je Funktion (Quelle KI-Aufrufe und Quelle Credit-Buchungen **getrennt** ausgewiesen, nicht addiert), Tokenverbrauch je Nutzer (Summe/Ø/Max/Min/p50/p95, Top 50) und Kennzahlen (aktive Nutzer, Fehlerquote, Ø Tokens je Aufruf, Cache-Anteil, Top- und nie genutzte Funktionen) und seit Plan #207 `module_usage`: Aufrufe je **Modul**, absteigend, je Posten mit seinen Funktionskennungen (`{modul, label, aufrufe, kennungen:{kennung:n}}`); `modul: null` ist der Posten »Nicht zugeordnet« (Kennungen ohne Modul, etwa Gap oder Backlinks). Query: `from`, `to`, `granularity=woche\|monat\|jahr`, seit Plan #86 `hide_admins` (boolean, **Vorgabe an**): rechnet die eigenen Admin-Konten (`users.is_admin`) aus ALLEN vier Bloecken heraus, damit die Zahlen die Kundennutzung zeigen. Zeilen ohne Nutzerbezug (Zeitplaner, Systemjobs) bleiben enthalten — sie koennen per Definition nicht von einem Admin-Konto stammen. Der Stand steht in `meta.hide_admins`, damit eine Zahl nicht ohne die Angabe gelesen wird, WELCHE Menge sie zaehlt. Nur Admins, sonst 403. **Grenzen:** kostenlose Aktionen erzeugen keinen Eintrag; Funktionskennungen werden erst seit dem 24.08.2026 mitgeschrieben. Seit Karte #7818 blaetterbar: `page` und `per_page` (1-200). **Ohne `per_page` ist die Antwort unveraendert** (200 Zeilen je Funktionsnutzungs-Block, 50 Nutzer, alle Module); mit Seitenangabe nennt `meta.total` die Gesamtzahl je Block. Die Obergrenze je Block liegt bei 2000 Zeilen | — |
| GET    | `/admin/kpi-report` | **KPI-Monatsbericht** (Karte #8449) — dieselben Zahlen wie die Monatsmail an die Betriebsadressen, aus derselben Rechenstelle (`KpiMonatsberichtService::fuerMonat()`): `benutzer_gesamt`, `benutzer_aktiv`, `rechnungen`, `rechnungen_bezahlt`, `module` (Top 10 `{label, aufrufe}`, »Nicht zugeordnet« bleibt sichtbar), dazu `monat`, `von`, `bis`. Query: `month=YYYY-MM` (Vorgabe: abgeschlossener Vormonat nach Berliner Uhr); ein laufender oder kuenftiger Monat ist 422. Kein Team-Scoping. Nur Admins, sonst 403. `meta.hide_admins` ist immer `true`, `meta.note` nennt die Grenzen der Zahlen | — |
| GET    | `/admin/online-users` *(Admin only)* — wer ist gerade angemeldet: Name, E-Mail, letzte Aktivitaet je Konto, zuletzt aktiv zuerst, je Konto GENAU EINE Zeile (Plan-Hub #70). Dieselbe Quelle und derselbe 60-s-Zwischenspeicher wie die Zahl auf `/admin`. Optional `window_minutes` (1–60, Vorgabe 5). Liefert `{data:[{id,name,email,last_activity_at,last_active_seconds_ago}], meta:{total,window_minutes,generated_at}}`. Nicht paginiert — die Menge ist durch das Zeitfenster begrenzt. **Keine IP-Adresse, kein User-Agent** (Datensparsamkeit). |
| GET    | `/admin/users/{user}/last-login` *(Admin only)* — wann sich ein Konto zuletzt angemeldet hat (Karte #8888, FB-169). Derselbe Wert und dieselbe Herleitung (`App\Support\LetzterLogin`) wie die Zeile »Letzter Login« auf `/admin/users/{user}`. Liefert `{data:{user_id, last_login_at, zustand, erfasst_seit}}`, Zeitstempel in UTC (ISO 8601). `zustand`: `erfasst` · `noch_nie_erfasst` (Konto älter als der Erfassungsbeginn `erfasst_seit`, seither kein Login — die Spalte ist rückwirkend leer) · `nie_angemeldet` (Konto nach dem Erfassungsbeginn angelegt, nie angemeldet). Eine Admin-Impersonation zählt nicht als Login. 404 bei unbekanntem Konto. Throttle 120/min. |
| GET    | `/admin/teams` *(Admin only)* — alle Teams, dieselbe Liste wie der Reiter »Teams« auf `/admin/users/teams` (K#11071 FB-211, Abfrage `App\Support\AdminTeamliste`). Query: `search` (Teamname, Besitzername oder Besitzer-E-Mail), `page`, `per_page` (Vorgabe 25, höchstens 100). Liefert `{data:[{id, name, owner:{id, name, email}\|null, plan, members_count, credit_balance:{milli, display, display_ceil, display_floor}, subscription_state, created_at}], meta:{total, page, per_page, last_page}}`. `subscription_state` aus `Team::aboZustand()`: `aktiv` · `laeuft_aus` (gekündigt, Enddatum in der Zukunft) · `inaktiv`. Sortierung: jüngstes Team zuerst. Nur Admins, sonst 403. |
| GET    | `/admin/subscription-users` *(Admin only)* — Benutzer mit laufendem Abo: Gesamtzahl und Summe je Plan (Karte #8663, FB-166). **Dieselbe Rechenstelle** wie die Kachel »Benutzer gesamt« auf `/admin` (`AdminDashboardService::aboBenutzerZahlen()`) — es gibt keinen zweiten Abo-Begriff. Liefert `{data:{total, by_plan:[{plan, label, users}]}, meta:{counts, running, excluded_team_ids, note}}`; `meta` nennt die Zaehlweise mit, weil eine Zahl ohne die Angabe ihrer Menge als Gesamtzahl gelesen wird. **»Laufend«** ist gegen `Subscription::BEENDETE_STATUS` gerechnet, nie gegen eine Lebendliste: `past_due`, `unpaid`, `paused` und `incomplete` zaehlen MIT, weil der Kunde zahlt oder zu zahlen versucht. **Abo-Benutzer** heisst: Mitglied mindestens eines Teams mit laufender Abo-Zeile, und Mitglied heisst `teams.owner_id` ODER eine Zeile in `team_user` (`User::isMemberOf()`) — der Besitzer bekommt die Pivot-Zeile nicht garantiert. Team 26 (das Team des Eigentuemers) ist ausgenommen; `calculateMrr()` und `getConversionFunnel()` im selben Dienst nehmen es NICHT aus. Ein Benutzer in zwei Teams mit VERSCHIEDENEN Plaenen zaehlt in BEIDEN Plan-Posten, die Summe der Posten kann deshalb groesser sein als `total`; ein Plan ohne laufendes Abo erscheint nicht. **Nicht paginiert** und **nicht vergleichbar** mit der Abo-Anzeige auf `/admin/users/{id}`: die rechnet enger (`status IN (active, trialing)` UND `ends_at IS NULL`) und beantwortet eine andere Frage — welche Zeile eine Kuendigung oder deren Ruecknahme treffen wuerde. Ohne Zwischenspeicher, drei Abfragen je Aufruf bei einem belegten Plan. Throttle 60/min mit eigenem Zaehler `admin-subscription-users`; die Route steht deshalb ausserhalb der `v1/admin`-Gruppe — ein schlichtes `throttle:N,M` zaehlt sonst je Nutzer in denselben Topf. |
| GET    | `/admin/system-settings` *(Admin only)* — die zwei Inaktivitaetsgrenzen des Auto-Logouts in Minuten (Plan-Hub #96) und der Zielbereich der Bruttomarge in Prozent (Karte #9385), dieselbe Quelle wie `/admin/systemeinstellungen`. Liefert `{data:{inactivity_timeout_user_minutes, inactivity_timeout_admin_minutes, zielmarge_von_prozent, zielmarge_bis_prozent}, meta:{min_minutes, max_minutes, min_prozent, max_prozent, defaults:{…}}}`. Vorgaben 60, 15 und 70 bis 80. |
| PUT    | `/admin/system-settings` *(Admin only)* — setzt einen oder mehrere Werte (Body `inactivity_timeout_user_minutes`, `inactivity_timeout_admin_minutes`, je 1–1440, integer; `zielmarge_von_prozent`, `zielmarge_bis_prozent`, je 0–100, integer, von < bis — geprueft wird das Paar aus geschicktem und gespeichertem Wert, Verstoss → 422 auf `zielmarge_von_prozent`). Partiell: ein fehlendes Feld bleibt unveraendert; leerer Body → 422. Wirkt sofort. Antwort wie GET. Throttle 120/min. |
| POST   | `/admin/users/{user}/cancel-subscription` *(Admin only)* — das Abo eines Teams zum Periodenende beenden (Karte #8219, Punkt #4893). Body `{team_id}` (Pflicht, integer, muss existieren). Der Zwilling der Adminansicht `POST /admin/users/{user}/cancel-subscription` — beide rufen DIESELBE Dienstmethode `StripeService::cancelAtPeriodEnd()`, es gibt genau eine Kündigungslogik. Der Kunde behält seinen Zugang bis zum bezahlten Periodenende; `teams.credit_balance` bleibt unberührt. → 200 `{data:{team_id, team_name, canceled, ends_at, abo_zustand}}`; `abo_zustand` ist `inaktiv`/`aktiv`/`laeuft_aus` aus derselben Herleitung wie Adminansicht und Billing-Dashboard (`Team::aboZustand()`). **Kein laufendes Abo ist KEIN Fehler:** 200 mit `canceled: false` — der Zustand, den der Aufrufer herstellen wollte, liegt bereits vor. `422 team_not_owned`, wenn das Team dem adressierten Benutzer nicht gehört (gilt auch für einen Admin). Jede wirksame Kündigung schreibt `user.subscription_canceled` ins Admin-Protokoll, mit `via: api`. Throttle 10/min mit eigenem Zähler — getrennt von der Adminansicht, damit ein Skript den Menschen im Browser nicht aussperrt. |
| POST   | `/admin/users/{user}/revoke-subscription-cancellation` *(Admin only)* — eine laufende Kündigung zurücknehmen (Karte #8219, Punkt #4890). Body `{team_id}` (Pflicht, integer, muss existieren). **Eigene Adresse statt eines Schalters an der Kündigung:** ein `revoke: true` versteckte die entgegengesetzte Handlung hinter derselben Adresse, und ein vergessener Schalter kündigte dann, wo zurückgenommen werden sollte. Zurücknehmbar ist **nur** der Zustand `laeuft_aus` — Status `active`/`trialing` MIT `ends_at` in der Zukunft. → 200 `{data:{team_id, team_name, revoked, grund, abo_zustand}}`. **`grund` ist das Feld, das trägt**, denn `revoked: false` allein kann vier Lagen nicht unterscheiden: `zurueckgenommen` · `nie_gekuendigt` · `periode_abgelaufen` (das Enddatum liegt in der Vergangenheit — eine Rücknahme wäre ein Abo ohne Zahlung) · `bereits_beendet` (die Beendigung ist vollzogen: beim Stripe-Weg durch `customer.subscription.deleted`, bei einem Abo auf Rechnung durch `billing:renew-invoice-subscriptions`, wo der Tarif nur zurückfällt, wenn kein weiteres Abo des Teams mehr läuft). **Kein `ends_at` im Umschlag** — es wäre in jeder Lage `null` und trüge keine Information. **Nichts zurückzunehmen ist KEIN Fehler:** 200 mit `revoked: false` plus Grund. `422 team_not_owned` wie bei der Kündigung. **`502 stripe_holds_cancellation`**, wenn Stripe die Änderung nicht anwendet und die Kündigung dort weiter führt; das lokale Enddatum wird dann auf Stripes Stand gezogen, nicht geleert. Protokolliert als `user.subscription_cancellation_revoked` — eigene Aktionskennung, damit im Protokoll nicht zwei entgegengesetzte Handlungen denselben Namen tragen. Throttle 10/min unter dem eigenen Zähler `admin-revoke-cancellation-api`, **getrennt von der Kündigung**: sonst könnte, wer zehnmal kündigt, nichts mehr zurücknehmen. Die Route steht deshalb außerhalb der `v1/admin`-Gruppe — Route-Middleware addiert sich zur Gruppen-Middleware, sie ersetzt sie nicht. |
| GET    | `/admin/kalkulation` *(Admin only)* — Kalkulation (Plan-Hub #100): dieselben Zahlen wie `/admin/kalkulation` im Browser. Optional `days` (1–365, Vorgabe 30). Liefert `{data:{kopf:{kosten_usd,einnahmen_usd,deckungsbeitrag_usd,faktor,bruttomarge_prozent,referenz_prozent:[von,bis]}, zeitreihe:[{tag,kosten_usd,einnahmen_usd}], plaene:[{plan,credits_pro_monat,preis_je_credit_eur_monatlich,preis_je_credit_eur_jaehrlich,kosten_je_credit_usd,deckungsbeitrag_je_credit_usd,faktor,je_monat:{einnahmen_eur,kosten_usd,deckungsbeitrag_usd}}], nachkauf:[{paket,credits,preis_je_credit_eur}], ehrlichkeit:{zeilen_gesamt,zeilen_echt,anteil_echt_prozent}}, meta:{days,from,to,wechselkurs:{eur_usd,stand},generated_at}}`. Kosten in USD (Anbieter rechnen in USD), Preise in EUR; Umrechnung mit dem benannten Kurs aus `config/pricing.php`. |
| GET    | `/admin/promo-codes` *(Admin only)* — Liste aller Promo-Codes mit Status (aktiv/abgelaufen/ausgeschöpft/deaktiviert) + fertiger `register_url` (`{app-url}/register?promo=CODE`) |
| GET    | `/admin/promo-codes/redemptions` *(Admin only)* — Einlösungen, filterbar per `year`/`month`/`day`/`per_page` |
| GET    | `/admin/job-postings` *(Admin only)* — alle Stellenanzeigen der Karriereseite, dieselbe Liste wie `/admin/job-postings` im Browser (26.10.2026, siehe [job-postings.md](modules/job-postings.md)). Liefert `{data:[{id, title, status, location, employment_type, description, apply_email, sort_order, published_at, updated_at}]}`; `status` ist `entwurf` (published_at leer), `geplant` (in der Zukunft) oder `veroeffentlicht`. **Entwürfe sind enthalten** — der Endpunkt spiegelt die Verwaltung, nicht die öffentliche Seite, die nur veröffentlichte Stellen zeigt. `apply_email` ist bereits aufgelöst (eigene Adresse der Stelle, sonst `mail.contact_to`). `employment_type` ist einer der Schlüssel aus `JobPosting::EMPLOYMENT_TYPES` (`full_time`, `part_time`, `full_or_part`). Reihenfolge wie die Seite: `sort_order` aufsteigend, dann jüngste zuerst. Nicht paginiert. Nur Admins, sonst 403. |
| GET    | `/admin/deleted-tokens` *(Admin only)* — Token-Löschprotokoll (K#10969, FB-189, siehe [api-token-loeschprotokoll.md](modules/api-token-loeschprotokoll.md)): jede Löschung eines API-Tokens mit Token-Name, Besitzer, Löscher und Zeitpunkt, dieselbe Liste wie »Gelöschte Tokens anzeigen« auf `/admin/feedback`. Filter `owner_id`, `deleted_by_id`, `path` (`einzelwiderruf`, `logout`, `konto_abmeldung`, `konto_selbst_geloescht`, `admin_konto_geloescht`, `befehl`, `unbenannt`; anderes = 422), `per_page` (1–100, Standard 25), `page`. Liefert `{data:[{id, token_id, token_name, abilities, owner_id, owner_email, owner_name, deleted_by_type (nutzer/system), deleted_by_id, deleted_by_email, deleted_by_name, impersonated_by_id (Admin hinter einer Impersonation, sonst null), path, caller, deleted_at, token_created_at, last_used_at, expires_at}], meta:{total, per_page, current_page, last_page}}`, neueste zuerst, Zeiten UTC. Das Token-Geheimnis und sein Hash werden nie gespeichert. Nur Admins, sonst 403. |
| GET    | `/admin/modules` *(Admin only)* — alle Module + plattformweiter Admin-Sperrzustand (Plan-Hub #41, siehe [module-store.md](modules/module-store.md)) |
| POST   | `/admin/modules/{module}/state` *(Admin only)* — Sperrzustand setzen (`state`: `active`/`disabled`/`hidden`; 422 Kernmodul/unbekannt) |
| GET    | `/admin/invoices` *(Admin only)* — die Rechnungsliste von `/admin/invoices` (Karte #7417). Filter wie die Oberfläche (`InvoiceService::adminListe()`): `status` (all\|pending\|paid\|cancelled\|uncollectible; unbekannt = alle), `search` (Teamname oder Rechnungs-ID), `page`, `per_page` (max. 100), seit K#11216 `from` und `to` (Rechnungsdatum `created_at`, `YYYY-MM-DD`, Tagesgrenzen in UTC, beide Tage eingeschlossen), `sort` (created_at\|invoice_number\|team\|plan\|amount\|status\|due_date\|paid_at) und `direction` (asc\|desc). Ohne Parameter bleibt es bei allen Rechnungen, neueste zuerst; die Vorgabe »Offen« hat nur die Oberfläche. Ein unbekannter Wert in `from`, `to`, `sort` oder `direction` und `to` vor `from` ergeben 422. → 200 `{data:[{id, invoice_number, team_id, team_name, owner_user_id, owner_name, abo_zustand, status, amount, currency, plan, billing_cycle, source (stripe\|invoice), stripe_invoice_id, stripe_invoice_number, stripe_pdf_url, invoice_pdf_url, paid_at, due_date, cancelled_at, cancellation_reason, credit_note_number, credit_note_pdf_url}], meta:{total, current_page, last_page, per_page}}`. `stripe_pdf_url` nur als https-Link einer Stripe-Rechnung — der lokale PDF-Pfad des Rechnungskaufs geht nie raus. `invoice_pdf_url` (K#11314) ist der Link hinter »PDF anzeigen« auf `/admin/invoices` — die Admin-Route `/admin/invoices/{invoice}/pdf` (Stripe-Weiterleitung oder lokale Datei), `null`, wenn kein PDF vorliegt. `owner_user_id`/`owner_name` sind der Team-Besitzer (Karte #8832) — dasselbe Sprungziel, das die Oberfläche in der Spalte »Team« verlinkt (`/admin/users/{owner_user_id}`); die E-Mail-Adresse des Besitzers steht bewusst NICHT in der Liste (Datensparsamkeit), sie kommt aus `/admin/users/{user}`. `abo_zustand` (K#11069, FB-209) ist `aktiv` \| `laeuft_aus` \| `inaktiv` aus `Team::aboZustand()` — dieselbe Herleitung wie die Abo-Zeile der Benutzerakte; Probezeit zählt als `aktiv`, ein Team nur mit Guthaben ist `inaktiv`, ohne Team `null`. `paid_amount`: eingegangene Cent brutto, `null` heißt der volle Betrag; `lexware_balanced_without_payment_at`: in Lexware ausgeglichen, Geldeingang nicht belegt (K#11200). Throttle 60/min mit eigenem Zähler (teilt ihn nicht mit dem Storno). |
| GET    | `/admin/incidents` *(Admin only)* — das Vorfallregister von `/admin/incidents` (Karte #7432): ALLE Meldungen, auch die öffentlichen ohne Team (`GET /incidents` zeigt nur das eigene Team). `filter` = `unbearbeitet` (Vorgabe) \| `alle`, `per_page` (max. 100). → 200 `{data:[{id, case_ref, category, title, description, affected_feature, occurred_at, severity, severity_rationale, gdpr_art33_relevant, processing_status, source, reporter_email, reporter_name, created_at, team_id, resolved_at, resolved_by_user_id}], meta:{total, per_page, current_page, last_page, unbearbeitet}}`. `meta.unbearbeitet` ist dieselbe Zahl wie der Zähler am Menüpunkt. Throttle 60/min mit eigenem Zähler. |
| POST   | `/admin/incidents/{id}/resolved` *(Admin only)* — Meldung als bearbeitet markieren (`{resolved: true}`, setzt `resolved_at` und `resolved_by_user_id` auf den Aufrufer; eine schon bearbeitete behält ihren ersten Bearbeiter) oder wieder öffnen (`{resolved: false}`). → 200 `{data:{…}}` wie oben. |
| GET    | `/admin/errors` *(Admin only)* — das Fehlerprotokoll (Karte #10392, siehe [admin-fehlerprotokoll.md](modules/admin-fehlerprotokoll.md)): je Fingerabdruck (Ausnahmeklasse + Datei + Zeile) ein Eintrag mit Zähler, erstem und letztem Auftreten, maskierter Meldung und Trace. `status` = `open` (Vorgabe) \| `resolved` \| `all`, `since` (filtert `last_seen_at`), `page`, `per_page` (max. 100). → 200 `{data:[{id, fingerprint, exception_class, file, line, message, trace, occurrences, first_seen_at, last_seen_at, status, resolved_at}], meta:{total, per_page, current_page, last_page, open}}`. Erreichbar mit Admin-Session, Vollzugriffs-Token eines Admins oder einem Token mit genau dem Recht `admin:errors` (öffnet nur diese Gruppe). Kunden-Token und ein anderes eingeschränktes Recht → 403. Throttle 60/min mit eigenem Zähler. |
| POST   | `/admin/errors/{id}/resolved` *(Admin only)* — Fehler als erledigt setzen (Body optional `{resolved: true}`, Vorgabe) oder wieder öffnen (`{resolved: false}`). Tritt ein erledigter Fehler erneut auf, steht er von selbst wieder auf `open`. → 200 `{data:{…}}` wie oben, 404 bei unbekannter ID. |
| GET    | `/admin/invoices/open-count` *(Admin only)* — Zahl der offenen Rechnungen (Plan #97). Dieselbe Herleitung wie die Kachel »N offen« auf `/admin/invoices` und der Zähler am Menüpunkt: `Invoice::STATUSES_OPEN` (`pending`, `open`) — die EINE Statusliste, weil `status` eine freie String-Spalte ist, in die auch Stripe-Rohwerte durchgereicht werden. → 200 `{count, statuses}`. Bewusster Unterschied zu `/billing/invoices/overdue`: hier stehen ALLE offenen Rechnungen (Arbeitsvorrat), dort nur die überfälligen. Throttle 20/min. |
| POST   | `/admin/invoices/{invoice}/cancel` *(Admin only)* — Rechnung ueber die Lexware-API stornieren (Plan-Hub #62). Body `{reason}` (Pflicht, 3–500 Zeichen) — geht in den Belegtext der Stornorechnung und ins Admin-Protokoll. Erzeugt eine mit der Rechnung VERKNUEPFTE, finalisierte Gutschrift (`POST /v1/credit-notes?finalize=true&precedingSalesVoucherId=…`); die Public API hat keinen Void-Endpoint. Reine Buchhaltung: Credits, Plan und Abo bleiben unangetastet. Idempotent — ein zweiter Aufruf erzeugt keinen zweiten Beleg. `422 not_cancellable` (bezahlt, bereits storniert oder ohne Lexware-Verknuepfung), `502 lexware_error` (Lexware lehnt ab, z. B. Beleg nicht `open` — es entsteht dann KEIN Beleg). Antwort enthaelt `credit_note_number` und `credit_note_pdf_url`. Throttle 20/min. |
| POST   | `/admin/invoices/{invoice}/dunnings` *(Admin only)* — eine foermliche Mahnung erzeugen und versenden (Karte K#8834). Kein Body. Legt einen ECHTEN Mahnbeleg im Lexware-Produktivkonto an (`POST /v1/dunnings?precedingSalesVoucherId=…`, immer ein Entwurf — `finalize` gibt es fuer Mahnungen nicht) und schickt dessen PDF an die Rechnungsadresse des Teams. **Die Stufe laesst sich NICHT setzen** — sie kommt aus dem Bestand (hoechste vorhandene plus eins), sonst entstuende eine Luecke oder ein Sprung in der Kette. Idempotent je Rechnung UND Stufe (Unique auf `invoice_id, level`). Abzugrenzen von der automatischen Zahlungserinnerung (Stufe 0, taeglich, ohne Beleg und ohne PDF): diese hier geht nur hinaus, weil ein Mensch sie ausgeloest hat. → 201 `{data:{id, invoice_id, level, lexware_dunning_id, voucher_number, has_pdf, pdf_download_url, sent_at, sent_to, triggered_by_user_id, created_at}, meta:{mail_sent}}`. `422 not_dunnable` (storniert, nicht offen oder ohne Lexware-Beleg), `502 lexware_error` (Lexware lehnt ab — HTTP 406 heisst: keine weitere Mahnstufe zulaessig), `409 dunning_in_progress` (zu dieser Rechnung legt gerade ein anderer Aufruf eine Mahnung an — es entsteht nichts, ein spaeterer Versuch zieht die richtige Stufe). `mail_sent: false` bei angelegtem Beleg und gescheitertem Versand; `sent_at` ist dann null und der Beleg bleibt gueltig. Throttle 20/min, geteilt mit dem Storno. |
| GET    | `/admin/invoices/{invoice}/dunnings` *(Admin only)* — die Mahnhistorie einer Rechnung (Karte K#8834), aufsteigend nach Stufe. → 200 `{data:[{id, invoice_id, level, lexware_dunning_id, voucher_number, has_pdf, pdf_download_url, sent_at, sent_to, triggered_by_user_id, created_at}], meta:{invoice_id, invoice_number, total, next_level, blocked_reason}}`. `next_level` ist die Stufe, die ein POST als naechstes anlegen wuerde; `blocked_reason` nennt im Klartext, warum nicht gemahnt werden kann, und ist `null`, wenn es geht. Der rohe PDF-Pfad geht nie raus (interner Storage-Pfad) — stattdessen `has_pdf` und `pdf_download_url`, die Admin-Route `GET /admin/invoices/{invoice}/dunnings/{dunning}/pdf` (Web, Admin-Sitzung; `null` ohne PDF; Karte K#9388). Throttle 60/min mit eigenem Zaehler (teilt ihn nicht mit dem Mahn-POST). |
| POST   | `/admin/invoices/{invoice}/payment-reminders` *(Admin only)* — eine Zahlungserinnerung per Mail an die Rechnungsadresse senden (Karte K#10963). Kein Body. Nennt Rechnungsnummer, Betrag, Faelligkeit und Zahlweg; kein Lexware-Beleg, keine foermliche Mahnung, nie automatisch. Die Rechnung wird serverseitig frisch gelesen. → 201 `{data:{id, invoice_id, sent_at, sent_to, sent_by_user_id, sent_by_name}}`. `422 not_remindable` (bezahlt, storniert, nicht offen, keine oder gesperrte Rechnungsadresse). Jeder Versand steht in `admin_audit_log`. Throttle 10/min mit eigenem Zaehler. |
| GET    | `/admin/invoices/{invoice}/payment-reminders` *(Admin only)* — die gesendeten Zahlungserinnerungen einer Rechnung (Karte K#10963), aelteste zuerst. → 200 `{data:[{id, invoice_id, sent_at, sent_to, sent_by_user_id, sent_by_name}], meta:{invoice_id, invoice_number, total, blocked_reason}}`. `blocked_reason` ist `null`, wenn eine Erinnerung moeglich ist. Throttle 60/min mit eigenem Zaehler. |
| GET    | `/admin/invoices/{invoice}/payment-reminders/{reminder}/mail` *(Admin only)* — der Inhalt einer gesendeten Zahlungserinnerung (Karte K#11030). Die Mail wird nicht gespeichert, sondern mit derselben Vorlage in der Sprache des Versands neu erzeugt, aus den heutigen Rechnungsdaten (`regenerated: true`). → 200 `{data:{id, invoice_id, sent_at, sent_to, bcc, sent_by_user_id, sent_by_name, subject, locale, html, regenerated}}`. 404, wenn der Eintrag nicht zu dieser Rechnung gehoert oder Rechnung/Team fehlen. Throttle 60/min, Zaehler der Liste. |
| GET    | `/admin/accounting/invoices` *(Admin only)* — Eingangsrechnungen, Liste von `/admin/accounting` (K#11102). Filter `period` (YYYY-MM), `state` (complete\|incomplete\|unclear\|link_only\|sent), `page`, `per_page` (max. 100). → 200 `{data:[{id, provider, api_key_id, period, invoice_number, invoice_date, net_cents, tax_cents, gross_cents, currency, source, format, check_status, state, state_reason, portal_url, mailbox_message_id, received_at, sent_at, has_file, is_reading_copy_present}], meta:{total, current_page, last_page, per_page}}`. Interne Dateipfade gehen nie raus. Throttle 60/min. |
| POST   | `/admin/accounting/invoices` *(Admin only)* — Rechnung hochladen, multipart: `file` (PDF, XML, ZIP, bis 20 MB, serverseitig am Inhalt geprüft), `provider`, `period`. Ein ZIP wird eine Ebene tief entpackt, jede Datei eine Zeile. → 201 `{data:[…], meta:{duplicates, skipped}}`. `409` mit `existing_id` bei gleicher Datei, `422` bei falschem Typ oder zu groß. Throttle 20/min. |
| GET    | `/admin/accounting/invoices/{providerInvoice}` *(Admin only)* — Ein Beleg, Felder wie in der Liste. Throttle 60/min. |
| PUT    | `/admin/accounting/invoices/{providerInvoice}` *(Admin only)* — Felder von Hand nachtragen: `provider`, `period`, `invoice_number`, `invoice_date`, `gross_cents`, `net_cents`, `tax_cents`, `currency`. Fehlt danach nichts mehr, wird der Beleg vollständig. `422` bei einem versendeten Beleg. Throttle 20/min. |
| DELETE | `/admin/accounting/invoices/{providerInvoice}` *(Admin only)* — Beleg samt Datei löschen, nur von Hand, mit Eintrag in `admin_audit_log`. Es gibt keine automatische Löschung (Aufbewahrung). `422` bei einem versendeten Beleg. Throttle 20/min. |
| GET    | `/admin/accounting/invoices/{providerInvoice}/check-log` *(Admin only)* — Prüfprotokoll: `{data:{id, format, check_status, hint, lines:[{field, label, norm, ok}]}}`. `check_status`: valid, invalid, not_structured, not_checked. Throttle 60/min. |
| POST   | `/admin/accounting/invoices/{providerInvoice}/file` *(Admin only)* — Portal-Rechnung auf eine Nur-Link-Zeile hochladen, multipart `file` (PDF oder XML). Anbieter, Periode und Herkunftsmail bleiben, der Zustand wechselt auf `complete`. Throttle 20/min. |
| GET    | `/admin/accounting/inbox` *(Admin only)* — Posteingang: Protokollzeilen `{id, sender, subject, received_at, state, reason, folder}`. Filter `state` (processed\|unclear\|blocked\|spam\|foreign), ohne Filter alles außer Spam; `page`, `per_page`. Throttle 60/min. |
| GET    | `/admin/accounting/inbox/{message}/content` *(Admin only)* — Text und Anhangsliste einer Mail, in diesem Moment aus dem Postfach gelesen und nicht gespeichert. → 200 `{data:{text, attachments:[{name, bytes}]}}`, `503` wenn das Postfach nicht erreichbar ist. Throttle 10/min. |
| POST   | `/admin/accounting/inbox/{message}/forward` *(Admin only)* — Mail weiterleiten, Body `to`. Wird in `mailbox_forwards` protokolliert. Throttle 10/min. |
| POST   | `/admin/accounting/inbox/{message}/spam` *(Admin only)* — Als Spam kennzeichnen und in den Spam-Ordner verschieben. Legt keine Sperre an. Throttle 10/min. |
| POST   | `/admin/accounting/inbox/{message}/foreign` *(Admin only)* — Als Fremdpost einstufen. Throttle 10/min. |
| POST   | `/admin/accounting/inbox/{message}/block-sender` *(Admin only)* — Absender sperren. Throttle 10/min. |
| POST   | `/admin/accounting/inbox/{message}/confirm-invoice` *(Admin only)* — »Ist doch eine Rechnung«: legt Anhänge oder die Mail als `.eml` ab. → 201 `{data:[…]}`. Throttle 10/min. |
| GET    | `/admin/accounting/completeness` *(Admin only)* — Soll je Anbieter mit aktivem Schlüssel: `{data:{months:[…], matrix:{<provider>:{<YYYY-MM>: ok\|gap\|missing}}}}`. Throttle 60/min. |
| GET    | `/admin/accounting/rules` *(Admin only)* — Absenderregeln `{id, pattern, match_type, action, forward_to}`. Throttle 60/min. |
| POST   | `/admin/accounting/rules` *(Admin only)* — Regel anlegen oder ändern: `pattern` (Adresse oder Domain), `action` (forward\|block\|spam), `forward_to` (Pflicht bei forward). Throttle 20/min. |
| DELETE | `/admin/accounting/rules/{rule}` *(Admin only)* — Regel löschen. Throttle 20/min. |
| GET    | `/admin/accounting/settings` *(Admin only)* — Einstellungen `{interval, cutoff_day, recipients, forward_to, retention_days}`. Throttle 60/min. |
| PUT    | `/admin/accounting/settings` *(Admin only)* — Dieselben Felder setzen: `interval` (15, 30, 60, 1440), `cutoff_day` (1 bis 28 oder last), `recipients` (Text, eine Adresse je Zeile), `forward_to`, `retention_days`. Wirkt ohne Deploy. Throttle 20/min. |
| GET    | `/admin/accounting/runs` *(Admin only)* — Die letzten 50 Läufe `{id, type, period, processing_status, processing_started_at, processing_completed_at, processing_error, stats}`, Filter `type` (fetch\|dispatch). Throttle 60/min. |
| POST   | `/admin/accounting/fetch` *(Admin only)* — Postfach jetzt abrufen. → 202 `{data:{status:'queued'}}`, sonst 200 mit `running` oder `not_configured`. Throttle 10/min. |
| POST   | `/admin/accounting/dispatch` *(Admin only)* — Sammelmail für `period` (Vorgabe: der abgeschlossene Vormonat). → 202 `queued`, sonst 200 mit `already_sent`, `running`, `no_recipients` oder `nothing`. Einmal je Periode. Throttle 10/min. |
| GET    | `/admin/api-keys` *(Admin only)* — Inventar der Zugangsschlüssel von `/admin/api-keys` (K#11101). Der Wert verlässt den Server nie, nicht einmal teilweise (`has_value` statt Zeichen). Filter `state` (overdue\|due\|fresh\|no_date), `provider`, `source` (manual\|config\|database). → 200 `{data:[{id, provider, source, env_name, key_name, owner, issued_at, expires_at, rotation_interval_days, last_rotated_at, notes, is_active, has_value, due_on, days_until_due, state, updated_at}], meta:{total, thresholds, due_window_days}}`. `due_on` = das frühere von Ablauf und geplanter Rollierung. Throttle 60/min. |
| GET    | `/admin/api-keys/{apiKey}` *(Admin only)* — ein Zugang, Felder wie in der Liste. 404 bei unbekannter Id. Throttle 60/min. |
| PUT    | `/admin/api-keys/{apiKey}` *(Admin only)* — Metadaten ändern: `key_name`, `owner`, `issued_at`, `expires_at`, `rotation_interval_days` (1–3650), `last_rotated_at`, `notes`, `is_active`; jedes Feld optional. Der Wert ist hier NICHT schreibbar (Eigentümer-Entscheid 30.10.2026: er bleibt in der `.env`), ein mitgeschicktes `key_value` wird ignoriert. Jede Änderung steht in `admin_audit_log` (`api_key_updated`). → 200 `{data:{…}}`, 422 bei ungültigem Datum. Throttle 20/min. |
| GET    | `/admin/payment-reminder-settings` *(Admin only)* — die BCC-Adresse der Zahlungserinnerung (Karte K#11030), dieselbe Einstellung wie unter Buchhaltung › Einstellungen (`/admin/invoices?tab=einstellungen`). → 200 `{data:{bcc_email}}`, `null` = keine Blindkopie. Throttle 60/min. |
| PUT    | `/admin/payment-reminder-settings` *(Admin only)* — Body `{bcc_email: string|null}` (Pflichtfeld, gueltige Adresse oder `null`/leer = keine Blindkopie). → 200 wie GET; 422 bei ungueltiger Adresse oder fehlendem Feld. Throttle 60/min. |
| POST   | `/admin/teams/{team}/invoices/{invoice}/release` *(Admin only)* — eine Teamrechnung mit `status: lexware_unklar`, deren Beleg es in Lexware NICHT gibt: Reservierung zuruecknehmen (Zeile weg, Credits wieder berechenbar). Kein Body. → 200 `{data:{released: true}}`; `422 not_unclear` fuer jede andere Rechnung. Throttle 10/min. |
| POST   | `/admin/teams/{team}/invoices/{invoice}/attach` *(Admin only)* — dieselbe Lage, aber der Beleg EXISTIERT in Lexware: Body `{lexware_invoice_id}` (UUID). Die ID wird erst nach erfolgreichem `GET /v1/invoices/{id}` geschrieben; danach `status: pending`, Nummer und PDF best effort. → 200 `{data}`; `422 not_unclear` (falsche Rechnung, ID-Format, ID schon verknuepft), `502 lexware_error`. Throttle 10/min. |
| GET    | `/admin/teams/{team}/enterprise` *(Admin only)* — der nicht oeffentliche Tarif Enterprise je Team (K#11074, FB-214, siehe [billing.md](modules/billing.md)). → 200 `{data:{team_id, plan, enterprise, postpaid_active, credit_limit_cents, unbilled_milli, unbilled_cents, headroom_milli, credit_balance_milli, net_cents_per_1000_credits}}`. `unbilled_*` ist der noch nicht berechnete Verbrauch, genau das, was die naechste Teamrechnung (`/admin/teams/{team}/invoice-draft`) berechnen wuerde; `credit_limit_cents` ist NETTO. Throttle 60/min. |
| PUT    | `/admin/teams/{team}/enterprise` *(Admin only)* — Team auf Enterprise stellen oder das Kreditlimit aendern. Body `{credit_limit_cents}` (ganze Cent netto, 1 bis `pricing.enterprise.max_credit_limit_cents`). Das Team darf danach ueber sein Guthaben hinaus verbrauchen, bis der noch nicht berechnete Verbrauch das Limit erreicht; dann stoppt jeder Verbrauch wie bei leerem Guthaben (402 `insufficient_credits`). Protokoll `team.enterprise_enabled`. → 200 wie GET; 422 bei ungueltigem Limit. Throttle 60/min. |
| DELETE | `/admin/teams/{team}/enterprise` *(Admin only)* — Enterprise beenden. Body `{plan}` (ein oeffentlicher Tarif: free, starter, pro, agency). Das Limit wird geloescht; ein negatives Guthaben bleibt stehen und wird ueber die Teamrechnung berechnet. Protokoll `team.enterprise_disabled`. → 200 wie GET; 422 bei unbekanntem Tarif. Throttle 60/min. |
| GET    | `/admin/invoice-articles` *(Admin only)* — der Artikelkatalog fuer Rechnungen je Team (K#11072, FB-212, siehe [billing.md](modules/billing.md)), ausgeschaltete eingeschlossen. → 200 `{data:[{id, name, description, unit_name, unit_price_cents, is_active, sort_order}]}`. `unit_price_cents` ist NETTO. Throttle 60/min. |
| POST   | `/admin/invoice-articles` *(Admin only)* — Artikel anlegen. Body `{name (max 150), description?, unit_name (max 30), unit_price_cents (netto, ganze Cent), is_active?, sort_order?}`. → 201 `{data}`; 422 bei ungueltigen Feldern. |
| PUT    | `/admin/invoice-articles/{article}` *(Admin only)* — Artikel aendern, jedes Feld optional; `is_active: false` schaltet ihn aus (geloescht wird nichts). → 200 `{data}`. |
| GET    | `/admin/teams/{team}/invoice-draft` *(Admin only)* — Entwurf einer Rechnung je Team. Query `period_from`, `period_to` (Y-m-d; ohne Angabe der Vormonat). → 200 `{data:{period_from, period_to, credits:{consumed_milli, consumed_credits, billable_milli, billable_credits, paid_milli, uncovered_milli, billed_before_milli, transactions, upto_id, net_cents_per_1000_credits}, billing_profile:{exists, complete, missing, name, address, vat_id, tax_treatment, lexware_contact_id}, lines:[{type (article\|credits\|custom), article_id, name, description, quantity, unit_name, unit_price_cents}], totals:{net_cents, tax_cents, gross_cents, tax_rate, line_net_cents}, articles}}`. Der Verbrauch sind die noch nicht berechneten Buchungen `deduction` und `refund` des Zeitraums; die Credit-Position bepreist nur `billable_milli`, den Teil, den weder gekauftes Guthaben noch Abo-Kontingent deckt. |
| POST   | `/admin/teams/{team}/invoice-draft/preview` *(Admin only)* — Summen geaenderter Positionen, nichts wird gespeichert. Body `{lines}` wie im Entwurf. → 200 `{data:{lines, totals, tax_treatment}}`; 422 bei ungueltigen Positionen, zweiter Credit-Position oder Summe 0. |
| POST   | `/admin/teams/{team}/invoices` *(Admin only)* — legt die Rechnung ueber die Lexware-API an (ECHTER, finalisierter Beleg). Body `{idempotency_key (16–64 Zeichen, je Entwurf eine UUID), period_from, period_to, credits_upto_id und credits_billable_milli (beide aus dem Entwurf, Pflicht mit Credit-Position; weicht der Verbrauch inzwischen ab, `422 not_billable` — Entwurf neu laden), lines}`. Bleibt die Lexware-Antwort unklar (5xx, Zeitueberlauf), steht die Zeile mit `status: lexware_unklar` und zaehlt nicht als offene Rechnung. Fehlt der Lexware-Kunde zur Rechnungsanschrift, wird er angelegt. Die berechneten Credits gelten danach als abgerechnet (`credit_transactions.billed_invoice_id`). → 201 `{data, reused: false}`; derselbe Schluessel noch einmal → 200 `{data, reused: true}`, kein zweiter Beleg. `422 not_billable` (Anschrift unvollstaendig, Credits inzwischen berechnet, Lexware nicht eingerichtet), `502 lexware_error`. Throttle 10/min. |

---

## Demo-Teams (Vertriebs-Demo-Konsole)

Nur für einen Demo-Login (Mitglied mindestens eines Teams mit `is_demo`), sonst 403. Jedes `{team}` wird gegen die EIGENEN Demo-Teams des Logins aufgelöst; ein fremdes oder ein Kundenteam antwortet 404. Schreibende Aufrufe: Throttle 20/min. Modul-Doku: `docs/modules/demo-einrichtung.md`.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/demo-teams` | Die Demo-Teams des Logins. → 200 `{data: [{id, name, is_home, projects_count, credit_balance, setup, takeover, created_at}]}` — `setup`/`takeover` tragen `processing_status` (`pending` → `processing` → `completed`/`failed`) | — |
| POST   | `/demo-teams` | Neues Demo-Team je Interessent: Team mit `is_demo`, Modulauswahl, 1.000.000 Credits, dann im Hintergrund je URL ein Projekt plus AI-Visibility-Projekt mit Analyse. Body `{name, urls: [1–2 Domains], modules?: [Modul-Slugs]}` → 202 `{data: {id, name, processing_status}}`; ungültige Domain 422 | — |
| POST   | `/demo-teams/{team}/reset` | Neu aufsetzen: leert alle Inhalte genau dieses Demo-Teams, Name, Branding und Module bleiben. → 200 `{data: {id, rows: {tabelle: anzahl}}}` | — |
| POST   | `/demo-teams/{team}/takeover` | Übernahme in ein frisches Kundenteam (Tarif `free`) **per Einladung** (Eigentümer-Entscheid 03.11.2026): kopiert die Demo, bis zur Annahme besitzt der Demo-Login das Kundenteam, und an `owner_email` geht eine Besitz-Einladung über die gewöhnlichen Team-Einladungen. Erst mit der Annahme (bestätigte Mailbox, #10864) wird der Kunde Besitzer, und der Demo-Login verlässt das Team. Body `{owner_email, team_name}` → 202 `{data: {id, processing_status}}` — **dieselbe Antwort, ob zur Adresse ein Konto existiert oder nicht**; keine gültige Adresse 422; eigene Adresse des Demo-Logins, Heim-Team, laufende oder abgeschlossene Übernahme, mehr Projekte als der Tarif erlaubt → 409 | — |
| POST   | `/demo-teams/{team}/takeover/resend` | Besitz-Einladung einer abgeschlossenen Übernahme erneut senden (`TeamMembershipService::resendInvitation`, wieder 7 Tage gültig) — für den Fall, dass der Kunde sie hat ablaufen lassen. → 200 `{data: {id, resent: true}}`; 409, wenn es keine offene Einladung mehr gibt (Kunde hat angenommen) oder innerhalb von 60 s schon gesendet wurde | — |
| DELETE | `/demo-teams/{team}` | Wegwerfen: entfernt das Demo-Team restlos (Inhalte, Team-Zeile, Mitgliedschaften, Dateien). Nie die `Demo-Zentrale`, nie mit Rechnungen/Abos/Widerrufen/Provisionen → 409 mit Grund. → 200 `{data: {id, deleted: true, rows}}` | — |

---

## 🧾 Rechnungsdaten

Team-Rechnungsprofil (Firmenanschrift, USt-IdNr. oder Steuernummer, Rechnungsempfänger, abweichende Anschrift, PO-Referenz). Wird beim Schreiben nach Stripe gesynct.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/billing/profile` | Rechnungsprofil lesen. Leeres Shape, wenn noch keins existiert | — |
| PUT    | `/billing/profile` | Rechnungsprofil schreiben (validiert; USt-ID-Format je EU-Land; triggert Stripe-Customer-Sync). `vat_id` ODER `tax_number` (Steuernummer, bis 64 Zeichen): beide zugleich → 422. Fehlt `tax_number` im Body, bleibt die gespeicherte Steuernummer stehen; eine `vat_id` leert sie. Die Steuernummer löst kein Reverse-Charge aus und geht weder an Stripe noch an Lexware. Gate: `manage settings` (Owner/Admin). Body: `{company_name, address_line1, postal_code, city, country, contact_name?, address_line2?, vat_id?, tax_number?, billing_email?, purchase_order_ref?, separate_billing_address?, billing_address_line1?, billing_postal_code?, billing_city?, billing_country?}` | — |
| GET    | `/billing/invoices/overdue` | Die überfälligen Rechnungen des eigenen Teams (Plan #97) — dieselbe Menge, über die der Hinweis an der Kunden-Glocke spricht. Fälligkeit: `due_date`, sonst Anlagedatum plus Zahlungsziel (`Company::invoiceTermsDays()`, heute 14 Tage); die Regel steht an EINER Stelle in `OverdueInvoiceNotifier::faelligAb()`. Gate: `manage settings` + `billing read`. → 200 `{count, amount_cents, invoices: [{id, status, amount_cents, currency, due_at, due_date_set}]}` | — |
| POST   | `/billing/invoice-upgrade` | Upgrade auf einen bezahlten Plan **auf Rechnung** (ohne Stripe): Plan sofort aktiv, offene PDF-Rechnung + Info-Mail an den Betreiber. Voraussetzung: vollständiges Rechnungsprofil (sonst 422) + hinterlegte Bankverbindung. Gate: `manage settings`. Body: `{plan: starter\|pro\|agency, cycle: monthly\|yearly}` → 201 `{invoice_id, invoice_number, plan, billing_cycle, amount, currency, status, due_date}` | — |
| POST   | `/billing/subscription/cancel` | Abo zum Ende der bezahlten Periode kündigen. Bewusst kein Sofort-Storno: die laufende Periode ist bezahlt und bleibt nutzbar, es wird nichts erstattet. Nur die Eigentümerin/der Eigentümer des Teams (sonst 403); ohne aktives Abo 404. → 200 `{status: cancelled_at_period_end, plan, billing_cycle, access_until}`. Throttle 5/min | — |
| GET    | `/billing/contract-cancellation` | Stand der Kündigung nach § 312k BGB für das eigene Team. → 200 `{cancelled, plan, status, access_until, public_form_url}`. Gate: `billing read` |
| POST   | `/billing/contract-cancellation` | Kündigungserklärung nach § 312k BGB abgeben — mit den Pflichtangaben aus Absatz 2. Anders als der Endpunkt darüber storniert das nicht bloss technisch, sondern bildet den gesetzlichen Vorgang ab: die Erklärung wird protokolliert, wirkt zum Periodenende und wird nach Absatz 4 in Textform bestätigt (Mail mit Datum, Uhrzeit und Beendigungszeitpunkt). Identität und Bestätigungsadresse kommen aus dem angemeldeten Konto, nicht aus dem Rumpf. Nur die Eigentümerin/der Eigentümer des Teams (sonst 403). Body: `{vertrag, art: ordentlich\|ausserordentlich, beendigung?, zusatz?}` → 201 `{status: declaration_received, received_at, access_until, confirmation_sent_to}`. Throttle 5/min. Der login-freie Weg, den § 312k verlangt, ist das Formular unter `/kuendigen` — nicht dieser Endpunkt | — |
| GET    | `/billing/auto-topup` | Einstellung des automatischen Credit-Nachkaufs auf Rechnung (Karte #7437). → 200 `{enabled, threshold_credits, package_key, monthly_limit, max_monthly_limit, packages: [{key, credits, price_cents}]}`. Gate: `billing read` | — |
| PUT    | `/billing/auto-topup` | Automatischen Nachkauf einstellen. Body: `{enabled, threshold_credits ≥ 1, package_key (aus pricing.topups), monthly_limit 1–10, withdrawal_consent}`. Einschalten verlangt Zahlung auf Rechnung, ein vollständiges Rechnungsprofil (sonst 422 `{error: billing_data_missing, billing_data_url}`), eine gültige deutsche USt-IdNr. (sonst 422 `{error: vat_id_missing, billing_data_url}`; USt-IdNr. eines anderen EU-Lands: 422 `{error: vat_id_not_german}`, erlaubte Länder in `pricing.auto_topup.allowed_vat_countries`) und beim ersten Einschalten `withdrawal_consent=true` (sonst 422 `withdrawal_consent_missing`). Wirkung: fällt das Guthaben durch eine Abbuchung unter die Schwelle, wird das Paket einmal per Lexware-Rechnung nachgekauft — höchstens `monthly_limit`-mal je Kalendermonat (Europe/Berlin), nie zwei gleichzeitig, pausiert solange eine Nachkauf-Rechnung überfällig ist. Gate: `billing update`, Throttle 10/min | — |
| GET    | `/billing/auto-topup/purchases` | Die automatischen Nachkäufe des eigenen Teams, neueste zuerst. Filter `?status=running\|completed\|failed\|needs_review`, `?page=`, `?per_page=` (max 100). → 200 `{data: [{id, status, package_key, credits, price_cents, invoice_id, invoice_number, created_at, completed_at}], meta}`. Gate: `billing read` | — |
| GET    | `/billing/credit-transfer` | Die Teams, die dem Nutzer GEHÖREN (`owner_id`), mit Guthaben — Auswahl für den Credit-Übertrag (K#11540), ohne Demo- und Nachzahler-Teams. → 200 `{data: [{id, name, balance: {milli, display, …}}]}`. Gate: `billing read` | — |
| POST   | `/billing/credit-transfer` | Credits zwischen zwei eigenen Teams verschieben (K#11540). Body: `{from_team_id, to_team_id, credits}` (`credits` > 0, höchstens 3 Nachkommastellen). Beide Teams müssen dem Nutzer gehören (sonst 403 `not_owner`, auch für eine unbekannte ID); die Quelle geht nie ins Minus (422 `insufficient_balance`), bis genau 0 erlaubt. 422 `same_team`, `demo_team` (Demo-Team als Quelle oder Ziel), `postpaid_team` (Enterprise-Nachzahler als Quelle oder Ziel). Beide Teamzeilen werden in fester Reihenfolge gesperrt; jede Seite bekommt eine Buchung `type = credit_transfer`, die per `reference_id` auf die andere zeigt. Kein Verbrauch, keine Bezahlung, kein Auto-Nachkauf. **Rücknahme folgt dem Übertrag:** wird der Kauf widerrufen, erstattet oder zurückgebucht, holt Rankion den Fehlbetrag bei den Zielteams nach (Belegtyp `credit_transfer_clawback`), jedes nur bis zu seinem Guthaben. → 200 `{data: {amount, from_team, to_team, from_transaction_id, to_transaction_id}}`. Kostenlos, Gate: `billing update`, Throttle 10/min. Bewusst kein MCP-Werkzeug | — |

### Workflow (GEO-Optimizer-Perspektive)

**Use-Case:** Vor dem ersten Checkout das Rechnungsprofil setzen, damit Stripe-Rechnungen korrekte Firmenanschrift + USt-IdNr. (Reverse-Charge) tragen.

**Idiom:** GET `/billing/profile` (Ist-Stand lesen) → PUT `/billing/profile` (vollständiges Profil schreiben, 422 bei ungültigem USt-ID-Format/fehlenden Pflichtfeldern) → der PUT synct synchron nach Stripe; `vat_id_status` (Stripe-Echo) erscheint beim nächsten GET. Single-Resource (kein Bulk, kein Polling).

**Anti-Patterns:** Kein wiederholtes PUT in Schleife (jeder PUT triggert einen Stripe-Sync). USt-ID nicht selbst „verifizieren" — das macht Stripe; nur den `vat_id_status` lesen.

**Cross-Module:** `/billing` (Checkout/Invoices) liest dasselbe Profil; InvoiceMail geht an `billing_email ?? owner`. Modul-Doc: `docs/modules/billing.md`.

---

## 🤝 Partnerprogramm »Kunden empfehlen«

Beitritt, Stammdaten, Zahlen, Provisionen und Abrechnungen des **eigenen** Kontos, dazu die Admin-Endpunkte des Programms (Karte #7423, Plan #199). Kein Partnerpfad trägt eine Partner-Id — ein fremder Partner ist über diese Endpunkte nicht ansprechbar; der einzige Pfad mit Id (Abrechnungs-PDF) antwortet für eine fremde Abrechnung 404. Admin-Endpunkte: nur globale Admins (`is_admin`), sonst 403. Solange der Programmschalter `affiliate.enabled` aus ist (Standard), sind die Endpunkte nicht registriert: 404, auch ohne Anmeldung; sie fehlen dann auch in `openapi-v1.yaml`. Die IBAN ist schreibbar, aber nie lesbar — auch nicht für Admins: Antworten tragen nur `iban_masked` (`DE89 **** 3000`); die volle IBAN einer offenen Auszahlung steht nur auf der Adminseite und in deren CSV, nicht in der API.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/affiliate` | Teilnahme-Status. Nicht beigetreten → 200 `{joined:false, prefill:{legal_name, street, postal_code, city, country, tax_status, vat_id, account_holder, billing_email, …}, terms_version, self_billing_version}` (Vorbelegung aus dem Rechnungsprofil des aktuellen Teams, nie eine IBAN). Beigetreten → 200 `{joined:true, code, link, status, discount_percent, conditions:{rabatt_prozent, rabatt_monate, provision_credits_prozent, provision_abo_prozent, abo_monate, abo_unbegrenzt, freigabe_tage, mindestauszahlung_cents, abrechnungstag, cookie_tage}}` (die für diesen Partner wirksamen Konditionen) | — |
| POST   | `/affiliate` | Beitreten. Body: `{legal_name, street, postal_code, city, country (ISO-2), tax_status: unternehmer_de\|kleinunternehmer\|unternehmer_eu\|unternehmer_drittland\|privat, tax_number?, vat_id?, account_holder, iban, bic?, billing_email, display_name?, accept_terms: true, accept_self_billing: true}`. Pflichtfelder je Steuerstatus: Unternehmer DE Steuernummer oder USt-IdNr. (Sitz DE) · Kleinunternehmer Steuernummer (Sitz DE) · Unternehmer EU USt-IdNr. passend zum Land, qualifiziert beim BZSt geprüft (Name und Ort) · Drittland Register- oder Steuernummer, Land außerhalb der EU · Privatperson keine. IBAN mit Prüfziffer (ISO 13616). → 201 Profil (wie GET `/affiliate/profile`). 422 bei Fehlern, auch wenn das BZSt die USt-IdNr. als ungültig meldet oder Name/Ort nicht passen; eine technische Störung der Prüfung blockiert nicht (`vat_id_check_status: fehler`). 409, wenn schon beigetreten | — |
| GET    | `/affiliate/profile` | Eigene Stammdaten → 200 `{id, code, link, status, display_name, legal_name, street, postal_code, city, country, tax_status, tax_number, vat_id, vat_id_check_status, vat_id_checked_at, account_holder, iban_masked, bic, billing_email, terms_accepted_at, terms_version, self_billing_accepted_at, self_billing_version}`. 404 ohne Teilnahme | — |
| PUT    | `/affiliate/profile` | Stammdaten ändern; dieselben Felder und Regeln wie beim Beitritt, ohne die Zustimmungen. `iban` weglassen oder leer = unverändert. Eine Änderung von IBAN (maskiert) oder Steuerstatus wird protokolliert. → 200 Profil. 404 ohne Teilnahme | — |
| GET    | `/affiliate/stats` | Kennzahlen wie im Dashboard `/referrals`. Zeitraum `?period=7\|30\|90\|monat` oder `?from=&to=` (Y-m-d, beide, höchstens 366 Tage, nicht in der Zukunft); ohne Angabe die letzten 30 Tage. `?interval=day\|week\|month` gruppiert die Reihe (Woche = ISO-Woche, Schlüssel ihr Montag; Monat Schlüssel `Y-m`). → 200 `{from, to, interval, kpis:{klicks, rabatt_gesichert, registrierungen, zahlende_kunden, kaeufe, conversion_registrierung, conversion_kauf, umsatz_netto_cents, provision_offen_cents, provision_freigegeben_cents, provision_ausgezahlt_cents}, series:[{tag, klicks, registrierungen, kaeufe}]}`. 422 bei falschem Zeitraum, 404 ohne Teilnahme | — |
| GET    | `/affiliate/commissions` | Eigene Provisionen, neueste Zahlung zuerst. `?status=ausstehend\|freigegeben\|abgerechnet\|ausgezahlt\|storniert\|abgelehnt`, `?page=`, `?per_page=` (max 100). → 200 `{data:[{id, kind: credits\|abo, base_net_cents, rate_percent, amount_cents, status, payment_received_at, is_reversal}], meta:{current_page, per_page, total, last_page}}` — pseudonym, ohne Team oder Kundennamen; `is_reversal` = Minusposten einer Erstattung. 404 ohne Teilnahme | — |
| GET    | `/affiliate/statements` | Eigene Abrechnungen (Gutschrift bzw. Provisionsabrechnung), neueste zuerst, `?page=`, `?per_page=`. → 200 `{data:[{id, period, number, document_type: gutschrift\|provisionsabrechnung, net_cents, tax_rate_percent, tax_cents, gross_cents, issued_at, paid_out_at, pdf_url}], meta:{…}}`; `pdf_url` null ohne PDF. 404 ohne Teilnahme | — |
| GET    | `/affiliate/statements/{statement}/pdf` | PDF der eigenen Abrechnung (`application/pdf`, Dateiname `<number>.pdf`). 404 für eine fremde oder unbekannte Abrechnung und ohne PDF — nicht 403, damit der fortlaufende Nummernkreis nichts verrät | — |
| GET    | `/admin/affiliates` *(Admin only)* — Partner, neueste zuerst. `?flagged=1` nur Partner mit offener Markierung: IBAN oder Steuerstatus geändert in den letzten 7 Tagen vor dem Abrechnungslauf am 28. (06:00), offen bis zum Folgelauf. `?page=`, `?per_page=` (max 100). → 200 `{data:[{id, user_id, code, status, effective_active, legal_name, tax_status, flagged_change_open, created_at}], meta:{current_page, per_page, total, last_page}}` — `status` ist der Partnerdatensatz, `effective_active` was gilt (auch false, wenn das Benutzerkonto gesperrt oder gelöscht ist). 403 ohne Admin | — |
| GET    | `/admin/affiliates/{affiliate}` *(Admin only)* — Stammdaten wie GET `/affiliate/profile` plus `user_id`, `flagged_change_open` und `changes:[{id, field, old_value, new_value, changed_by_user_id, created_at, shortly_before_run, flag_open}]` — `field` ist `iban`, `tax_status`, `status` oder ein Feld aus `overrides`; `shortly_before_run`/`flag_open` können nur bei `iban` und `tax_status` true sein (IBAN nur maskiert), dazu `blocked, blocked_at, blocked_reason, effective_active`, `conditions` (wirksam, wie bei GET `/affiliate`) und `overrides:{discount_percent, discount_months, commission_credit_percent, commission_plan_percent, plan_commission_months, plan_commission_unlimited, min_payout_cents}` (eigene Werte, null = Programmstandard). 403 ohne Admin — auch für eine unbekannte Id, damit niemand die vergebenen Ids abzählen kann —, für Admins 404 unbekannt. Dasselbe gilt für jeden Admin-Pfad mit Id | — |
| PUT    | `/admin/affiliates/{affiliate}` | *(Admin only)* Konditionen ändern; nur die gesendeten Felder aus `overrides`, `null` = zurück auf den Programmstandard; `plan_commission_unlimited` nur `true`/`false` (weglassen = unverändert, `null` = 422). Grenzen: Rabatt 0–50 %, Provision 0–60 %, Monate 1–120, `min_payout_cents` 0–1.000.000. Ein neuer Satz gilt für Zahlungen ab jetzt; jede Änderung wird protokolliert. → 200 `{changed:[feld,…], data:<wie GET>}`. 422 bei Fehlern | — |
| POST   | `/admin/affiliates/{affiliate}/block` | *(Admin only)* Partner sperren, Body `{reason}` (Pflicht, max. 255). Keine neuen Klicks und Zuordnungen, neue Provisionen `abgelehnt`, Abrechnungen übersprungen; verdiente Provisionen bleiben. → 200 Detail | — |
| POST   | `/admin/affiliates/{affiliate}/unblock` | *(Admin only)* Sperre aufheben. → 200 Detail | — |
| GET    | `/admin/affiliate-settings` | *(Admin only)* Programm-Standards → 200 `{min_payout_cents, config_min_payout_cents, defaults, config_defaults, effective_defaults}`; `defaults` je Feld (`discount_percent`, `discount_months`, `commission_credit_percent`, `commission_plan_percent`, `plan_commission_months`, `min_payout_cents`) der Admin-Wert oder null (= Wert aus der Konfiguration) | — |
| PUT    | `/admin/affiliate-settings` | *(Admin only)* Body: eines oder mehrere der sechs Felder, Ganzzahl oder null (Rabatt 0–50, Provisionen 0–60, Monate 1–120, Mindestauszahlung 0–1.000.000 Cent); leerer Body → 422 `settings`. Neue Sätze gelten für Zahlungen ab jetzt, die Mindestauszahlung ab dem nächsten Abrechnungslauf; der eigene Wert eines Partners hat Vorrang. → 200 `{changed, changed_fields, data}` | — |
| GET    | `/admin/affiliate-stats` | *(Admin only)* Programmzahlen der Admin-Übersicht, `?period=7\|30\|90\|monat` (Standard 30). → 200 `{from, to, kpis (wie `/affiliate/stats`, über alle Partner), series, partners:[{affiliate_id, klicks, registrierungen, zahlende_kunden, umsatz_netto_cents, provision_cents, conversion_kauf, kosten_umsatz}]}` — nur Partner mit Aktivität im Zeitraum | — |
| GET    | `/admin/affiliate-hints` | *(Admin only)* Betrugshinweise der letzten 90 Tage, neueste zuerst, `?type=self_referral\|fast_purchase\|no_referrer\|bank_change`, `?page=`, `?per_page=`. → 200 `{data:[{type, affiliate_id, at, detail}], meta}` (`detail` je Art, IBAN nur maskiert) | — |
| GET    | `/admin/affiliate-statements` | *(Admin only)* Abrechnungsläufe je Monat und offene Auszahlungen → 200 `{runs:[{period, anzahl, brutto_cents, gebucht, fehlgeschlagen, offen, ausgezahlt}], open_payouts:[{statement_id, affiliate_id, number, period, document_type, gross_cents, issued_at, lexware_status, partner, account_holder, iban_masked, bic, reference, warnings:{iban_changed, bank_flag, objected, blocked}}], unbooked:[{statement_id, affiliate_id, number, period, lexware_status, lexware_error, issued_at, paid_out_at}]}` — `unbooked` sind alle nicht in Lexware gebuchten Abrechnungen, auch schon überwiesene (neueste zuerst, höchstens 100), die Ids für `/rebook` | — |
| POST   | `/admin/affiliate-statements/run` | *(Admin only)* Monatslauf `affiliate:abrechnen` einreihen, Body `{period?: JJJJ-MM}` — nur der aktuelle oder der Vormonat (Nachholfall), Standard aktueller Monat; sonst 422. → 202 `{status: queued, period}`; das Ergebnis erscheint in GET `/admin/affiliate-statements`. Je Partner und Periode höchstens eine Abrechnung; ein laufender Lauf sperrt den zweiten, der Job endet dann als fehlgeschlagen. Achtung: ein Lauf vor dem 28. rechnet den Monat vorzeitig ab, der reguläre Lauf findet ihn dann als erledigt. Gedrosselt 10/min | — |
| POST   | `/admin/affiliate-statements/{statement}/paid` | *(Admin only)* Als überwiesen markieren, Body `{paid_on?: Y-m-d}` (Standard heute, nicht in der Zukunft, nicht vor dem Ausstellungsdatum); die Provisionen gehen auf `ausgezahlt`. → 200 `{id, number, paid_out_at, changed}` — `changed:false`, wenn schon markiert. 422, 404 | — |
| POST   | `/admin/affiliate-statements/{statement}/rebook` | *(Admin only)* Lexware-Buchung erneut einreihen (wie »Erneut buchen«). → 202 `{status: queued, statement_id}`; 409, wenn schon gebucht. Gedrosselt 10/min | — |

### Workflow (GEO-Optimizer-Perspektive)

**Use-Case:** Eine Agentur, die rankion ihren Kunden empfiehlt, tritt per API bei und bekommt ihren Partnerlink für Newsletter und Angebote.

**Idiom:** GET `/affiliate` (Vorbelegung lesen) → POST `/affiliate` mit Stammdaten, Bankverbindung und beiden Zustimmungen → `link` aus der Antwort teilen. Änderungen per PUT `/affiliate/profile`. Monatlich GET `/affiliate/stats?period=monat` für den Bericht an den Kunden, GET `/affiliate/statements` und das PDF für die eigene Buchhaltung.

**Anti-Patterns:** Die IBAN nicht zurücklesen wollen — sie kommt nur maskiert. Kein PUT in Schleife: eine geänderte EU-USt-IdNr. löst je Aufruf eine Abfrage beim BZSt aus. Kein Polling von `/affiliate/stats` im Minutentakt — die Zahlen ändern sich mit Klicks und Zahlungen, nicht live; einmal täglich genügt. `POST /admin/affiliate-statements/run` ist für den Nachholfall gedacht, der Zeitplaner läuft am 28. von selbst.

**Cross-Module:** Die Vorbelegung kommt aus `/billing/profile`. Modul-Doc: `docs/modules/referrals.md` (nicht öffentlich ausgeliefert).

---

## 👥 Team-Mitglieder

Mitglieder-Verwaltung + Einladungen eines Teams. Alle Endpoints team-scoped auf `currentTeam()` des Token-Users; Gate `manage members` (Owner/Admin). Delegiert an `TeamMembershipService` (Single-Source der Mutationen). Fremdes-Team-Member/-Einladung → `404`; Service-Regelverletzung (z. B. Owner ändern, sich selbst entfernen, Plan-Rolle, Sitzplatz-Limit) → `422` mit `message`.

| Method | URL | Beschreibung | Gate | Credits |
|--------|-----|--------------|------|---------|
| GET    | `/team/members` | Mitglieder-Liste `{id, name, email, role, is_owner, last_active_at}`. Owner wird immer als `owner` ausgewiesen. | manage members | — |
| PATCH  | `/team/members/{user}` | Rolle ändern. Body `{role}`. `admin` darf nur der Owner vergeben/entziehen; Owner-Rolle nicht änderbar; eigene Rolle nicht änderbar. `404` fremdes Team, `422` Plan-Rolle/Regelverstoß. | manage members | — |
| DELETE | `/team/members/{user}` | Mitglied entfernen (+ zugehörige `project_user`-Einträge; `current_team_id` des Entfernten wird genullt, wenn es dieses Team war). Owner/Selbst nicht entfernbar (`422`). | manage members | — |
| GET    | `/team/invitations` | Offene (nicht angenommene, nicht abgelaufene) Einladungen `{id, email, role, expires_at}`. | manage members | — |
| POST   | `/team/invitations` | Einladen. Body `{email, role}`. `422` bei Sitzplatz-Limit oder im Plan nicht verfügbarer Rolle. Versendet Einladungs-Mail (7 Tage gültig). `201`. | manage members | — |
| DELETE | `/team/invitations/{id}` | Einladung widerrufen. Bereits angenommene → `422`. `404` fremdes Team. | manage members | — |

### Workflow (GEO-Optimizer-Perspektive)

**Use-Case:** Team-Sitzplätze verwalten — Kollegen mit passender Rolle einladen, Rollen anpassen, ausgeschiedene Mitglieder entfernen.

**Idiom:** GET `/team/members` + GET `/team/invitations` (Ist-Stand) → POST `/team/invitations` `{email, role}` (einladen; `422` bei Limit/Plan-Rolle) → nach Annahme erscheint die Person in `/team/members`, die Einladung verschwindet aus `/team/invitations` → PATCH `/team/members/{user}` für spätere Rollen-Änderungen, DELETE zum Entfernen. Single-Resource, kein Bulk, kein Polling (Annahme passiert async durch den Eingeladenen via Accept-Link).

**Anti-Patterns:** Keine Owner-Rolle per PATCH/POST vergeben (nur via Ownership-Transfer). Nicht wiederholt dieselbe E-Mail einladen — offene Einladungen sind idempotent (dieselbe Einladung wird wiederverwendet, kein Duplikat). Rolle nicht aus einem hardcoded Set wählen — nur Rollen aus `RoleOptions::forTeam()` (plan-abhängig) sind gültig, sonst `422`.

**Cross-Module:** Rollen/Permissions stammen aus `TeamRole` + `HasTeamPermissions::canDo()`; UI-Pendant ist die Team-Settings-Surface. Modul-Doc: `docs/modules/team-members.md`.

---

## Agentur-Branding (White-Label-Berichte)

Logo, Primärfarbe und Anzeigename des Teams für Reality-Check- und Site-Monitor-Berichte. Team-scoped auf `currentTeam()`; `403` ohne Plan-Merkmal `white_label` (`Team::hasFeature`), Schreiben zusätzlich nur mit `manage settings`. Antwort aller vier Endpunkte: `{data: {white_label, name, color, report_color, has_logo}}` — `report_color` ist die im Bericht gedruckte Farbe (zu helle Farben werden auf 4,5:1 gegen Weiß abgedunkelt, Farbton bleibt).

| Method | URL | Beschreibung | Gate | Credits |
|--------|-----|--------------|------|---------|
| GET    | `/team/branding` | Branding lesen. | white_label | — |
| PUT    | `/team/branding` | Body `{name?: string ≤ 120, color?: "#RRGGBB"}`; leere Werte löschen. `422` bei ungültiger Farbe. | white_label + manage settings | — |
| POST   | `/team/branding/logo` | `multipart/form-data`, Feld `logo`: PNG oder JPEG ≤ 2 MB, geprüft am Dateiinhalt. SVG → `422`, auch als `.png` benannt. Ersetzt ein vorhandenes Logo. Throttle 10/min. | white_label + manage settings | — |
| DELETE | `/team/branding/logo` | Logo entfernen. | white_label + manage settings | — |

### Workflow (GEO-Optimizer-Perspektive)

**Use-Case:** Eine Agentur gibt Berichte unter eigener Marke an ihre Kunden weiter.

**Idiom:** GET `/team/branding` (prüft das Plan-Merkmal) → PUT `/team/branding` `{name, color}` → POST `/team/branding/logo` → der nächste erzeugte Bericht trägt das Branding. Kein Polling.

**Anti-Patterns:** Kein SVG hochladen. Branding nicht je Bericht umsetzen — es ist ein Teamwert.

**Cross-Module:** gelesen von `ai-reality-check` und `site-monitor` über `App\Support\AgenturBranding::fuerBericht()`. Modul-Doc: `docs/modules/agentur-branding.md`.

---

## 🔬 Page-Deep-Audit (Vision + KI-Render)

Auditiert eine einzelne Landingpage: ein Desktop-Screenshot, Lighthouse-Metriken (Mobile und Desktop), Claude-Multimodal-Vision-Analyse plus optional EIN gpt-image-2 KI-Render der optimierten Variante (Tablet- und Mobile-Renders seit 28.05.2026 entfernt) und optional ein Entwurfsturnier (3 Stufen zu je 2 Entwürfen). Authentifizierung: `Authorization: Bearer <token>`, alle Endpoints team-scoped.

| Method | URL | Credits | Beschreibung |
|--------|-----|---------|--------------|
| POST   | `/page-audit` | 30 + 15 | Audit starten. Body: `{url, tracking_project_id?, persona?, tournament_mode?}` → 202 + `{id, status:"pending", url}`. Mit `tournament_mode` (`auto`\|`manual`) wird ein Entwurfsturnier vorgemerkt, das nach dem Audit startet; die Antwort trägt dann `tournament: {id, mode, status:"queued"}` oder `tournament: {error}` (das Audit läuft trotzdem) |
| POST   | `/page-audit/{id}/refresh` | 30 + 15 | Erneut prüfen: neues Audit derselben URL (Projekt und Persona übernommen), das bisherige bleibt. Läuft für die URL schon ein Audit aus den letzten 5 Minuten, kommt dessen `id` mit `deduplicated: true` zurück, ohne Buchung → 202 + `{id, status, url, refreshed_from, deduplicated}`. Der Credits-Riegel prüft vorher: unter 30 Credits kommt 402 `insufficient_credits`, auch wenn ein Lauf dedupliziert würde. 403 fremdes Team oder `impersonation_no_spend` |
| GET    | `/page-audit/{id}` | — | Detail mit 2 Bild-URLs (Desktop-Source + Desktop-Ideal) + `opus_analysis` + Lighthouse |
| POST   | `/page-audit/{id}/tournament` | 30 je fertiger Stufe, max. 90 | Entwurfsturnier starten (Karte #7436). Body: `{mode: "auto"\|"manual"}`. Guthabenprüfung ohne Abbuchung (Automatik 90, Auswahl 30), gebucht wird je fertiger Stufe im Hintergrund → 202 + Turnier. 402 `insufficient_credits`, 409 `tournament_running` / `audit_not_ready`, 503 `tournament_unavailable` (Bild-Render aus), 403 fremdes Team |
| GET    | `/page-audit/{id}/tournament` | — | Stand des jüngsten Turniers: `{id, mode, status (queued\|running\|waiting_choice\|completed\|failed), current_stage, max_stages, winner_variant_id, stop_reason, credits_used, variants[{id, stage, branch, parent_variant_id, direction, status, image_url, is_winner, is_recommended, rationale, error}]}`. 404 ohne Turnier |
| POST   | `/page-audit/{id}/tournament/choose` | 30 für die nächste Stufe | Auswahlmodus: Sieger der aktuellen Stufe wählen. Body: `{variant_id}` → 202, die nächste Stufe startet (nach Stufe 3 ist das Turnier `completed`). 422 `invalid_choice`, 402 `insufficient_credits` |
| GET    | `/page-audits` | — | Paginierte Liste. `?per_page=25&tracking_project_id=`. **Achtung, andere Feldnamen als beim Detail:** die Liste gibt die Models roh heraus, die Lighthouse-Daten heißen dort `lighthouse_data` (nicht `lighthouse`) — inklusive `lighthouse_data.strategies.mobile` / `.desktop`. |

**Pipeline-Status:** `pending` → `scraping` → `screenshotting` → `analyzing` → `completed`. Der Desktop-KI-Render läuft in einem separaten Background-Job nach `completed`; `ideal_screenshot_url` füllt sich anschließend.

**Polling-Pattern:**

```bash
# 1) Audit starten
ID=$(curl -s -X POST $BASE/page-audit \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/landing"}' | jq -r '.id')

# 2) Pollen bis completed + Desktop-Ideal-Render fertig (Hauptflow ~1-2 min, Desktop-Render +2-3 min)
while true; do
  R=$(curl -s -H "Authorization: Bearer $TOKEN" $BASE/page-audit/$ID)
  STATUS=$(echo "$R" | jq -r '.status')
  IDEAL=$(echo "$R" | jq -r '.ideal_screenshot_url // "null"')
  echo "$STATUS desktop_ideal=$([ "$IDEAL" = null ] && echo no || echo yes)"
  [ "$STATUS" = completed ] && [ "$IDEAL" != null ] && break
  sleep 30
done
```

**Response-Shape (`GET /page-audit/{id}` nach `completed`):**

```json
{
  "id": 10,
  "tracking_project_id": null,
  "url": "https://www.example.com/landing",
  "status": "completed",
  "error_message": null,
  "screenshot_url":         "https://rankion.ai/storage/page-audits/10/screenshot.png",
  "ideal_screenshot_url":   "https://rankion.ai/storage/page-audits/10/ideal.png",
  "lighthouse": {
    "performance": 78, "seo": 92, "accessibility": 88, "best_practices": 83,
    "strategies": {
      "mobile":  { "performance": 78, "seo": 92, "accessibility": 88, "best_practices": 83, "core_web_vitals": { "lcp": 2500, "cls": 0.01 } },
      "desktop": { "performance": 94, "seo": 92, "accessibility": 88, "best_practices": 83, "core_web_vitals": { "lcp": 900, "cls": 0.0 } }
    }
  },
  "analysis": {
    "user_intent": "1-Satz Search-Intent",
    "persona_fit": 75,
    "trust_score": 72,
    "layout_score": 68,
    "cta_score": 65,
    "problem_solution_clarity": 70,
    "above_the_fold_quality": 60,
    "mobile_friendliness_visual": 80,
    "personas": [
      { "name": "...", "who": "...", "primary_motivation": "...",
        "pain_points": ["..."], "goals": ["..."],
        "fit_score": 75, "fit_reason": "..." }
    ],
    "critical_issues": [
      { "severity": "high|medium|low", "title": "...", "explanation": "...", "evidence": "..." }
    ],
    "improvement_suggestions": [
      { "area": "headline|cta|trust|layout|copy|visuals|forms|navigation|seo|accessibility",
        "priority": "high|medium|low",
        "title": "...", "description": "...", "example": "before/after micro-copy" }
    ],
    "summary": "3-5 Satz Executive-Summary",
    "headline_rewrite": "stärkerer H1-Vorschlag oder null"
  },
  "gpt_image_used": true,
  "credits_used": 75,
  "started_at": "2026-04-27T19:28:58Z",
  "completed_at": "2026-04-27T19:30:22Z"
}
```

**Lighthouse-Felder:** die flachen Felder (`performance`, `seo`, `accessibility`, `best_practices`, `core_web_vitals`, `full_report`) sind der **Mobile**-Wert und bleiben der zugesagte Vertrag; `strategies.mobile` / `strategies.desktop` kamen am 22.09.2026 additiv dazu. Scheitert ausgerechnet die Mobile-Messung, während Desktop gelingt, sind die flachen Felder vorhanden und `null` — nie fehlend. Scheitern beide, ist `lighthouse` wie bisher `null`. `GET /page-audits` (Liste) gibt die Models roh heraus: dort heißt dasselbe Feld `lighthouse_data`.

**Status-Codes:** 202 (gestartet), 200 (Detail/List), 403 (Cross-Team / Cross-Project), 404 (Audit nicht gefunden), 422 (Validation: `url` Pflicht, max 500 Zeichen).

**Use-Cases:**
- **Iterative Optimierung:** `analysis.improvement_suggestions` (sortiert nach `priority`) lesen → Änderungen am Source-Page anwenden → Re-Audit → Score-Diff vergleichen.
- **A/B Variant-Generation:** `ideal_screenshot_url` (Desktop+Mobile) als Visual-Reference in den Design-Pipeline-Iteration füttern.
- **Persona-driven Copy:** `analysis.personas[].pain_points` + `goals` → gezielte Headline-/CTA-Varianten schreiben → re-audit.
- **Headline-Drop-In:** `analysis.headline_rewrite` ist ein validierter Headline-Replacement — kann direkt in den Live-Page übernommen werden.

---

## 📑 Reports & Cross-Module Correlation

Auto-Generator für Tracking-Project-Reports (Markdown-Output via `GenerateTrackingProjectReportJob`) und ein Cross-Modul-Korrelations-Endpoint, der Site-Audit, Rank-Tracker, Backlinks und AVI verknüpft. Authentifizierung: `Authorization: Bearer <token>`, alle Endpoints team-scoped.

### Reports

| Method | URL | Credits | Beschreibung |
|--------|-----|---------|--------------|
| POST   | `/tracking-projects/{id}/generate-report` | 10 | Report generieren (async). 202 + `{report_id, tracking_project_id, status, status_endpoint}`. **Rate-Limit:** 1 Request / 30 Min / Project — sonst 429 `{error:"rate_limited"}`. Optional `period_days` (7–365, Standard 30). Ohne Guthaben 402 `insufficient_credits`. **Gebucht wird im Job erst nach fertigem Bericht und PDF** (K#9357) — ein fehlgeschlagener Bericht kostet nichts. **Nur das Besitzer-Team** (K#10894): Gäste mit Projektfreigabe bekommen 403 `{error:"not_owner"}`, es wird nichts gebucht. |
| GET    | `/tracking-projects/{id}/reports` | — | Letzte 50 Reports des Projects (sortiert nach `created_at desc`). |
| GET    | `/reports/{id}` | — | Report-Detail inkl. `markdown_content` und `summary_json`. `summary_json.ai_visibility.platforms[]` trägt je Plattform `{platform, visibility, total, sentiment, recommendation_rate, citation_rate, citations_measurable, citations_messbar_seit, measurement_note}` — die letzten drei seit dem 24.09.2026. **Der Inhalt ist GESPEICHERT, nicht gerechnet:** Berichte von vor diesem Datum tragen die drei Felder nicht und werden nicht nachgerechnet. Lies sie deshalb mit Rückfall und behandle einen fehlenden Wert wie `false`, nicht wie „messbar" — sonst druckt ein Leser für eine nie gemessene Plattform wieder `0,0 %`. |
| GET    | `/reports/{id}/pdf` | — | Der fertige Analysebericht als PDF (`application/pdf`, Anhang, K#9357). 404 solange nicht `completed`, 403 ohne Zugriff auf das Projekt. Die Liste `/tracking-projects/{id}/reports` nennt je Zeile `pdf_endpoint`, `period_start`, `period_end`, `locale`. |
| POST   | `/tracking-projects/{id}/geo-report` | 0 | GEO-Auswertung (Kundendokument, K#9245) erzeugen (async). 202 + `{data: {id, tracking_project_id, status, status_endpoint}}`. Optional `b_from`/`b_to` (Berichtszeitraum), `a_from`/`a_to` (Vorlauf), `basis=all\|same_basis`; ohne Fenster die beiden letzten Messungen. 422 `invalid_range`/`range_too_large`/`overlap`/`previous_not_before` (Vorlauf muss vor dem Berichtszeitraum enden), 429 `rate_limited` (1 je Projekt / 30 Min). Siehe `docs/modules/geo-auswertung.md`. |
| GET    | `/tracking-projects/{id}/geo-reports` | — | Letzte 50 GEO-Auswertungen des Projekts: `{id, status, from, to, previous_from, previous_to, basis, locale, generated_at, pdf_endpoint, download_url}`. `download_url` ist ein signierter Browser-Link (30 Min gültig, Anmeldung mit Projektzugriff nötig), null solange nicht `completed`. |
| GET    | `/geo-reports/{id}` | — | Detail einer GEO-Auswertung wie die Listenzeile plus `data` (alle Zahlen des Dokuments). `data.plattformen_nicht_messbar[]` (slug, label, hinweis) nennt Plattformen, die nicht gemessen werden (Amazon Rufus) — getrennt von `data.plattformen`, ohne Zahlen (K#9249). 403 ohne Zugriff auf das Projekt. |
| GET    | `/geo-reports/{id}/pdf` | — | Die fertige GEO-Auswertung als PDF (`application/pdf`). 404 solange nicht `completed`, 403 ohne Zugriff. |
| GET    | `/tracking-projects/{id}/geo-report-dates` | — | Auswertungstermine des Projekts (K#9295): `{id, date, status (scheduled\|processing\|generated\|failed\|no_completed_run), geo_report_id, status_endpoint, processed_at, mailed_at}`. Am Termin entsteht die GEO-Auswertung aus dem letzten abgeschlossenen Lauf und geht als PDF an den Team-Eigentümer. |
| POST   | `/tracking-projects/{id}/geo-report-dates` | 0 | Termin eintragen, Body `date` (`YYYY-MM-DD`, heute oder später). 201; 422 `date_in_past`/`date_exists`/`too_many_dates` (max. 24 offene). |
| DELETE | `/geo-report-dates/{id}` | — | Offenen Termin entfernen; 409 `not_open`, wenn er schon ausgeführt ist. |
| GET    | `/tracking-projects/{id}/geo-report-links` | — | Geteilte Weblinks des Projekts (nur eigenes Team): `{id, url, title, status (active\|revoked\|expired\|maxed), password_protected, expires_at, max_views, views_count, last_viewed_at, revoked_at}`. |
| POST   | `/tracking-projects/{id}/geo-report-links` | 0 | Öffentlichen, nur lesbaren Weblink auf die GEO-Auswertungen erzeugen (Agentur-Branding, noindex). Optional `expires_in_days` (1–365, Standard 30), `password`, `max_views`, `title`. 201; 422 `too_many_links` (max. 20 aktive). |
| DELETE | `/geo-report-links/{id}` | — | Weblink widerrufen; die Seite antwortet danach 410. |

**Status-Werte:** `pending` → `generating` → `completed` (oder `failed`).

**Polling-Pattern:**

```bash
# 1) Report dispatchen
RID=$(curl -s -X POST $BASE/tracking-projects/$PID/generate-report \
  -H "Authorization: Bearer $TOKEN" | jq -r '.report_id')

# 2) Pollen bis completed
while true; do
  S=$(curl -s -H "Authorization: Bearer $TOKEN" $BASE/reports/$RID | jq -r '.status')
  echo "Status: $S"
  [ "$S" = completed ] && break
  [ "$S" = failed ] && exit 1
  sleep 5
done

# 3) Markdown extrahieren
curl -s -H "Authorization: Bearer $TOKEN" $BASE/reports/$RID | jq -r '.markdown_content' > report.md
```

**Response — `POST /tracking-projects/{id}/generate-report` (202):**

```json
{
  "report_id": 42,
  "tracking_project_id": 7,
  "status": "pending",
  "status_endpoint": "/v1/reports/42",
  "message": "Report dispatched"
}
```

**Response — `GET /tracking-projects/{id}/reports` (200):**

```json
{
  "data": [
    {
      "id": 42,
      "tracking_project_id": 7,
      "team_id": 3,
      "user_id": 12,
      "status": "completed",
      "credits_used": 10,
      "generated_at": "2026-04-27T18:14:22+00:00",
      "created_at": "2026-04-27T18:13:40+00:00",
      "updated_at": "2026-04-27T18:14:22+00:00"
    }
  ]
}
```

**Response — `GET /reports/{id}` (200):**

```json
{
  "id": 42,
  "tracking_project_id": 7,
  "team_id": 3,
  "user_id": 12,
  "status": "completed",
  "markdown_content": "# Tracking-Report Project XY\n\n## Executive Summary\n…",
  "summary_json": {
    "headline_kpis": { "avi_score": 64, "delta_30d": "+8", "top10_keywords": 14 },
    "wins": [ "..." ],
    "risks": [ "..." ]
  },
  "credits_used": 10,
  "error_message": null,
  "generated_at": "2026-04-27T18:14:22+00:00",
  "created_at": "2026-04-27T18:13:40+00:00",
  "updated_at": "2026-04-27T18:14:22+00:00"
}
```

**Status-Codes:** 202 (Report dispatched), 200 (List/Detail), 403 (Cross-Team / Cross-Project), 404 (Report/Project nicht gefunden), 429 (Rate-Limit: max 1 / 30 Min).

### Cross-Module Correlation

Aggregiert drei Korrelations-Datenpunkte für einen TrackingProject in einem Roundtrip — Risk-Map (Site-Audit × Rank-Tracker), Backlinks-Velocity × AVI-Trend (Pearson, 30d), Smart-Todos.

| Method | URL | Credits | Beschreibung |
|--------|-----|---------|--------------|
| GET    | `/tracking-projects/{id}/correlation` | — | Cross-Module-Korrelation Snapshot |

**Response — `GET /tracking-projects/{id}/correlation` (200):**

```json
{
  "data": {
    "site_audit_ranking_risks": [
      {
        "keyword_id": 91,
        "keyword": "best crm",
        "search_volume": 4400,
        "position": 5,
        "url": "https://example.com/best-crm",
        "issues_count": 7,
        "critical_count": 2,
        "medium_count": 3,
        "risk_score": 8.4,
        "issues": [
          { "type": "missing_h1", "severity": "critical", "message": "..." }
        ]
      }
    ],
    "backlinks_av_correlation": {
      "correlation_coefficient": 0.612,
      "days_with_data": 14,
      "velocity_total_30d": 23,
      "avi_data_points_30d": 18,
      "backlink_events_30d": 41,
      "observation": "Moderat positive Korrelation: Backlink-Velocity beeinflusst AVI."
    },
    "smart_todos": [
      {
        "priority_score": 84,
        "estimated_impact": "medium",
        "title": "Page-Issues fixen die Top-Ranking gefährden",
        "why": "URL …/best-crm rankt für \"best crm\" auf Position 5 — hat aber 7 Site-Audit-Issues (missing_h1, ...).",
        "reference_type": "tracking_keyword",
        "reference_id": 91,
        "url": "https://example.com/best-crm",
        "keyword": "best crm",
        "position": 5,
        "risk_score": 8.4
      }
    ]
  }
}
```

**Status-Codes:** 200 (Erfolg), 403 (Cross-Team / Cross-Project), 404 (Project nicht gefunden).

---

## Beispiele (curl)

### Wizard-Flow komplett über API

```bash
# 1) Analyse starten
RESP=$(curl -s -X POST $BASE/tracking-projects/analyze \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"mode":"domain","domain":"example.com","language":"de","country":"DE"}')
PID=$(echo $RESP | jq -r .project_id)

# 2) Polling bis completed
while [ "$(curl -s $BASE/tracking-projects/$PID/analysis-status \
  -H "Authorization: Bearer $TOKEN" | jq -r .analysis_status)" != "completed" ]; do
  sleep 3
done

# 3) Atomic Aktivierung
curl -X POST $BASE/tracking-projects/$PID/activate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{
    "keywords":["best crm","crm software"],
    "competitors":[{"domain":"hubspot.com"}],
    "prompts":[{"prompt":"What is the best CRM?","category":"recommendation"}],
    "platforms":["chatgpt","perplexity"],
    "frequency":"weekly",
    "with_search":true,
    "reality_check_enabled":true
  }'
```

### Keyword Explorer mit allen Daten

```bash
curl "$BASE/explorer/176007?include=related,questions" \
  -H "Authorization: Bearer $TOKEN"
# returns: keyword, search_volume, kd_score, parent_topic, traffic_potential,
# serp_urls, monthly_searches[12], related[90], questions[11], cache_hit
```

### Article generieren + publishen

```bash
# Generate
curl -X POST $BASE/articles/$ART/generate \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"keyword":"AI coding tools","article_type":"blog","target_length":1500}'

# Publish to WordPress (CMS-ID 1 vorausgesetzt)
curl -X POST $BASE/articles/$ART/publish \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"cms_integration_id":1}'
```

### Cited-Sources Outreach

```bash
# Liste mit Filter
curl "$BASE/tracking-projects/$PID/cited-sources?outreach_status=neu" \
  -H "Authorization: Bearer $TOKEN"

# Status updaten
curl -X PATCH $BASE/tracking-projects/$PID/cited-sources/$SID \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"outreach_status":"anfrage_gestellt","notes":"E-Mail an redaktion@..."}'

# CSV-Export
curl "$BASE/tracking-projects/$PID/cited-sources/export?from=2026-01-01" \
  -H "Authorization: Bearer $TOKEN" -o sources.csv
```

---

## Konventionen

- **Async Jobs:** Endpoints mit `credits:N` und/oder Job-Dispatch geben `202 Accepted` mit Job-ID. Polling via Detail-Endpoint bis `processing_status == 'ready'`.
- **Cache-Bust:** Endpoints die intern cachen unterstützen `?cache=false` (z.B. Explorer). 50 Credits, einmal gebucht vom Job vor dem Anbieter-Abruf, erstattet bei endgültigem Fehlschlag.
- **Filter via Query:** `?from=&to=&status=&platform=&search=&sort=&limit=&page=`.
- **Pagination:** `?per_page=N&page=N`. Response: `{data:[], meta:{total, per_page, current_page, last_page}}`.
- **Includes:** Wo unterstützt: `?include=related,questions` (kommagetrennt) inlined Sub-Resources statt separater Roundtrips.

## Routes-Generierung

Diese Datei spiegelt `routes/api.php`. Die maschinenlesbare SSOT der `/v1`-Route-Liste
ist `docs/API_ROUTES.generated.json` — pro Endpoint `method`, `uri` (doc-Stil, ohne
`api`-Prefix), `name`, `action` (`Controller@method` | `Closure`) und der aus der
Middleware geparste `credits` + `credits_mode` (`gate` = `credits:N`, `auto` =
`credits-auto:N`). Sortiert nach `(uri, method)`, diff-stabil.

```bash
# Manifest (neu) generieren — nach jeder Route-Änderung ausführen + committen:
php artisan api:generate

# CI-Gate: prüft (ohne zu schreiben), ob das committete Manifest aktuell ist.
# Exit 1 + Diff-Summary bei Drift:
php artisan api:generate --check

# Schnellzählung der Live-/v1-Endpoints (Fallback):
php artisan route:list | grep "api/v1" | wc -l
```

Quelle: `App\Services\Api\RouteIntrospector` (spiegelt exakt
`ApiDocsController::countV1Endpoints()`). Der Parity-Test
`tests/Feature/Docs/ApiDocParityTest.php` erzwingt: (1) Introspector-Count ==
Controller-Count, (2) committetes Manifest aktuell (sonst „Run `php artisan
api:generate`"), (3) jede `/v1`-Route ist in dieser Datei dokumentiert ODER auf der
expliziten `KNOWN_UNDOCUMENTED`-Baseline (fängt neue undokumentierte Routen), (4)
jede in dieser Datei genannte `/v1`-Pfad-Referenz löst auf eine echte Route auf, (5)
Modul-Doc-Freshness (README-Link↔Datei-Parität + Soft-Check der `/api/v1`-Referenzen
in `docs/modules/*.md`).

## Frontend-Doku-Link

Live-Tabellen mit Beispielen: https://rankion.ai/settings/api-documentation

---

## Chat Shares

Public-share API for AI chat sessions. Owner creates a share link from `/api/v1/chat-shares`, recipient views it via `/share/{token}` (public, noindex). All endpoints require `auth:sanctum` and team-scoping.

| Method | URL | Description | Credits |
|---|---|---|---|
| GET | `/v1/chat-shares` | List own share links (paginated, 25/page) | 0 |
| POST | `/v1/chat-shares` | Create new share for owned session (default 7-day expiry) | 0 |
| GET | `/v1/chat-shares/{id}` | Detail + stats | 0 |
| PATCH | `/v1/chat-shares/{id}` | Update settings | 0 |
| DELETE | `/v1/chat-shares/{id}` | Revoke (soft delete; row preserved) | 0 |
| GET | `/v1/chat-shares/{id}/views` | Paginated view log (DSGVO-anonymized) | 0 |
| GET | `/v1/agentic/sessions/{session}/shares` | Shares for a specific session | 0 |

### POST /v1/chat-shares

Body:
- `session_id` (required, integer): id of an `AiChatSession` owned by the authenticated user
- `title` (optional, string)
- `expires_at` (optional, ISO datetime, must be future)
- `password` (optional, string min 4)
- `max_views` (optional, integer min 1)
- Content toggles (optional, boolean):
  - `show_user_messages` (default `true`)
  - `show_assistant_messages` (default `true`)
  - `show_tool_results` (default `false` — recipients see only inputs+outputs, not the tool-call cards)
  - `show_reasoning_steps` (default `false`)
  - `show_uploaded_files` (default `true`)
  - At least ONE toggle must be `true`, else `422`.

Response 201:
```json
{
  "data": {
    "id": 1, "token": "<40 chars>",
    "url": "https://rankion.ai/share/<token>",
    "title": "...", "session_id": 42,
    "expires_at": "...", "frozen_at": null,
    "has_password": false, "max_views": null,
    "view_count": 0, "unique_viewer_count": 0,
    "last_viewed_at": null, "revoked_at": null,
    "status": "active",
    "show_user_messages": true,
    "created_at": "..."
  }
}
```

### PATCH /v1/chat-shares/{id}

Same fields as POST plus `freeze: bool` (true = freeze content snapshot, false = back to live).

### DELETE /v1/chat-shares/{id}

Soft revoke. Sets `revoked_at`. Public access returns 410. Row stays in DB until daily prune job (90 days post-revoke by default, configurable via `CHAT_SHARES_RETENTION_DAYS`).

### Public route (NOT exposed via API)

`GET /share/{token}` — public, no auth, returns HTML. `noindex` enforced via meta + `X-Robots-Tag` header + robots.txt disallow.
`POST /share/{token}/unlock` — password-form submission; throttle 5/min. Sets path-scoped httpOnly cookie on success.
`GET /share/{token}/print` — clean print-CSS variant of the share view.

---

## Agentic Plans

Read-only API for the agentic-superpowers Brainstorm → Plan → Execute pipeline. Plans are created/mutated EXCLUSIVELY by the master agent's pipeline tools (`build_plan`, `complete_step`, `fail_step`, `skip_step`, `complete_plan`, `clarify_intent`); the HTTP API exposes only inspection. All endpoints require `auth:sanctum` and team-scoping (Policy: `view` only — no `update`/`delete` exposed).

| Method | URL | Description | Credits |
|---|---|---|---|
| GET | `/v1/agentic/sessions/{session}/plans` | Plan history for a session (paginated 50/page, ordered desc by created_at) | 0 |
| GET | `/v1/agentic/plans/{plan}` | Plan detail (status, denormalized counters, brainstorm_summary) | 0 |
| GET | `/v1/agentic/plans/{plan}/steps` | All steps with output_summary, output_data (≤64kB JSON), tool_calls_made, credits_used, latency_ms, retry_count, last_error, subagent_session_id | 0 |
| POST | `/v1/agentic/sessions/{session}/abort` | Stops a running turn (Plan #217). No body. Sets `agentic_status='aborting'` via the same atomic claim the UI stop button uses, and closes an active plan through `AgenticPlanService::abort()`. The session is immediately free for a new question — `aborting` is deliberately not in `BUSY_STATUSES`. The turn notices at its next checkpoint (before every LLM and tool call) and ends without a further LLM call; credits already consumed stay charged, nothing is consumed after. Response: `{data:{session_id, aborted, plan_aborted}}`. Returns 409 when no turn is running, 403 for another team's or user's session | 0 |
| POST | `/v1/agentic/feedback` | Persists 👍/👎 feedback on an assistant message. Body: `{message_id: int, direction: 'up'\|'down'}`. Idempotent upsert keyed by (user_id, message_id) — second click overwrites direction. Returns 404 unknown_msg, 403 not_yours, 422 invalid direction. Response: `{ok: true, direction}` | 0 |

### Plan response shape

```json
{
  "data": {
    "id": 12, "session_id": 42, "goal": "Konkurrenz-Analyse example.com",
    "brainstorm_summary": null, "strategy": "linear",
    "status": "executing",
    "current_step_id": 35,
    "total_steps": 4, "completed_steps": 1, "failed_steps": 0,
    "started_at": "2026-05-01T08:30:00Z", "completed_at": null,
    "total_tool_calls": 9, "total_credits_used": 5,
    "created_at": "2026-05-01T08:29:55Z"
  }
}
```

### Step response shape

```json
{
  "data": [
    {
      "id": 35, "step_index": 0,
      "label": "Top-10 SERP-Konkurrenten ermitteln",
      "description": "...",
      "complexity": "trivial", "assigned_agent": null,
      "status": "completed", "retry_count": 0, "last_error": null,
      "output_summary": "10 Konkurrenten: ahrefs.com, semrush.com, ...",
      "output_data": { "competitors": ["ahrefs.com", "..."] },
      "tool_calls_made": 1, "credits_used": 1, "latency_ms": 3120,
      "subagent_session_id": null,
      "started_at": "...", "completed_at": "..."
    }
  ]
}
```

`status` values: `pending` | `in_progress` | `completed` | `failed` | `skipped` (steps); `planning` | `executing` | `completed` | `failed` | `aborted` (plans).
`assigned_agent` values: `null` (master itself) | `master` | `analyze` | `explore` | `research` | `action` (Subagent-Persona).

## 📜 OpenAPI 3.1 Spec + Error-Code-Vokabular (Subsystem D)

Maschinenlesbarer Vertrag für Subsystem A + C — fixed 2026-05-09.

### `GET /api/v1/openapi.yaml`

OpenAPI 3.1.0 Spec (public, kein Auth nötig). 5-min CDN-Cache. Generiert TypeScript/Python/PHP-SDKs aus z.B. [openapi-generator-cli](https://github.com/OpenAPITools/openapi-generator-cli):

```bash
npx @openapitools/openapi-generator-cli generate \
  -i https://rankion.ai/api/v1/openapi.yaml \
  -g typescript-fetch \
  -o ./rankion-sdk-ts
```

**Schema-Coverage:**
- Subsystem A — `/grounding/analyze`, `/audits/{id}`, `/audits`, `/batch`, `/batches/{id}`, `/batches/{id}/results.ndjson`, `/sitemap-audit`
- Subsystem B — `/content-optimizer/{id}` (canonical + alias)
- Subsystem C — `/agentic/chat`, `/agentic/sessions/{session}`
- Reusable schemas: `GroundingAudit`, `GroundingBatch`, `Finding`, `Tier`, `StructuredError`, `AgenticChatRequest/Response`, `AgenticSessionStatus`

Andere Endpoints sind weiterhin nur in dieser Markdown-Reference dokumentiert; eine vollständige OpenAPI-Export-Phase ist auf der Roadmap.

### Error-Code-Vokabular (kanonisch)

Alle `code` Werte aus dem `error: {code, ...}` Envelope (Subsystem A + C):

| HTTP | code | Endpoint | Beschreibung |
|---|---|---|---|
| 422 | `context_overflow` | /agentic/chat | 1M-Token-Limit überschritten. Body: `limit_tokens, used_tokens, suggested_action, max_urls_per_call` |
| 502 | `anthropic_api_error` | /agentic/chat | Anthropic-Upstream 5xx. Body: `http_status, message` |
| 404 | `audit_not_found` | /grounding/audits/{id} | ID unbekannt ODER fremdes Team (404 statt 403 gegen Enumeration) |
| 410 | `audit_expired` | /grounding/audits/{id} | TTL gepruned (30d soft + 60d hard) |
| 404 | `batch_not_found` | /grounding/batches/{id} | ID unbekannt ODER fremdes Team |
| 422 | `invalid_url` | /grounding/* | Kein gültiger http(s)-URL |
| 422 | `invalid_framework` | /grounding/* | Framework außerhalb `[v1.5, eeat, people-first]` |
| 422 | `too_many_urls` | /grounding/batch | Batch > 500 URLs |
| 402 | `insufficient_credits` | /grounding/*, /content-optimizer/* | Team-Balance < required |
| 503 | `sitemap_unreachable` | /grounding/sitemap-audit | Sitemap-Parse fehlgeschlagen |
| 404 | `session_not_found` | /agentic/sessions/{id} | Triple-scoped IDOR-Schutz greift |
| 429 | `rate_limited` | (alle Endpoints mit `throttle:`) | Standard Laravel-Throttle. Header `Retry-After` setzt Wartezeit |

### Rate-Limit-Headers

Jeder Endpoint mit `throttle:N,M` Middleware liefert auf erfolgreiche Responses:
- `X-RateLimit-Limit: N` (Anfragen pro Window)
- `X-RateLimit-Remaining: <int>` (verbleibend im aktuellen Window)
- `X-RateLimit-Reset: <epoch>` (Unix-Timestamp wann Counter resettet)

Bei 429: zusätzlich `Retry-After: <seconds>`. Counters per Sanctum-Token, nicht per IP.

Throttle-Werte pro Endpoint stehen in der Endpoint-Tabelle der jeweiligen Sektion (oben). Aktuelle Werte:
- `/grounding/analyze`: 30/min · `/grounding/batch`, `/sitemap-audit`: 5/min · `/grounding/audits/{id}`, `/batches/{id}`: 120/min · `/grounding/audits` (Liste): 60/min · `/grounding/batches/{id}/results.ndjson`: 30/min · `/agentic/sessions/{session}`: 120/min · `/agentic/chat`: kein expliziter Throttle (Cap durch interne max_iterations + Anthropic-Token-Limits).

---

## 🤖 Master-Agent Härtung (Subsystem C)

Drei Härtungen am `/api/v1/agentic/chat` Flow für robustere programmatische Caller — fixed 2026-05-09.

### P1.4 — Strukturierter `context_overflow` Error

Wenn der Agent das 1M-Token-Kontext-Limit hittet, returnt `/agentic/chat` jetzt **HTTP 422** mit strukturiertem Error-Envelope statt 200+German-Text:

```json
{
  "session_id": 1201,
  "error": {
    "code": "context_overflow",
    "limit_tokens": 1000000,
    "used_tokens": 1042000,
    "suggested_action": "split_request",
    "max_urls_per_call": 1
  },
  "text": "⚠️ Diese Anfrage ist zu komplex geworden …"
}
```

Programmatischer Retry ohne String-Matching auf Deutsch. Bei Anthropic-API-Errors (5xx upstream) → HTTP **502** mit `error.code = "anthropic_api_error"`.

### P1.5 — `tool_policy` im Request

Restriktiere welche Tools der Master-Agent in einem Turn nutzen darf — ideal für Audit-Loops, die nur 1-Tool-Calls wollen ohne Token-Blow:

```json
{
  "message": "Audit https://example.com",
  "tool_policy": {
    "allow": ["analyze_grounding_page"],
    "deny": ["web_scrape", "extract_from_urls"],
    "max_tool_calls": 1
  }
}
```

Server-side Enforcement: gesperrte Tools liefern Tool-Result `{success:false, error:"tool X blocked by tool_policy.deny", policy_blocked:true}` — der Agent ignoriert sie und liefert ein Teil-Resultat statt das Limit zu sprengen.

### P1.6 — Async-Default + Polling-Endpoint

`/agentic/chat` blockiert standardmäßig **NICHT mehr** bis zum finalen Resume. Wenn der Agent mid-turn `wait_and_recheck` ausgelöst hat, returnt der Endpoint **HTTP 202** sofort:

```json
{
  "session_id": 1201,
  "agentic_status": "waiting",
  "eta_seconds": 120,
  "poll_url": "/api/v1/agentic/sessions/1201",
  "resumed": false,
  "text": "⏳ Ich warte auf Hintergrund-Daten…"
}
```

Caller polled dann `GET /api/v1/agentic/sessions/{id}`:

```json
{
  "session_id": 1201,
  "agentic_status": "completed",
  "message_id": 4567,
  "text": "Audit fertig: Score 75/100…",
  "last_message_at": "2026-05-09T14:32:01+00:00",
  "is_terminal": true
}
```

`agentic_status`: `null` (idle) | `running` | `waiting` | `awaiting_user` | `failed` | `completed`. `is_terminal=true` heißt: keine weitere Hintergrund-Arbeit pending (`awaiting_user` ist NICHT terminal — es wartet auf eine User-Antwort).

**Wartezustand mit Runde (Plan #305, 2026-09-22).** `POST /agentic/chat` (202) und `GET /agentic/sessions/{id}` tragen zusätzlich:

```json
{
  "waiting": { "until": "2026-09-22T08:31:10+02:00", "reason": "Optimizer-Run läuft", "round": 2, "max_rounds": 3 },
  "wait_limit_reached": false
}
```

`waiting` ist ein Objekt, solange die Session auf einen Hintergrundlauf wartet (`agentic_status='waiting'`), sonst `null`. `round` zählt die Warte-Runden in Folge (jede Runde ist ein eigener, berechneter Zug), `max_rounds` die Obergrenze (`config('agentic.wait_and_recheck.max_consecutive_rounds')`, Vorgabe 3). Ist sie erreicht, endet der Zug mit einer Standmeldung und `wait_limit_reached=true` — der Chat wartet nicht weiter, der Caller holt das Ergebnis über den Link im Text oder stellt die Frage später erneut.

**Opt-in legacy blocking:** `POST /agentic/chat?wait_for_final=true` → server polled bis 480s und liefert das finale Result inline (alte Verhalten).

### Rückfragen / Bestätigungen über die API (`ask_user_question`, 2026-06-10)

Ruft der Agent das `ask_user_question`-Tool (oder ein Plan-Confirmation-Gate), pausiert die Loop mit `agentic_status='awaiting_user'`. `POST /agentic/chat`, `GET /agentic/sessions/{id}` und `POST /agentic/sessions/{id}/answer-question` hängen dann zusätzlich an:

```json
{
  "agentic_status": "awaiting_user",
  "is_terminal": false,
  "awaiting_user": true,
  "pending_question": "Soll ich den teuren Voll-Pull starten (120 Credits)?",
  "pending_question_options": [
    {"label": "Ja, starten (Empfohlen)", "description": "Kostet 120 Credits"},
    {"label": "Nein, abbrechen", "description": ""}
  ],
  "answer_url": "/api/v1/agentic/sessions/1201/answer-question"
}
```

Antworten: `POST /agentic/sessions/{id}/answer-question` mit `{ "option_label": "Ja, starten (Empfohlen)" }` → resumed die Loop synchron (Response-Shape = `/agentic/chat`; enthält erneut die `awaiting_user`-Felder, falls direkt eine Folge-Frage kommt). Damit funktionieren Rückfragen + Aktions-Bestätigungen via API genauso wie im Chat-UI. Die Entscheidung WANN der Agent fragt ist advisory (Tool-Beschreibung empfiehlt es bei >50-Credit-Ops) — kein erzwungener Gate.

---

## 🧭 Grounding Audit Pipeline (Subsystem A)

Async-Pipeline für LLM-Citation-Readiness-Audits über öffentliche URLs. Single + Bulk + Sitemap mit NDJSON-Stream und HMAC-signierten Webhook-Callbacks.

**Frameworks:**
- **Grounding Page Standard v1.5** (Hanns Kronenberg, https://groundingpage.com/spec/de/) — 22 Page- + 4 Site-Root-Checks
- **Google E-E-A-T** (Search Quality Rater Guidelines + Helpful Content) — *aktuell nur Master-Agent-Pfad, REST-Pipeline-Stub*
- **Google People-First + AI-Features** (developers.google.com) — *aktuell nur Master-Agent-Pfad, REST-Pipeline-Stub*

| Method | URL | Beschreibung | Credits |
|---|---|---|---|
| POST | `/v1/grounding/analyze` | Single-URL-Audit dispatchen. Body: `{url, frameworks?, tracking_project_id?, seo_project_id?}`. Throttle: 30/min. Returns 202 + `{audit_id, status, estimated_seconds, poll_url}` + `Location:` Header. | 1 |
| GET | `/v1/grounding/audits/{id}` | Polling/Result. Returns `{audit_id, status, score, tier:{name,threshold_min,threshold_max}, frameworks{}, findings[], raw_text, duration_ms, credits_used, started_at, completed_at, expires_at, ui_url}`. `ui_url` (string) = relativer UI-Pfad `/grounding/audits/{id}` — kanonischer Detail-Link fürs Team (nie selbst konstruieren). 404 (audit_not_found), 410 (audit_expired). | 0 |
| GET | `/v1/grounding/audits` | Liste eigener Audits (jede Row inkl. `ui_url` und `seo_project_id`). Filter: `?status=, ?tracking_project_id=, ?seo_project_id=, ?from=, ?to=`, Pagination. Plan #183: `?domain=` (nur Audits dieses Hosts) · `?sort=created\|url\|status\|score\|technical\|content` · `?dir=asc\|desc` · `?q=` (URL) · `?per_page=25` (max 100, `alle` = bis 1000); unbekannter Wert → 422. `seo_project_id` = SEO-Projekt (`projects.id`), muss dem eigenen Team gehören, sonst 422; `project_id` bleibt hier 422 (Variante-1-Rename). `?without_project=1` = nur Audits ohne SEO-Projekt; jede Row trägt dann `suggested_seo_project_id` (Domain-Vorschlag, `null` bei keinem/mehreren Treffern); zusammen mit `seo_project_id` → 422. | 0 |
| POST | `/v1/grounding/audits/assign-project` | Ordnet Audits des eigenen Teams gesammelt einem SEO-Projekt zu. Body `{audit_ids: int[] (1–500), seo_project_id}`. Synchron 200 `{seo_project_id, requested, assigned, not_found}` — `not_found` = unbekannte + fremde + bereits zugeordnete Nummern (keine Enumeration; geschrieben wird nur auf Audits ohne Projekt). Fremdes Projekt oder ohne Projektrecht → 422, `project_id` → 422. Throttle 30/min. | 0 |
| POST | `/v1/grounding/batch` | Bulk-Audit für 1–500 URLs. Body: `{urls[], frameworks?, concurrency? (1-10), callback_url?, tracking_project_id?, seo_project_id?}`. `seo_project_id` = SEO-Projekt (`projects.id`, eigenes Team, sonst 422 ohne Batch und ohne Abbuchung) — wird auf dem Batch gespeichert und in JEDE Audit-Zeile kopiert; `project_id` bleibt 422. Throttle: 5/min. Returns 202 + `{batch_id, seo_project_id, url_count, callback_secret, eta_seconds, poll_url, results_url}`. `callback_secret` einmalig. | url_count |
| GET | `/v1/grounding/batches/{id}` | Batch-Status + Counters. Returns `{batch_id, seo_project_id, status, source, completed, failed, total, concurrency, eta_seconds, results_url, callback?, started_at, completed_at}`. | 0 |
| GET | `/v1/grounding/batches/{id}/results.ndjson` | NDJSON-Stream completed/failed Audit-Rows. Streamt während Lauf. | 0 |
| POST | `/v1/grounding/sitemap-audit` | Sitemap-Audit. Body: `{sitemap_url, frameworks?, filter:{path_contains?,limit? (max 500),priority_min?}, concurrency?, callback_url?, tracking_project_id?, seo_project_id?}`. `seo_project_id` wie bei `/batch` (eigenes SEO-Projekt, sonst 422; landet auf Batch und jeder Audit-Zeile). Credits werden NACH Sitemap-Parse abgezogen. Returns 202 + `{batch_id, seo_project_id, url_count:0, callback_secret, poll_url, results_url}`. | url_count_after_parse |
| GET | `/v1/grounding/project-run/preview` | Vorschau eines Projektlaufs (Karte #7397). Query: `seo_project_id` (eigenes SEO-Projekt im Rahmen der Projektrechte, sonst 422), `mode=top\|all`, `pages?` (1–50, Standard 10). `top` = Search-Console-Seiten nach Klicks (28 Tage), ohne Search-Console-Daten Rückfall auf die Sitemap; `all` = Sitemap der Projekt-Domain. Returns `{seo_project_id, mode, source: gsc\|sitemap\|null, pages, pages_exact, urls[], sitemap_url, max_pages, credits_per_page, credits_total}`. | 0 |
| POST | `/v1/grounding/project-run` | Startet den Projektlauf. Body: `{seo_project_id, mode, pages?, max_credits}` — `max_credits` ist die Bestätigung und muss ≥ `credits_total` der Vorschau sein (sonst 422 `credits_not_confirmed`). 402 `insufficient_credits`, wenn das Guthaben den Höchstwert nicht deckt; 422 `no_page_source` ohne Search Console und ohne Domain. Gebucht wird je tatsächlich geprüfter Seite (idempotent je Seite), eine endgültig gescheiterte Seite wird erstattet. Returns 202 + Vorschaufelder + `{batch_id, poll_url, results_url}`. | pages_audited (≤ credits_total) |

### Tier-Schwellen

| Score | Tier |
|---|---|
| 0–25 | not-grounded |
| 26–50 | weak |
| 51–75 | partial |
| 76–100 | grounded |

### Findings-Schema

Jedes Audit liefert `findings[]` mit Objekten: `{id, severity (critical|high|medium|low|info), framework (v1.5|eeat|people-first), title, description, fix:{type, action, details}, spec_url}`. Zusätzlich `raw_text` (Markdown) für UI-Render.

### Webhook-Verifikation

Server POSTet zu `callback_url` mit Header `X-Rankion-Signature: sha256=<hex>` (HMAC-SHA256 über Body, Key = `callback_secret`) + `X-Rankion-Webhook-Id: <uuid>`. 4 Retries (60/300/1800/7200s) bei 5xx oder 429. Verifikation:
```php
$expected = 'sha256=' . hash_hmac('sha256', $rawBody, $callbackSecret);
hash_equals($expected, $request->header('X-Rankion-Signature'));
```

### Error-Codes (Subsystem A)

| HTTP | code |
|---|---|
| 422 | invalid_url, invalid_framework, too_many_urls |
| 402 | insufficient_credits |
| 404 | audit_not_found, batch_not_found |
| 410 | audit_expired |
| 503 | sitemap_unreachable |
| 429 | rate_limited |

**Beispiel — Single-URL Audit:**
```bash
ID=$(curl -sX POST -H "Authorization: Bearer $RANKION_API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"url":"https://example.com","frameworks":["v1.5"]}' \
     https://rankion.ai/api/v1/grounding/analyze | jq -r .audit_id)

# Poll
sleep 30
curl -H "Authorization: Bearer $RANKION_API_TOKEN" \
     https://rankion.ai/api/v1/grounding/audits/$ID
```

**Beispiel — Sitemap-Batch:**
```bash
curl -X POST -H "Authorization: Bearer $RANKION_API_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"sitemap_url":"https://example.com/sitemap.xml","filter":{"path_contains":"/blog","limit":50},"callback_url":"https://my-app/hook"}' \
     https://rankion.ai/api/v1/grounding/sitemap-audit
```

**Knowledge-Base intern:** `docs/knowledge/grounding-pages-geo.md`. Modul-Doku: `docs/modules/grounding-audit.md`.

## 🧬 GEO Criteria Catalog

Read-only Discovery-Endpoint für die GEO-/LLM-Citation-Kriterien — die Single Source of Truth (`App\Support\Geo\GeoCriteriaRegistry`), die den Grounding-Audit, den GEO-Content-Score, die Generierungs-Prompts (`ContentGeneratorService` — live) und die Off-Page-/Demand-Achse (`OffPageReadinessScorer`) speist. Lässt GEO-Optimizer-Agenten entdecken, **welche** Signale Rankion prüft, mit welchem Engine-Signal, welcher Evidenz-Stufe und an welchen Oberflächen (audit/score/prompt/offpage). Synchron, 0 Credits, kein Polling.

| Method | URL | Beschreibung | Credits |
|---|---|---|---|
| GET | `/v1/geo-criteria` | GEO-Kriterien-Katalog. Optional `?category=<cat>` (z.B. `entity`, `extraction`, `schema`, `evidence`, `eeat`, `indexability`, `offpage`). Returns `{data: [{id, label, description, category, engine_signal, dimension, evidence_tier, study_source, score_weight, shapes[]}]}`. Read-only. | 0 |
| GET | `/v1/tracking-projects/{id}/off-page-readiness` | Off-Page-/Demand-Achsen-Score eines Tracking-Projekts. Team-scoped, read-only. | 0 |

**Response-Felder (`/v1/geo-criteria`):**
- `id` — stabile Kriterien-ID, identisch mit der Grounding-Finding-ID bzw. dem GEO-Score-Breakdown-Key (`gp.h1.entity_only`, `geo.evidence.citation_density`, `geo.offpage.brand_demand`, …).
- `engine_signal` — referenziertes Engine-Capability-Matrix-Signal (`config/engines.php`).
- `dimension` — `technical` (Signal `indexability`/`crawlability`) oder `content`. Abgeleitet, nie driftend.
- `evidence_tier` — `A`/`B` Default-**Hint**. Der wirksame Tier eines konkreten Findings kommt aus `/v1/grounding/audits/{id}` (Laufzeit-Auflösung über die Engine Capability Matrix).
- `shapes[]` — Oberflächen, an denen das Kriterium teilnimmt: `audit` (Page- oder Site-Root-Detektor), `score`, `prompt`, `offpage` (Demand-Achse, `geo.offpage.*`).

### Off-Page-Readiness (Demand-Achse)

`GET /v1/tracking-projects/{id}/off-page-readiness` — aggregierter Off-Page-Score eines Tracking-Projekts (gewichtetes Mittel über die `data_available`-Signale). Team-scoped: Fremd-Team → **403**, unbekannte ID → **404**. 0 Credits.

```json
{
  "score": 87,
  "data_available": true,
  "components": {
    "geo.offpage.brand_demand":        { "score": 100, "data_available": true, "meta": { "brand_keyword": "rankion", "search_volume": 12000 }, "weight": 25 },
    "geo.offpage.third_party_mentions": { "score": 70,  "data_available": true, "meta": { "third_party_count_90d": 30, "window_days": 90 }, "weight": 20 },
    "geo.offpage.source_affinity":     { "score": 100, "data_available": true, "meta": { "distinct_third_party_types": 3, "has_wiki": true }, "weight": 15 }
  },
  "meta": { "tracking_project_id": 17, "credits": 0 }
}
```

- `score` ist `int|null` — **`null` ⇒ noch keine Daten, NICHT 0**. `data_available` unterscheidet beide Fälle; jedes `components.*` trägt sein eigenes `data_available` + `meta.reason`.
- Voraussetzungen für non-null Daten: `brand_demand` braucht `brand_name` + passendes Tracking-Keyword mit `search_volume`; `third_party_mentions` braucht aktivierten Community-Monitor (`CommunityProject` am selben `Project`); `source_affinity` braucht Citation-Historie (`cited_sources`).
- **ID-Bridge:** Der Scorer löst `community_project_id` (Drittseiten-Mentions) vs. `tracking_project_id` (Brand-Demand + Quellen-Affinität) pro Faktor auf — via `CommunityProject.project_id == TrackingProject.project_id`.
- v2-Envelope (`Accept: application/vnd.rankion.v2+json`): `{data: {score, data_available, components}, meta}`.

**Read-only:** Detector-/Factor-Objekte werden nie serialisiert — nur skalare Metadaten. Den Katalog einmal ziehen, dann damit Grounding-Audit (`/v1/grounding/analyze`), Content-Optimizer bzw. Off-Page-Readiness ansteuern.

**Skill-Manifeste:** `grounding.geo-criteria`, `tracking.off-page-readiness`. **Modul-Doku:** `docs/modules/geo-criteria-catalog.md`.

## 📚 Wiki

Public + internal wiki content. Markdown-Files in `docs/wiki/` werden via `php artisan wiki:sync` (auto bei jedem Deploy) in die DB gespiegelt. Slug-Hierarchie via `/`: `modules/backlinks`. Wiki-interne Links via `[[slug]]` oder `[[slug|Anzeigetext]]`.

| Method | URL | Beschreibung | Credits |
|---|---|---|---|
| GET | `/v1/wiki/pages` | Liste aller Pages (paginiert 50/Seite). Filter: `?category=modules`, `?visibility=public\|internal`. Sanctum-User sehen auch `internal`-Pages. | 0 |
| GET | `/v1/wiki/pages/{slug}` | Single Page mit `body_md`, `body_html`, `toc[]` (level/text/anchor), `related[]`. Slug akzeptiert Slashes für Hierarchie. | 0 |
| GET | `/v1/wiki/search?q=...` | Top-20 FULLTEXT-Treffer (title+description+body_md). Title-Boost 2×. Snippets mit `<mark>`-highlighted Tokens. Min. 2 Zeichen. | 0 |

Public-Reader: `https://rankion.ai/wiki/{slug}` — server-rendered HTML, Sidebar+TOC, `/`-Shortcut für Search-Focus. `visibility: internal` → 401 für anonyme User.

Master-Agent-Integration: `query_wiki(query)` Master-Tool returnt Top-3 mit vollen URLs — Trigger bei "wie geht X", "wie aktiviere ich Y", "was ist Z?".

## 🧠 Deep Insights (LLM Data Mining)

Tiefenanalyse über alle 6 Mining-Schichten (Atomisierung → Quellenscan → Cross-Pattern → Faktencheck → Forecast → Action). Liefert pro Projekt einen 9-Sektionen-Report (Executive Summary, Faktenfehler, Konsens-Narrative, Persona-Lücken, Outreach Top-50, Wettbewerbs-Co-Occurrence, Frühwarn-Radar, Source-Whitelist, 6-Wochen-Forecast). Voraussetzung: Atomisierung läuft (Layer 1 backfill); für Faktencheck zusätzlich Truth-Source mit `human_reviewed=true`.

| Method | URL | Beschreibung | Credits |
|---|---|---|---|
| GET | `/v1/data-mining/insights/{project}` | Voller 12-Sektionen-JSON-Payload (alle Sections in einer Response). | 0 |
| POST | `/v1/data-mining/insights/{project}/refresh` | Triggert Insights-Report-Generierung (PDF + CSV-ZIP-Bundle, asynchron). HTTP 202. Abgebucht wird im Job nach erfolgreicher Erzeugung (`data_mining_export`). Nur das Besitzer-Team des Projekts; Gäste mit Projektfreigabe bekommen 403. | 10 |
| GET | `/v1/data-mining/insights/{project}/section/{section}` | Einzel-Section-Lazy-Load. `{section}` ∈ `executive_summary, fact_errors, consensus_narratives, consensus_clusters, persona_gaps, battle_cards, outreach_top50, competitor_cooccurrence, early_warnings, source_whitelist, forecast_6w, recommended_actions`. | 0 |
| GET | `/v1/data-mining/exports/{project}.pdf` | Letzten generierten PDF-Export downloaden. 404 falls noch keiner existiert (vorher `refresh` aufrufen). | 0 |
| GET | `/v1/data-mining/exports/{project}.zip` | Letztes CSV-Bundle (1 CSV pro Section, gezippt). 404 wenn nicht vorhanden. | 0 |

Alle Endpoints Sanctum-authed mit Team-Scope-Check (`tracking_projects.team_id === user.currentTeam->id`, sonst 403).

**Wöchentlicher Insights-Report:** Jeden Sonntag 18:00 wird für alle Projekte der PDF-Report generiert und mit Highlights-Summary an den Team-Owner gemailt.

**Live-Dashboard:** `https://rankion.ai/ai-visibility/{id}/insights` — interaktive 9-Tab-Ansicht des gesamten Payloads.


## 🧬 Citation Causality (CCE)

Layer-7-Engine über `tracking_cited_sources`: extrahiert pro Citation strukturierte Dimensionen (Content-Format, Schema-Types, E-E-A-T-Signale, Pricing/FAQ-Indikatoren, Domain-Authority), vergleicht gegen Google-SERP-Top-10-Counterfactuals und produziert Lift-Scores als Hebel-Empfehlungen. Drei Tiers: per Project (Tier 1), Account (Tier 2), Admin Global (Tier 3).

| Method | URL | Beschreibung | Credits |
|---|---|---|---|
| GET | `/v1/citation-causality/project/{projectId}/patterns` | Alle Pattern-Rows (Dimension × Wert × Lift-Score × p-Value) für ein Projekt. Lift>1 = überproportional bei zitierten Pages. | 0 |
| GET | `/v1/citation-causality/project/{projectId}/recommendations` | Priorisierte Hebel-Karten P1-P4 nach Lift × Signifikanz × Sample-Size; mit `description` (LLM-Klartext) und Top-3 Evidence-Citations. | 0 |
| GET | `/v1/citation-causality/project/{projectId}/audit?url=<sitemap_url>` | Per-Page-Compliance-Audit: extrahiert Dimensionen der URL via Ollama, vergleicht mit Top-Patterns, liefert Gap-Liste + estimated_uplift. | 1 |
| POST | `/v1/citation-causality/project/{projectId}/audit/bulk` | Bulk-Audit async für bis zu 200 URLs. Body `{urls: [...]}`. Returnt 202 + `audit_id`. Job läuft auf Queue `ollama` (ca. 30s pro URL). | 10 |
| GET | `/v1/citation-causality/project/{projectId}/audit/bulk/{auditId}` | Status-Poll für Bulk-Audit. Returns `status` (pending/processing/completed/failed) + `results` (Array von Per-URL-Resultaten). | 0 |
| GET | `/v1/citation-causality/account/cross-project` | Cross-Project-Patterns über alle Projekte des Account-Teams. Nutzt für Tier-2-Dashboard "Welcher Hebel funktioniert wo?". | 0 |
| GET | `/v1/citation-causality/admin/global` | Globale Patterns über alle Customers/Branchen. **Admin-only** (401 sonst). Speist Tier-3-Strategie-Dashboard. | 0 |
| GET | `/v1/citation-causality/admin/sandbox/runs` | Liste aller Sandbox-Runs (paginiert 25). Admin-only. Felder: name, status, total_citations_target, started_at, completed_at, estimated_cost_cents. | 0 |
| POST | `/v1/citation-causality/admin/sandbox/runs` | Neuen Sandbox-Run starten. Body: `{name, catalog_id, ollama_model_primary, ollama_model_fallback, prompt_template_version, citation_filter:{project_ids,limit}, serp_counterfactual_enabled, dataforseo_domain_analytics_enabled}`. Returnt 202 + run_id. | 0 |
| GET | `/v1/citation-causality/admin/sandbox/runs/{runId}` | Run-Detail mit verschachtelten Citations + Counterfactuals + Patterns. Admin-only. | 0 |
| POST | `/v1/citation-causality/admin/sandbox/runs/{runId}/promote` | Promote eines Sandbox-Runs nach Production (Plan 3). Aktuell 501 bis Plan 3 implementiert. | 0 |
| GET | `/v1/citation-causality/projects/{project}/recipe` | **Plan 5 Phase E** — Content-Recipe (briefing + 3-5 title_suggestions + bullets + CTA + confidence) für das Team via `ContentRecipeSynthesizer`. 24h Cache (key: `cce.recipe.t<id>.<sha>`). Bei Top-3 Hebel `n_cited<5` → `{available:false, reason:"insufficient_sample"}`. | 0 |
| GET | `/v1/citation-causality/projects/{project}/levers` | **Plan 5 Phase E** — `{top, unique, anti}` Hebel-Buckets mit Klartext-Labels (`key_label`, `value_label`) + `interpretation` (`headline`, `explanation`, `action`). Optional `?include=trend,by_llm` lazy-expansion pro Lever. Bounded auf 30+20+15 Rows. | 0 |
| GET | `/v1/citation-causality/projects/{project}/levers/{key}/{value}/drill-down` | **Plan 5 Phase E** — Per-Lever Tiefenansicht: `details` (cited URLs + SERP-Counterfactuals + counts), `trend` (Time-Axis-Verlauf), `by_llm` (Per-LLM-Provider Lift-Vergleich). Path-Param `key` regex `[a-z][a-z0-9_]{0,63}`. | 0 |
| POST | `/v1/citation-causality/projects/{project}/audit` | **Plan 5 Phase E** — Dispatched `AuditTopPagesAgainstHebelJob` für Top-N own-domain URLs gegen Top-5 Hebel. Body optional `{limit: 10}` (1-50, default 10). Returnt 202 + `{job, project_id, team_id, estimated_cost_credits}`. Polling via Bestehender `/audit/bulk/{auditId}`. | 5 |

Alle Endpoints Sanctum-authed + Team-Scope (`tracking_projects.team_id === user.currentTeam->id`).

**Skill-Caller-Idiom (für Master-Agent / Claude Code Skill / externe Agenten via REST):**
1. **Trigger:** `POST /admin/sandbox/runs` mit `citation_filter:{project_ids:[N], limit:30}` + `serp_counterfactual_enabled:true`
2. **Polling:** `GET /admin/sandbox/runs/{runId}` alle 30s bis `status: completed` (Run dauert 5-15 min)
3. **Iteration:** Bei unzufriedenstellenden Patterns: Catalog-Editor unter `/admin/citation-causality-lab/catalog` für Klon + Anpassung, dann neuer Run mit `dimensions_catalog_id: <neue_id>`
4. **Bulk-vs-Single:** Tier-1 Per-Page-Audit (1 URL on-demand) vs Tier-3 Globale Pattern-Pipeline (alle Citations)
5. **Abschluss:** Lift-Scores aus `cc_patterns` + Klartext-Insights aus `synthesized_text`-Spalte. P1-P4-Priorisierung via `/project/{projectId}/recommendations`.

**Skill-Caller-Idiom Plan-5-Phase-E (Insight-to-Action über REST):**
1. **Recipe abrufen:** `GET /projects/{id}/recipe` — Cache-Hit liefert sofort, sonst <30s glm-4.7 Call
2. **Lever-Übersicht:** `GET /projects/{id}/levers` für Top-Buckets, optional `?include=trend,by_llm` für detaillierte Inspection
3. **Drill-Down:** `GET /projects/{id}/levers/{key}/{value}/drill-down` für Evidence (cited URLs + SERP-Counterfactuals)
4. **Audit dispatchen:** `POST /projects/{id}/audit` (5 Credits) → 202 + Job-Marker; Status via existierenden `/audit/bulk/{auditId}` Endpoint pollen
5. **Anti-Patterns:** Recipe vor genug Sample-Data → leerer Output (sample-floor returnt `available:false`); Drill-Down auf bad-format Key → 404

**Live-Dashboards:**
- Per-Project (Tier 1): `https://rankion.ai/ai-visibility/{projectId}?activeTab=citation-causality`
- Account (Tier 2): `https://rankion.ai/account/citation-intelligence`
- Admin Global (Tier 3): `https://rankion.ai/admin/citation-causality-intelligence`

---

## ⚡ Prefetch (Rankion Turbo)

Team-aggregiertes Markov-„Next-Route"-Modell für die intelligente Prefetch-Schicht (siehe `docs/modules/turbo-prefetch.md`). Team-gescopt, `auth:sanctum` (First-Party via Session-Cookie).

| Methode | URL | Beschreibung | Credits |
|---|---|---|---|
| POST | `/v1/prefetch/transition` | Navigations-Übergang melden. Body: `{from, to}` (interne Pfade `^/…`, kein Query/Signatur; `from≠to`). `firstOrCreate`+`increment`. 422 bei ungültigen Pfaden/Self-Loop. Throttle 60/min. | 0 |
| GET | `/v1/prefetch/predictions?from=&limit=` | Top-N team-aggregierte Folge-Routen für `from`. Returns `{from, predictions:[{to_route, count, prob}]}` (prob = count/Σcount, sortiert desc). `limit` 1–10 (Default 5). | 0 |
| GET | `/v1/prefetch/config` | Client-Config: `{enabled, weights, limit, prerender_allowlist}`. | 0 |

**Workflow:** nach jeder Navigation `transition` melden (fire-and-forget) → beim Einstieg `predictions?from=<pfad>` lesen → client-seitig mit lokalem localStorage-Markov blenden (lokal führend, Server = Cold-Start). Kein Polling, keine Job-Lifecycles.

---

## 🔐 Account / Sicherheit

User-scoped (NICHT team-scoped) — Konto-Sicherheitsstatus lesen, Passwort ändern, andere Sitzungen beenden. `auth:sanctum`, gelöst über den authentifizierten User (`$request->user()`), kein Team-Scope.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET    | `/account` | Sicherheitsstatus: `{id, name, email, email_verified, two_factor_enabled, auth_provider, pending_email}`. **Enthält NIEMALS Secrets** (kein `two_factor_secret`, keine `recovery_codes`, kein `pending_email_token`). | — |
| PUT    | `/account/password` | Passwort ändern. Body: `{current_password, password, password_confirmation}`. `current_password` ist serverseitig Pflicht (422 sonst) — ein Token allein reicht NICHT. Google-Konten → 422 (Passwort per Reset-Link). Beendet andere Web-Sitzungen und rotiert das Remember-Token; API-Tokens bleiben gültig (Karte #10872). throttle:10,1. | — |
| DELETE | `/account/sessions` | Beendet alle Web-Sitzungen des Users (löscht nur dessen eigene `sessions`-Rows) und rotiert das Remember-Token, damit ein Gerät mit »angemeldet bleiben« abgemeldet bleibt (K#10886); API-Tokens bleiben gültig. Response: `{message, deleted}`. throttle:10,1. | — |
| GET    | `/account/notification-preferences` | Benachrichtigungs-Präferenz-Matrix (Default-aufgelöst): `{data:{email:{reports,tracking_alerts,job_results}, inapp:{…}, locked:{billing,security,team}}}`. `email`/`inapp` = abbestellbare Kategorien als bool (Default `true`); `locked` = immer-aktive Kategorien. | — |
| PUT    | `/account/notification-preferences` | Präferenzen setzen. Body: `{email?:{reports?:bool, tracking_alerts?:bool, job_results?:bool}, inapp?:{…}}`. NUR optionale Kategorien werden berücksichtigt (nicht-optionale `billing/security/team` werden ignoriert). Es werden NUR Abweichungen (`false`) persistiert; alles-an ⇒ `NULL`. Antwort = GET-Shape (round-trip). | — |

### Sicherheits-Ausnahme — bewusst NICHT per API exponiert

2FA-Setup/-Deaktivierung, E-Mail-Änderung und Konto-Löschung sind **absichtlich** nur in der Web-Oberfläche verfügbar, nicht über die REST-API. Begründung: Ein geleakter Sanctum-Token darf weder 2FA abschalten noch die E-Mail-Adresse übernehmen noch das Konto löschen können. Die Passwort-Änderung ist die einzige sicherheitskritische Schreib-Operation per API und verlangt deshalb zusätzlich das aktuelle Passwort (`current_password`).

### Workflow (GEO-Optimizer-Perspektive)

**Use-Case:** Programmatischer Self-Service-Check + Härtung des eigenen Kontos (Status lesen, Passwort rotieren, fremde Sessions kappen) ohne Web-UI.

**Idiom:** GET `/account` (Status lesen — `two_factor_enabled`/`email_verified` prüfen) → bei Bedarf PUT `/account/password` (mit `current_password` + neuem Passwort, 422 bei falschem aktuellem PW) → optional DELETE `/account/sessions` (nach Passwortwechsel andere Geräte abmelden). Single-Resource (kein Bulk, kein Polling). Für Benachrichtigungen: GET `/account/notification-preferences` (volle Matrix lesen) → PUT `/account/notification-preferences` (nur die abzuschaltenden optionalen Kategorien als `false` senden; Default-an muss nicht mitgeschickt werden) → erneutes GET bestätigt den Round-trip.

**Anti-Patterns:** Kein Versuch, 2FA/E-Mail/Löschung per API zu fahren — diese Endpoints existieren bewusst nicht (siehe Sicherheits-Ausnahme). Passwort-PUT nicht ohne `current_password` aufrufen (deterministisch 422). DELETE `/sessions` löscht nie fremde User-Rows. Beim Notification-PUT nicht `billing/security/team` abzuschalten versuchen — diese werden serverseitig ignoriert (kein 422, aber kein Effekt); `locked` ist read-only.

**Cross-Module-Bezüge:** Token-Erstellung/-Widerruf unter `/settings/api-tokens` (Web). Rechnungs-/Team-Verwaltung → `## 🧾 Rechnungsdaten` bzw. Team-Members-API.


## 📞 Outbound (Callcenter-Leads · Admin-only)

> **Admin-only.** Interner Provimedia-Kaltakquise-Pool (`leads`, global — **kein** `team_id`). Serviert ausschließlich unter `v1/outbound/*` mit `auth:sanctum` **+** `admin`-Middleware (separater Route-Block, NICHT im Kunden-v1-Block). Ein normaler Kunden-Token bekommt `403` am Gate — die Prospect-Liste darf nie an Kunden-Tokens leaken. Kein Team-Scoping (globaler interner Pool); das `admin`-Gate ist der einzige Zugriffsschutz. Mutationen laufen über `LeadWorkflow` mit Agent = `null` (System-Caller); Aktivitäten werden mit `outbound_agent_id = NULL` geloggt.

| Method | URL | Beschreibung | Auth · Credits |
|--------|-----|--------------|----------------|
| GET | `/outbound/leads` | Pool-Feed, filter- + paginiert. Query: `q` (Volltext: Firma/Kontakt/Telefon/E-Mail/Stadt), `status` (`Lead::STATUSES`), `category` (werbe\|seo\|online_marketing\|geo), `assigned` (`null`=unzugewiesen \| `any`=zugewiesen), `has` (`email`\|`phone`), `sort` (`recent`\|`company`\|`rating`\|`callback`), `page`, `per_page` (Default 25, max 100). Paginator mit `total/per_page/current_page/last_page`. | admin · 0 |
| POST | `/outbound/leads` | Lead anlegen (idempotent auf `dedup_key` = lower(email) ?: tel:+digits(phone,≥6) ?: co:+lower(company\|city)). Body: `{company*, contact_name?, phone?, email?, website?, city?, region?, category?, status?}`. Re-Post derselben Firma aktualisiert nur die Quelldaten und lässt den Workflow-State (Status/Zuweisung/Notizen) unangetastet. Immer **201**. | admin · 0 |
| GET | `/outbound/leads/{}` | Ein Lead inkl. `notes_count`, `activities_count`, `callbacks_count`, `is_contactable`. | admin · 0 |
| PUT | `/outbound/leads/{}` | Quelldaten aktualisieren. Body: `{company?, contact_name?, phone?, email?, website?, city?, region?, category?, status?}`. `status` wird über `LeadWorkflow::setStatus` geführt (loggt `status_changed` + Sperrlisten-Side-Effect bei `do_not_call`); `dedup_key` bleibt unverändert. | admin · 0 |
| POST | `/outbound/leads/{}/notes` | Notiz anhängen (Autor „API"), loggt `note_added`. Body: `{body* (max 2000)}`. **201**. | admin · 0 |
| POST | `/outbound/leads/{}/callbacks` | Wiedervorlage planen. Body: `{scheduled_at* (Zukunft), note? (max 1000)}`. Setzt `next_callback_at` + Status `callback` (außer won/lost/do_not_call), loggt `callback_scheduled`. **201**. | admin · 0 |
| POST | `/outbound/leads/{}/email` | Vertriebsmail async versenden (`SendSalesOfferJob`, Absender „Rankion-Team"). `422` ohne E-Mail, `409` wenn nicht kontaktierbar (`do_not_call`/Sperrliste), sonst **202**. | admin · 0 |
| GET | `/outbound/leads/{}/activities` | Timeline des Leads (neueste zuerst, paginiert, `per_page` max 100). | admin · 0 |
| GET | `/outbound/stats` | Funnel-Kennzahlen: `{total, assigned, unassigned, with_email, by_status:{status:count}, status_labels}`. | admin · 0 |

---

## Ergänzende Endpoints

Module ohne eigene Sektion oben — hier vollständigkeitshalber dokumentiert.

### agency

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/agency/approvals` | Offene Freigabe-Anfragen des Teams auflisten (Inbox) | 0 |
| POST | `/agency/approvals/{}/approve` | Aktuelle Stufe einer Freigabe-Anfrage genehmigen | 0 |
| POST | `/agency/approvals/{}/reject` | Lehnt Freigabe-Anfrage ab, optional mit Feedback | — |
| GET | `/agency/clients` | Kunden des Teams mit Anzahl Projekte und Zugänge (Recht: Mitglieder verwalten) | 0 |
| GET | `/agency/clients/{}/access` | Projekte, Personen mit Zugang (Stufe `viewer`/`editor`) und offene Einladungen eines Kunden | 0 |
| PUT | `/agency/clients/{}/projects` | Projekte des Kunden setzen (`project_ids`, ersetzt die Liste); fremdes Projekt → 422 | 0 |
| POST | `/agency/clients/{}/invitations` | Person in den Kundenzugang einladen (`email`, `level`), Mail mit Einladungslink, 7 Tage gültig → 201; 422 ohne Agentur-Tarif oder bei erreichter Höchstzahl (`max_client_logins`, Agency 100) | 0 |
| DELETE | `/agency/clients/{}/invitations/{}` | Offene Einladung zurückziehen | 0 |
| PATCH | `/agency/clients/{}/members/{}` | Stufe eines Kundenzugangs ändern (`level`) | 0 |
| DELETE | `/agency/clients/{}/members/{}` | Kundenzugang entziehen | 0 |

### brand-voices

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/brand-voices` | Brand Voices des Teams auflisten | 0 |

### feedback

Bugmeldungen/Verbesserungswünsche (Plan-Hub #51, Screenshot-Anhänge seit #68). Volle Doku: `docs/modules/feedback.md`.

Die GET-Antworten enthalten je Meldung `attachments[]` mit `id`, `url`, `mime_type`, `size_bytes`, `original_name`. Die `url` zeigt auf `GET /feedback/attachments/{id}` (Session-Route ausserhalb von `/v1`, nur Melder oder Admin, sonst 403) — die Bilder liegen auf der privaten Disk und haben bewusst keine `/storage/`-URL.

Seit Plan-Hub #82 tragen beide GET-Antworten ausserdem `page_url` (die Seite, von der aus gemeldet wurde — relativer Pfad ohne Host, sensible Query-Parameter wie `token`, `signature`, `secret`, `api_key`, `password`, `key` auf `***` maskiert) und `device` mit `browser`, `os` und `device`; ein nicht erkannter Wert ist `null` und wird nie geraten. Die ROHE User-Agent-Zeichenkette steht ausschliesslich in der Admin-Antwort `GET /v1/admin/feedback` im Feld `user_agent` — sie identifiziert den Melder genauer als die zerlegte Angabe.

Seit Plan-Hub #134 tragen beide GET-Antworten zusätzlich `project` (das beim Absenden aktive Projekt als `id` und `name`, oder `null`, wenn keines aktiv war oder es inzwischen gelöscht ist — der Name wird beim Antworten aufgelöst, eine Umbenennung gilt rückwirkend) und `module` (die Kennung des Moduls, in dem der Melder stand, z. B. `articles` oder `keywords`; `null` auf Seiten ohne Modul). Das Modul wird serverseitig beim Öffnen des Formulars erfasst, das Projekt beim Absenden aus der Sitzung gelesen; keines von beiden lässt sich über das Formular oder über `POST /v1/feedback` setzen.

**Bestätigungsmail an den Melder (Plan-Hub #164).** `confirm-bug` schickt dem Melder eine Mail »Bug bestätigt, 50 Credits gutgeschrieben« — sofort, wenn er höchstens ein offenes Feedback (Status ≠ `erledigt`) hat; hat er mehrere, sammelt rankion und schickt täglich um 18:00 Europe/Berlin EINE Mail mit allen seit der letzten Mail bestätigten Bugs. Jede Antwort trägt dazu `bug_confirmed_at` (Zeitpunkt der Bestätigung, ISO 8601 oder `null`) und `bug_confirmed_notified_at` (Zeitpunkt der Mail; `null` bei einem bestätigten Bug heißt: liegt für die Sammelmail).

**Ein BETREIBER-Admin als Melder bekommt weder Gutschrift noch Mail (Karte #8904, FB-170).** Gemeint ist ausschließlich `users.is_admin`, nicht die Team-Rolle `TeamRole::Admin` (Anzeigename »Administrator«) — ein Kunde mit jener Rolle wird wie jeder andere Kunde behandelt. Hat die Meldung ein `users.is_admin`-Konto als Melder, dann bucht `confirm-bug` keine Credits und verschickt keine Bestätigungsmail — die fachliche Bestätigung (`is_bug_confirmed`, `bug_confirmed_at`) setzt es trotzdem, denn sie ist eine Aussage über den Fehler, nicht über den Dank. `bug_confirmed_notified_at` bleibt in diesem Fall dauerhaft `null`; die Spalte bedeutet unverändert »die Mail ist raus«, und der Tageslauf überspringt solche Zeilen über ein eigenes Prädikat statt über den Merker. Der Riegel auf ein fehlendes Team (422 `Kein Team hinterlegt`) greift ebenfalls nur noch, wenn auch gebucht würde — eine Admin-Meldung ohne Team lässt sich also bestätigen.

**Antwort an den Melder (Plan-Hub #121).** `POST /admin/feedback/{id}/reply` mit `reply` speichert die EINE Antwort des Admins (ein änderbares Feld, kein Verlauf) — dieselbe Wirkung wie der Speichern-Knopf im Detailfenster auf `/admin/feedback`. Jede Antwort trägt `admin_reply` (Text oder `null`) und `admin_replied_at` (ISO 8601 oder `null`); der Melder sieht beides in `GET /feedback` und dauerhaft unter `/feedback`. Die Admin-Antwort führt zusätzlich `reply_template`: den Vorlagentext je Kategorie (Bug, Verbesserungsvorschlag — mit der Zahl aus `Feedback::REWARD_CREDITS`; leer bei Sonstiges), denselben, den das Antwortfeld nach dem Bestätigen vorschlägt. **Seit Karte #8904 nennt er die Gutschrift nur, wenn sie für diese Meldung auch zutrifft** — bei einer noch offenen Meldung also, wenn beim Bestätigen gebucht würde, und bei einer bereits bestätigten, wenn wirklich gebucht WURDE. Für eine Meldung, die ein Admin selbst eingereicht hat, fehlt der Satz über die Credits; ein Skript, das den Entwurf unverändert per `POST …/reply` speichert, verspricht damit nichts Falsches. Die Glocke geht ausschließlich an den Melder (`notifications.recipient_user_id`), nicht an sein Team, und nur, wenn sich der Wortlaut geändert hat.

**Feedback-Key und Archiv (Plan-Hub #155).** Die Admin-Routen erreicht man mit einem gewöhnlichen Token (`*`) oder mit einem **Feedback-Key**: einem Sanctum-Token mit genau der Ability `admin:feedback`, erzeugt auf `/admin/feedback` (Name, Ablauf kein/90 Tage/1 Jahr). Ein Feedback-Key öffnet ausschließlich `/v1/admin/feedback/*` — auf jeder anderen Route gilt er als nicht authentifiziert (`401`, Deny by default). Jede Antwort trägt `archived_at` (`null` = aktiv); in der Admin-Antwort führt jeder Anhang zusätzlich `api_url`, die Bearer-taugliche Download-Adresse. `GET /admin/feedback` liefert ohne Parameter nur aktive Meldungen.

**Erledigt-Zeitpunkt und Suche (Plan-Hub #210).** Jede Antwort trägt `completed_at` (ISO 8601): gesetzt beim Wechsel auf `erledigt` — egal ob über `PATCH /admin/feedback/{id}/status` oder die Oberfläche —, geleert beim Wechsel weg davon; Archivieren oder eine Antwort verschieben ihn nicht. `null` heißt: nicht erledigt, oder vor Einführung der Spalte erledigt (kein nachgetragener Wert). `GET /admin/feedback?q=` sucht dieselbe Menge wie das Suchfeld auf `/admin/feedback`: `#97` nur die Nummer, eine blanke Zahl Nummer UND Text, sonst Nachricht, Name und E-Mail des Melders; `%` und `_` gelten als Text. `q` wirkt zusammen mit `status` und `archived`.

**Team zum Meldezeitpunkt (K#11374, FB-226).** Die Admin-Antwort (`GET /admin/feedback`, `GET /admin/feedback/{id}` und jede andere Admin-Route, die eine Meldung zurückgibt) trägt `team_name_snapshot` und `team_plan_snapshot`: Teamname und Tarif (`free`, `starter`, `pro`, … — derselbe Wert wie `teams.plan`) zum Zeitpunkt der Meldung. Beide übernimmt der Server beim Anlegen aus dem Team, danach ändern sie sich nie — anders als `team` (`id`, `name`), das den heutigen Stand zeigt. `null` bei einer Meldung ohne Team und bei Meldungen von vor der Einführung (kein nachgetragener Wert). Der Melder sieht beide Felder nicht, und `POST /feedback` ignoriert sie, wenn sie mitgeschickt werden.

**Der Arbeitsvorrat (Plan #306, Feedback #152).** `GET /admin/feedback` führt in `meta` zusätzlich
`open_or_in_review`: die Zahl der Meldungen mit Status `offen` ODER `in_pruefung`. Sie ist
**global und bewegt sich von keinem Filter** — `?status=`, `?q=` und `?archived=` verändern
`data` und `meta.total`, nicht diesen Wert. Der Grund ist der Defekt, den Plan #306 behebt: die
Zahl beantwortet „wie viel liegt noch auf dem Tisch", und ein gefilterter Wert wäre eine dritte
Bedeutung derselben Zahl. Es ist exakt dieselbe Zahl, die der Zähler am Megafon und im
Admin-Menü zeigt — eine einzige Zählmethode speist alle drei Stellen, damit sie nicht
auseinanderlaufen können. Ein **Feedback-Key** (Ability `admin:feedback`) liest sie wie ein
Vollzugriffs-Token; sie steht damit genau dem Werkzeug offen, für das der Schlüssel gedacht ist.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/feedback` | Eigene Feedback-Meldungen auflisten (paginiert, 25/Seite) | 0 |
| POST | `/feedback` | Neue Meldung einreichen (message, optional category, optional `attachments[]` — bis zu 3 Bilder à max. 5 MB, `multipart/form-data`; optional `page_url` — max. 2048 Zeichen, nur eine Adresse dieser Anwendung, sonst 422) — löst Bestätigungsmail aus. Eine unbrauchbare Datei ⇒ 422 und es entsteht KEINE Meldung | 0 |
| GET | `/admin/feedback` | Alle aktiven Meldungen auflisten, optional `?status=`; `?q=` sucht Nummer (`#97` nur die Nummer), Nachricht, Name und E-Mail des Melders; `?archived=1` nur archivierte, `?archived=all` alle; `meta.open_or_in_review` führt den Arbeitsvorrat (Admin) | 0 |
| GET | `/admin/feedback/{}` | Einzelne Meldung im Admin-Format, 404 bei unbekannter Nummer (Admin) | 0 |
| GET | `/admin/feedback/{}/attachments/{}/download` | Anhang per Bearer-Token abrufen; der Anhang muss zur Meldung gehören, sonst 404 (Admin) | 0 |
| PATCH | `/admin/feedback/{}/status` | Status ändern: offen/in_pruefung/in_bearbeitung/erledigt (Admin) | 0 |
| POST | `/admin/feedback/{}/confirm-bug` | Als Bug bestätigen — vergibt einmalig 50 Credits ans meldende Team und löst die Bestätigungsmail an den Melder aus (Admin, idempotent) | 0 |
| POST | `/admin/feedback/{}/archive` | Erledigte Meldung archivieren — setzt `archived_at`, nur bei Status `erledigt` (sonst 422), idempotent (Admin) | 0 |
| POST | `/admin/feedback/{}/unarchive` | Meldung aus dem Archiv zurückholen — leert `archived_at` (Admin) | 0 |
| POST | `/admin/feedback/{}/reply` | Die eine Antwort an den Melder speichern (`reply`, 1–5000 Zeichen): setzt `admin_reply` + `admin_replied_at` und legt dem Melder genau eine Glocken-Benachrichtigung — nur bei geändertem Text; gleicher Wortlaut ⇒ 200 ohne zweite Glocke; leer ⇒ 422 (Admin) | 0 |

### engines

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/engines` | Komplette Engine-Capability-Matrix: Version, Signale und alle Engines | — |
| GET | `/engines/signals/{}` | Signal-Detail: Evidenz-Tier plus zustimmende und ablehnende Engines | — |
| GET | `/engines/{}` | Detaildaten einer einzelnen Engine aus der Capability-Matrix | — |

### exports

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/exports` | Generierte Exporte des Teams auflisten (Inbox) | 0 |

### grounding

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET / POST | `/grounding/check-validations` | Check-Validierungs-Runs auflisten bzw. neuen Run starten (202) | — |
| GET | `/grounding/check-validations/{}` | Run-Detail mit Per-Check-Ergebnissen, nach Lift sortiert | — |
| POST | `/grounding/signal-observations/{}/promote` | Kandidaten-Signal auf Tier A promoten (Admin-only) | — |
| POST | `/grounding/signal-observations/{}/reject` | Kandidaten-Signal-Beobachtung ablehnen (Admin-only) | — |

### incidents

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET / POST | `/incidents` | Team-Incident-Register auflisten bzw. KI-Vorfall öffentlich melden (202) | — |
| GET | `/incidents/{}` | Einzelnen team-eigenen Incident-Report abrufen | — |

### locations

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/locations` | Business-Standorte des Teams auflisten (Local SEO) | 0 |
| GET | `/locations/{}` | Detail eines Business-Standorts (team-gescopt) | — |

### module-tours

Gefuehrte Touren je Modul (Plan-Hub #79). Der Stand haengt am NUTZER, nicht am
Team: zwei Kollegen im selben Team haben eigene Staende. `offered_at` und
`seen_at` sind zwei verschiedene Zeitpunkte — angeboten ist nicht gesehen.

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/module-tours` | Alle Module mit Tour, deren Schritte und der eigene Stand | 0 |
| POST | `/module-tours/end-run` | Ueberblicksrunde beenden: jedes aktive Modul bekommt `offered_at`, Status bleibt `pending` | 0 |
| POST | `/module-tours/{}/seen` | Tour eines Moduls als durchlaufen vermerken (idempotent, setzt `seen_at`) | 0 |

### optimization

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/optimization/runs/{}` | Detail eines Optimization-Runs mit allen Iterationen und Laufstatus | — |
| POST | `/optimization/runs/{}/abort` | Bricht laufenden Optimization-Run ab (Status aborted) | — |

### search-console

| Method | URL | Beschreibung | Credits |
|--------|-----|--------------|---------|
| GET | `/search-console` | Search-Console-Uploads auflisten (Leistung + Indexierung, pro Team/Projekt) | 0 |
| GET | `/search-console/{}` | Search-Console-Insights eines Uploads (Chancen + Fehler, je mit KI-Erklärung & Beispiel) | 0 |
