# Rankion.ai API Reference

> **Stand:** 2026-06-27 · **Version:** v1 · **508 Endpoints**

Single Source of Truth für die komplette REST-API. Generiert aus `routes/api.php` + Live-Verifikation aller Endpoints. Diese Datei wird via `CLAUDE.md` auto-importiert und kann als Skill-Referenz genutzt werden.

## 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 ziehen N Credits aus dem Team-Balance vor Job-Dispatch. Bei `cache=false` Refresh (Explorer) wird inline 50 Credits abgebucht.

---

## 🎯 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` | — |
| 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) | — |
| 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). → **204** No Content | nur eigenes Team · 0 |
| GET    | `/tracking-projects/{id}/analysis-status` | Wizard polling: `{analysis_status, analysis_phase, suggested_keywords, suggested_competitors, suggested_prompts, suggested_personas, classification}` | — |
| 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 | — |
| POST   | `/tracking-projects/{id}/run` | Tracking-Run starten (async) | — |
| GET    | `/tracking-projects/{id}/runs` | Run-Historie (`?limit=&offset=`) | — |
| 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` | 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) | — |

### 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    | `/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, language?, country?, is_active?}` |
| 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?}` |
| 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 |

**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 |

### 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) |
| GET    | `/tracking-projects/{id}/cited-sources/export` | CSV-Stream. `?from=&to=&status=&platform=&search=&sort=` |
| 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=`) |
| GET    | `/tracking-projects/{id}/platform-breakdown` | (`?from=&to=`) |
| GET    | `/tracking-projects/{id}/insights/low-hanging-fruit` | (`?days=30&min_possibility=60&limit=5`) |
| GET    | `/tracking-projects/{id}/insights/platform-gap` | (`?days=30`) |
| POST   | `/tracking-projects/{id}/insights/platform-gap/{platform}/diagnose` | async — 1 Credit |
| GET    | `/tracking-projects/{id}/insights/potential-hero` | `{eligible:false}` wenn <20 Abfragen |
| 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}` |
| PATCH  | `/tracking-projects/{id}/competitors` | Body: `{domains[]}` (max 50) — normalisiert/dedupliziert, synct `competitor_domains` + `TrackingCompetitor`. Returnt `{competitor_domains[]}`. Powert das Gap-Wettbewerber-Modal |

### 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` — 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, indexing}`. `verifiable_pct` = Anteil Claims mit `verification_status ∈ {true,false,outdated,partial}` an allen Claims (identische Formel wie das `accuracyCoverage`-Computed). `indexing=true` wenn KB leer + Projekt `active` (Crawl läuft noch). 403 Fremd-Team. — 0 |
| POST   | `/tracking-projects/{id}/brand-kb/rebuild` | **Eigen-Domain Re-Crawl + Re-Embed** der Webseiten-KB (`BuildBrandKbJob`, `ShouldBeUnique`, Queue `ai`, Redirects DISABLED + `assertSafeUrl`). Hash-Dedup (`UNIQUE(project, passage_hash)`) macht den Re-Build idempotent. **202** + `{data:{status:"queued", tracking_project_id, message}}`. `throttle:5,1`. Kostenlos (Eigen-Domain-Read, kein Credit-Gate) — 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 |
| 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: queued\|running\|ready\|failed, progress?, download_url?}` |

### 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. `?project_id=&status=&per_page=&page=` | — |
| POST   | `/prompt-check` | Neuer Run async (202). Body: `{tracking_project_id, prompt (5-5000ch), platforms:["chatgpt","claude",…], with_search?:{platform:bool}, title?}` | 1 pro Platform×Mode |
| GET    | `/prompt-check/{id}` | Detail: run + alle responses mit response_text, sentiment, brands_mentioned, citations | — |
| 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 |
| 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}` | |

### Aktionen

| Method | URL | Credits |
|--------|-----|---------|
| POST   | `/articles/{id}/score` | — |
| POST   | `/articles/{id}/optimize` | — |
| 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?}`. `knowledge_mode` ∈ `all`\|`specific`; bei `specific` werden `knowledge_document_ids[]` strikt auf die `ready`-Docs des Projekts gescopt (fremde IDs verworfen) | 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` |

### Freshness

| Method | URL |
|--------|-----|
| 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 |
|--------|-----|---------|
| 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. |
| 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=`) |
| POST   | `/rank-tracker/{project}/keywords` Body: `{keywords[], devices[]}` async 202 |
| POST   | `/rank-tracker/{project}/refresh` |
| 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`. `?cache=false` | 0 / 50* |

### Keyword Gap (Phase 3)

| Method | URL |
|--------|-----|
| GET    | `/gap` · `/gap/{project}` · `/gap/{project}/opportunities` |
| PATCH  | `/gap/opportunities/{opportunity}` |
| POST   | `/gap/{project}/refresh` |

### Site Audit (Phase 4)

| Method | URL |
|--------|-----|
| GET/POST | `/site-audit` |
| GET    | `/site-audit/{crawl}` · `.../issues` |
| PATCH  | `/site-audit/{crawl}/issues/bulk` |
| PATCH  | `/site-audit/issues/{issue}` |
| POST   | `/site-audit/issues/{issue}/generate-brief` |

**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.

**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, deep_link}}`. 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`.

**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 |
| POST   | `/backlinks` | Activate-Endpoint: Backlink-Tracking für Projekt aktivieren (Subscription required) |
| POST   | `/backlinks/{project}/refresh` | Trigger Delta-Pull Job |
| POST   | `/backlinks/{project}/full-refresh` | Vollständigen DFS-Re-Pull triggern (Async 202) |
| 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`) |
| 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_trust_flow\|anchor_text`, `?follow=dofollow\|nofollow`, `?lost=1`. 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 Top toxic_severity=high Referring-Domains + 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 |

### 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.

| 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`, `?follow`, `?lost=1`). 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 High-Toxicity Disavow-Empfehlungen 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 |

### 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?}`) |

---

## 🛠 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` |
| GET    | `/projects/{project}/keyword-researches` | — |
| GET    | `/keyword-researches/{id}/status` | — |
| GET    | `/keyword-researches/{id}/results.csv` | — |
| DELETE | `/keywords/{id}` | — |
| POST   | `/keywords/research` | 5 |
| POST   | `/keywords/{id}/expand` | 10 |
| GET    | `/keywords/{id}/expansions` | — |

`GET /projects/{project}/keywords` liefert pro Keyword zusätzlich `source`
(`deep|quick|import` — 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**.

`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`) |

### 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` | — |

### Link Lists

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

### Orphan Scans

| Method | URL |
|--------|-----|
| POST   | `/projects/{project}/orphan-scans/discover` |
| POST   | `/orphan-scans/{id}/start` |
| GET    | `/orphan-scans/{id}` · `.../pages` |
| DELETE | `/orphan-scans/{id}` |

### Overview

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

---

## 📅 Calendar & Images

### Calendar

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

### 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. 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}` | — |

### 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` | 20 (POST) |
| GET    | `/competitor-analyses/{id}` · `.../gaps` (`?is_favorite=&is_dismissed=`) | — |
| 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` | — |
| 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 Monitor

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

---

## 📈 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` |

---

## 🚀 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: `{name, stages[]}` | 20 (POST) |
| GET    | `/pipelines/{id}` | — |
| POST   | `/pipelines/{id}/retry` | — |

---

## 🏗 Projects & Settings

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

---

## 🎯 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/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` |

---

## 🔌 Google Integrations

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

---

## ⭐ 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)* |

---

## 🧾 Rechnungsdaten

Team-Rechnungsprofil (Firmenanschrift, USt-IdNr., 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). Gate: `manage settings` (Owner/Admin). Body: `{company_name, address_line1, postal_code, city, country, contact_name?, address_line2?, vat_id?, billing_email?, purchase_order_ref?, separate_billing_address?, billing_address_line1?, billing_postal_code?, billing_city?, billing_country?}` | — |

### 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`.

---

## 👥 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`.

---

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

Auditiert eine einzelne Landingpage: 3 Source-Screenshots (Desktop/Tablet/Mobile mit echter UA-Emulation), Lighthouse-Metriken, Opus 4.7 Multimodal-Vision-Analyse plus bis zu 3 gpt-image-2 KI-Renders der optimierten Variante (Desktop ist die Design-Basis, Tablet+Mobile sind responsive Adaptionen). 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?}` → 202 + `{id, status:"pending", url}` |
| GET    | `/page-audit/{id}` | — | Detail mit 2 Bild-URLs (Desktop-Source + Desktop-Ideal) + `opus_analysis` + Lighthouse |
| GET    | `/page-audits` | — | Paginierte Liste. `?per_page=25&tracking_project_id=` |

**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 },
  "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"
}
```

**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"}`. |
| 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`. |

**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). Inline 50 Credits Charge.
- **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/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).

**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?}`. 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}`. 404 (audit_not_found), 410 (audit_expired). | 0 |
| GET | `/v1/grounding/audits` | Liste eigener Audits. Filter: `?status=, ?tracking_project_id=, ?from=, ?to=`, Pagination. | 0 |
| POST | `/v1/grounding/batch` | Bulk-Audit für 1–500 URLs. Body: `{urls[], frameworks?, concurrency? (1-10), callback_url?, tracking_project_id?}`. Throttle: 5/min. Returns 202 + `{batch_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, 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?}`. Credits werden NACH Sitemap-Parse abgezogen. | url_count_after_parse |

### 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. | 0 |
| 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). throttle:10,1. | — |
| DELETE | `/account/sessions` | Beendet alle anderen Sitzungen des Users (löscht nur dessen eigene `sessions`-Rows außer der aktuellen). 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.
