---
name: rankion-api
description: Interact with the rankion.ai SEO platform API — manage projects, articles, keywords, AI visibility tracking, content optimization, and more via REST API calls
related_skill: rankion-agent (REST-first decision tree, primary for grounding-audits + master-agent fallback)
last_verified: 2026-05-16
---

# Rankion.ai API Skill

Use this skill to interact with the rankion.ai platform via its REST API. All endpoints require a Bearer token.

## Canonical Reference (READ THIS FIRST)

The **single source of truth** is `/api-docs/markdown` — auto-generated from `routes/api.php` + live-verified, currently ~354 endpoints, ~1380 lines.

```bash
curl https://rankion.ai/api-docs/markdown > /tmp/rankion-api-ref.md
grep -n "^### \|^## " /tmp/rankion-api-ref.md       # see all module groups
grep -A 5 -i "<topic>" /tmp/rankion-api-ref.md      # find a specific endpoint shape
```

The tables in **this** skill cover the high-traffic ~130 endpoints. For anything NOT listed here (especially: Page-Deep-Audit, Action Center, Site Monitor, Community Monitor, SEO-Suite phases, Rankion OS, Reports & Correlation, Pipelines, Data-Mining outreach-draft-mail) — go to `/api-docs/markdown` directly. Do not assume "not in this skill" means "doesn't exist". **Live Prompt Check + AVI Reality-Check + Data-Mining Insights ARE documented here** (R6-correction 2026-05-15) — see sections below.

## Configuration

Set the API token as environment variable or use directly:
```bash
export RANKION_API_TOKEN="your-token-here"
```

**Base URL:** `https://rankion.ai/api/v1`
**Auth Header:** `Authorization: Bearer $RANKION_API_TOKEN`
**Format:** All requests and responses are JSON
**Rate Limit:** per-endpoint, per-token. Read response headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After`. Typical limits: `/grounding/analyze` 30/min POST, 120/min GET; `/grounding/batch` 5/min; agentic and most CRUD ~60/min.

## How to make API calls

Use the Bash tool with curl:
```bash
curl -s -H "Authorization: Bearer $RANKION_API_TOKEN" \
     -H "Content-Type: application/json" \
     https://rankion.ai/api/v1/ENDPOINT
```

For POST/PUT requests add `-X POST` or `-X PUT` and `-d '{"key":"value"}'`.

Async endpoints (article generation, content optimizer, tracking runs) return HTTP 202 with an ID. Poll the corresponding GET endpoint until status changes to `completed`.

## Complete Endpoint Reference

### Credits & Team
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/credits` | `{balance: {milli, display, display_ceil, display_floor}, plan}`. Inline route, no controller. Plan falls back to "free" if team null. Code-verified `routes/api.php:423`. **DB**: `teams.credit_balance` ist `bigint(20)` (stores raw mC). Response-shape (milli-object) entsteht via `CreditsResource::shape()`. Direct-DB-Joins sehen den int, nicht das object. |
| GET | `/credits/history` | Paginated 20/page. Item shape: `{id, team_id, amount: {milli, display, display_ceil, display_floor, signed_milli}, type, description, reference_type, reference_id, created_at}` — **`signed_milli` IS merged INTO `amount` object** (NOT separate top-level fields wie companion-R1+R2 fälschlich claimed). Wrapper: `{data: [...items], meta: {current_page, last_page, per_page, total}}`. Falls team null → empty array via ApiResponse::data([]). Code-verified `routes/api.php:430-465`. |
| GET | `/team` | `{id, name, plan, credit_balance: {milli, display, display_ceil, display_floor}, members_count, projects_count, articles_count}`. Code-verified `ApiV1Controller.php:90-109`. `credit_balance` ist MILLI-OBJECT (CreditsResource::shape), nicht int. **Performance-Drift (R5)**: 3 separate DB-queries für die counts (`members()->count()`, `projects()->count()`, `Article::whereIn(...)->count()` mit subquery für pluck). Bei Teams mit vielen Projects → 3 round-trips statt 1 SQL JOIN+COUNT. Latency-relevant für /team-dashboard-polls. |

> **Credit-value type drift across endpoints:** `/agentic/chat` responses include `credits_used` as a `{milli, display, display_ceil, display_floor}` object. `/grounding/audits/{id}` returns `credits_used` as a plain `int` (typically 0 since grounding is free). Always `jq type` to be safe.

> **⚠ Production-Bug-Kandidat — CreditService::deduct Race-Condition (`:17`):** Sequence: `hasCredits($credits)` → `decrement('credit_balance', milli)` → CreditTransaction::create. **KEIN row-lock zwischen hasCredits-check und decrement**. 2 concurrent requests können beide pass hasCredits (z.B. team hat 5cr, beide wollen 4cr → 5>4=true für beide) → beide decrement → team balance kann NEGATIVE werden. Production-Bug-Candidate. Sollte `DB::transaction` mit `SELECT ... FOR UPDATE` oder atomic `UPDATE teams SET credit_balance = credit_balance - ? WHERE credit_balance >= ?` nutzen.

### Engines

Read-only reference data: how AI answer engines (Google AI Overviews/Mode, ChatGPT Search,
Perplexity, Claude, Copilot, Gemini, Grok) retrieve and cite content. Per-engine × per-signal
evidence powers GEO advice and grounding-audit evidence tiers.

| Method | URL                  | Description                                  | Credits |
|--------|----------------------|----------------------------------------------|---------|
| GET    | `/v1/engines`        | Full matrix (version, signals, engines)      | 0       |
| GET    | `/v1/engines/{key}`  | One engine — key ∈ `google_ai, chatgpt_search, perplexity, claude, copilot, gemini, grok`. 404 on unknown key. | 0 |

Response shape (`GET /v1/engines`):
```json
{
  "data": {
    "version": "2026-05-16",
    "signals": { "<signal>": {"label": "...", "description": "..."} },
    "engines": {
      "<engine_key>": {
        "label": "...",
        "family": "...",
        "retrieval": "...",
        "official_guidance_url": "...",
        "summary": "...",
        "signals": {
          "<signal>": {"weight": "...", "evidence": "...", "note": "..."}
        }
      }
    }
  }
}
```

- `weight`   ∈ `required | high | medium | low | none`
- `evidence` ∈ `confirmed | confirmed_negative | observed | hypothesis | not_applicable`

Use this BEFORE giving GEO advice to a customer. Segment recommendations per engine: Google
AI follows the official Google SEO guide; the extractive engines (ChatGPT Search, Perplexity,
Claude, Copilot, Grok) are documented as `hypothesis` for most structural signals — present
them as plausible, never as confirmed. The same matrix powers `evidence_tier` on grounding
findings (Tier A = `confirmed`/`observed`, Tier B = `hypothesis`).

### Projects (code+DB-verified 2026-05-14 against `ApiV1Controller.php:113-185` + `rankion_local.projects`)

> **DB-Schema Gotcha:** `projects` table hat MEHR Columns als die API-Validation erlaubt. Silent-drop via mass-assignment für nicht-validierte Felder. Folgende existieren in DB aber sind via /projects-API NICHT setzbar: `country` (varchar 5, default 'DE'), `products_services` (json), `logo_path`, `is_active` (default 1), `modules_config` (json), `brand_voice_id` FK, **`primary_cms_integration_id` FK** (added Round 3 — companion-R2 missed). Wer sie braucht: Direct-DB-Update oder über TrackingProject-Flow (`/tracking-projects/analyze` setzt country indirekt). **`color` IS API-settable** in updateProject mit regex-Validation (hex color `/^#[0-9A-Fa-f]{6}$/`) — companion-Round1-doc hatte color fälschlich in dieser Liste, Round-2-Konvergenz-Proof gefixt.
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects` | List all team-projects with brandVoice relation. Returns raw array `[{...},...]` (NO pagination). |
| POST | `/projects` | Create project. Body validation: `name` required string max 255, `domain` nullable string max 255, `language` string `in:de,en,es,fr,it,nl,pt,pl,sv,da,no,fi`, `description` nullable string max 1000. **Idempotent by normalized (team_id, domain)** — duplicate domain returns existing project with 200, not new. **⚠ Production-Bug-Kandidat #17 (R5)**: Idempotency-check NICHT atomic. Sequence ist SELECT-(LIKE-scan)-then-CREATE ohne DB::transaction wrap. **Concurrent POST mit gleichem domain → race-condition kann immer noch duplicate projects erzeugen** (analog Bug #12 CreditService). **Performance-Drift**: `projects.domain` ist NICHT indexed → idempotency-query full-table-scans. Plus die `whereRaw('LOWER(REPLACE(...)) LIKE')` function-based-comparison ist anyway nicht index-able. |
| GET | `/projects/{id}` | Get project with brandVoice + latest 10 articles. 403 if cross-team. |
| PUT | `/projects/{id}` | Update project. Validates `:154-179`: `name` max:255, `domain` nullable max:255, `language` `in:de,en,es,fr,it,nl,pt,pl,sv,da,no,fi` (12 langs), `description` nullable max:1000, `settings:array`, `industry` max:100, `brand_name` max:255, `brand_aliases[].max:255`, `target_audiences:array`, `usps[].max:500`, `competitor_domains[].max:255`, **`color` nullable str regex `/^#[0-9A-Fa-f]{6}$/`** (hex color). 12 fields total. **Without these in allowlist, other DB-fields are silently dropped (200 OK)** — historical bug. |
| DELETE | `/projects/{id}` | Delete project. Returns 204 No Content. 403 if cross-team. **⚠ NUCLEAR-DELETE CASCADE** (R1+R2+R3 alle missed): Da `articles.project_id`, `ai_detection_scans.project_id`, `ai_chat_sessions.project_id`, `autopilot_schedules.project_id`, `bulk_generations.project_id`, `business_locations.project_id` + **viele weitere FKs** alle `ON DELETE CASCADE` haben → DELETE löscht **alle Artikel + Audits + Chats + Schedules + Bulk-Jobs + Locations** des Projekts. Plus `Project::booted` deletion-hook (`:17`) deletes ALL local image files aus `storage/` (incl. ImageEdit-result-files) BEFORE DB-CASCADE läuft — **filesystem side-effect**. `projects.brand_voice_id` + `primary_cms_integration_id` → SET NULL (relations preserved). Caller die DELETE-204 sehen, ahnen nicht den massive blast radius. |

### Articles (code+DB-verified 2026-05-14 against `ApiV1Controller.php` + `rankion_local.articles`)

> **Index-Coverage Drift (R5)**: `articles` table hat **keinen `(project_id, created_at)` composite-index** — `paginate(20)->latest()` in /projects/{id}/articles list sortiert ohne index bei großen projects → filesort full-scan. Plus `article_versions` hat **keinen `created_at` index** — `orderByDesc(created_at)` in listVersions ähnlich slow. Performance-Hotspots bei high-volume projects.

> **Job Retry/Failure (R6) — DREI Retry-Philosophien (R6-C7 refined)**: 
> - **Philosophie A: Default (no config)** — Article-domain: GenerateArticleJob, ScoreArticleJob, PublishToWordPressJob, OptimizeContentJob. `failed()` handlers OHNE explicit `$tries`/`$timeout` → Laravel-default 1 try.
> - **Philosophie B: Explicit retry (`$tries=2`)** — AI-Analyse/Generation-Jobs die idempotent retry-fähig sind:
>   - Tracking: AnalyzeDomainJob `$timeout=300, $tries=2`
>   - PromptCheck: ExecutePromptCheckJob `$timeout=180, $tries=2`; AnalyzePromptResponseJob `$timeout=120, $tries=2`
>   - RealityCheck: GenerateRealityCheckReportJob `$timeout=45, $tries=2`
>   - Freshness: ContentRefreshJob `$timeout=300, $tries=2`; CheckContentFreshnessJob `$timeout=120, $tries=2`
>   - Humanizer (R6-C8): HumanizeArticleJob `$timeout=600, $tries=2`; HumanizeDocumentJob `$timeout=900, $tries=2` (longest timeout, 15min!)
> - **Philosophie C: Explicit NO-retry (`$tries=1`)** (R6-C7 entdeckt) — Expensive/non-idempotent crawl-Jobs wo retry doppelte Charges/Scrapes produzieren würde:
>   - Audit: RunContentAuditJob `$timeout=600, $tries=1`; RunPageAuditJob `$timeout=600, $tries=1`; FinalizeAuditJob `$timeout=300, $tries=1`
>   - Competitor: RunCompetitorAnalysisJob `$timeout=1800, $tries=1` (30min timeout, single shot!)
> Caller-Polling-Strategie braucht per-domain awareness: Philosophie-A+C werden bei 1× failure final-failed; Philosophie-B macht 1 Retry-with-backoff. Article-Jobs sind systematisch weniger resilient als AI-Tracking-Jobs. Audit+Competitor sind absichtlich single-shot (cost-protection).

> **DB-Schema Gotcha — Articles haben DREI Status-Felder:**
> - `status` varchar(255) — frei, app-validated `in:draft,published,archived`. Mass-assignment-Bypass-Risk.
> - `processing_status` enum `pending|generating|completed|failed|scanning|humanizing` — Job-Lifecycle für Score/Generate/AI-Scan.
> - `publish_status` enum `draft|scheduled|publishing|published|failed` — CMS-Publish-Lifecycle.
>
> Wenn ein Caller "Status" pollt während ein Job läuft, MUSS `processing_status` gepollt werden, nicht `status`. Score- und Generate-Aktionen mutieren `processing_status` und `*_score` Spalten (seo_score, geo_score, eeat_score, humanization_score, ai_detection_score — alle tinyint unsigned, plus composite_score DECIMAL(5,2)).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/articles` | List articles. Inline closure `routes/api.php:135`. |
| POST | `/projects/{id}/articles` | Create article. Validation `ApiV1Controller.php:189`: `title` REQ str max 500, `content` nullable str, `status` `in:draft,published`, `target_keyword` nullable max 255, `meta_title` nullable max 255, `meta_description` nullable max 500, `language` nullable max 5. Auto-computed: slug (Str::slug(title)), word_count. Returns 201 with article data. **⚠ Production-Bug-Kandidat #18 (R5)**: `Str::slug($title)` ist deterministisch, **kein DB UNIQUE constraint auf (project_id, slug)** → 2 articles mit gleichem Titel im selben Project bekommen IDENTISCHEN slug. Frontend-URL-Routing-Bruch möglich (2 articles auf gleicher URL). |
| GET | `/articles/{id}` | Get article. Inline closure `routes/api.php:139`. |
| PUT | `/articles/{id}` | Update. Validation `:213`: `title` max 500, `content`, `status` `in:draft,published,archived` (3 values, CREATE only had 2!), `target_keyword`, `meta_title`, `meta_description`. word_count auto-recomputed if content present. |
| DELETE | `/articles/{id}` | Returns **204 No Content**. **⚠ CASCADE-LAYER (R4 entdeckt — R1+R2+R3 missed)**: 11 FK-CASCADE tables werden auch gelöscht: `article_versions` (HISTORY!), `article_comments, article_goals, article_knowledge_links, article_link_lists, cms_publications, content_optimizations, content_freshness_checks, internal_link_suggestions` (BOTH source_article_id AND target_article_id — bi-directional!), `agentic_optimization_runs, approval_requests`. **9 weitere tables SET NULL** (preserve FK as orphaned): `ai_chat_sessions, bulk_generation_items, competitor_content_gaps, content_pipelines, editorial_calendar, ga4_page_metrics, gsc_page_metrics, images, storylines`. Article model hat **kein booted/deleting hook** — reines DB-cascade, kein filesystem cleanup (im Gegensatz zu Project). |
| POST | `/articles/{id}/score` | Inline closure `:146`. Dispatches `ScoreArticleJob`. 202 with `{message: "Scoring queued", article_id}`. |
| POST | `/articles/{id}/optimize` | `:970`. Dispatches `OptimizeContentJob`. 202 with `{message, article_id}`. |
| POST | `/articles/{id}/optimize-loop` | `:153` → `:895`. Agentic Score-Optimization-Loop (Plan B 2026-05-13). Dispatches `OptimizeToTargetJob(article_id, 'manual')`. Returns 202 `{message: "Optimization-loop queued", article_id, poll_url: "/api/v1/articles/{id}/optimization/runs"}`. No body validation. |
| GET | `/articles/{id}/optimization/runs` | `:154` → `:913`. Paginated 20/page Laravel-classic. Returns OptimizationRun rows ordered by id desc. |
| GET | `/optimization/runs/{run}` | `:155` → `:929`. 403 cross-team. Eager-loads `iterations` relation. Returns `{data: <run>, meta: {duration_seconds, is_running, is_terminal}}`. |
| POST | `/optimization/runs/{run}/abort` | `:156` → `:951`. 403 cross-team. **409 `"Run is not running"`** wenn `!isRunning()`. Setzt `status='aborted', stop_reason='aborted', completed_at=now()`. Returns `{message: "Run aborted", run_id}`. **Verhindert nicht den aktuell laufenden Job** (max 1 iter mehr läuft durch), blockiert nur neue Runs. |
| GET | `/articles/{id}/freshness` | `:157`. Current freshness score. |
| POST | `/articles/{id}/freshness/check` | `:399`. Trigger freshness check (async 202). |
| GET | `/articles/{id}/freshness/history` | `:400`. Past freshness checks. |
| GET | `/articles/{id}/link-suggestions` | `:158`. Internal link suggestions. |
| GET | `/articles/{id}/versions` | `:159`, ctrl `:4883`. Returns `{data: [{id, article_id, content, created_at}]}`. Optional `?limit=N` (default 50). |
| POST | `/articles/{id}/versions/{vid}/restore` | `:160` → `:4902`. Lookup ArticleVersion `where article_id=$id AND id=$vid` → **404 "Version not found."** wenn nicht gefunden. Replace article.content mit version.content. Returns updated article fresh `{data: <article>}` (v1) or `ApiResponse::data` (v2). |
| POST | `/articles/{id}/generate` | `:161`, **5 credits** (NOT 15 like /generate/article standalone!). Validation `:4924`: `keyword` 2-255, `article_type` `in:blog,product,landing,pillar,how_to,listicle,comparison,review`, `outline` max 10000, `target_length` int 200-10000, `tone` max 50, `language` size:2, `style_profile_id` exists FK, `content_goal_id` exists FK, `prompt_id` exists FK (links TrackingPrompt+content_brief). **Brief-Resolution wenn prompt_id**: loads `TrackingPrompt + content_brief`. **403 wenn prompt cross-team**. Wenn article.title leer/Default → auto-update aus `brief.title`. Outline = `h2_outline[]` als Markdown bullets wenn outline nicht übergeben. Keyword fallback aus `brief.title ?? prompt.prompt_text`. Language fallback aus `prompt.language`. **422 wenn keyword nach Brief-resolution leer** ("keyword erforderlich (oder prompt_id mit Brief)."). |
| POST | `/articles/{id}/publish` | `:162`, ctrl `:5014`. Body: `cms_integration_id` REQ int exists FK. **404** wenn integration cross-team. **409** conflict + `{message, publication_id, status}` wenn bestehende CmsPublication mit status pending/publishing/published existiert. **422** `"CMS type 'X' not supported yet."` wenn `integration->type != 'wordpress'` — **WordPress ist aktuell der einzige unterstützte CMS-Type** (match-statement default → abort 422). Bei erfolg: 202 `{message: "Publishing queued", publication_id, status: "pending"}` + dispatches `PublishToWordPressJob`. **⚠ Production-Bug-Kandidat #19 (R5)**: 409-conflict-check ist NICHT atomic — `SELECT existing CmsPublication → IF return 409 → ELSE create new` ohne DB::transaction. **2 concurrent POST /publish** → beide SELECT finden nothing → beide CREATE → 2 publications dispatched → **same article 2× auf WordPress published**. Same race-pattern wie #12/#17/#18. Fix-Vorschlag: DB UNIQUE constraint auf `(article_id, cms_integration_id)` partial wo status IN active-states. |
| POST | `/articles/{id}/repurpose` | `:163`, **3 credits** (credits-auto:3) → `:5076`. Body: `format` nullable `in:linkedin,twitter,instagram,newsletter,youtube_script,tiktok_script,facebook`. Without format → `$service->repurposeAll($article)` (dict alle 7 Formate). Mit format → `[$format => $service->repurposeSingle($article, $format)]` (1-key dict). Returns v1 `{data: $results}` (results = format-keyed dict) · v2 `ApiResponse::data($results)`. |

### Content Generation (async, returns 202 — code+DB-verified 2026-05-14)
| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| POST | `/generate/article` | 15 | Inline closure `routes/api.php:185`. Validation: `project_id` REQ exists FK, `keyword` REQ str min:2, `type` `in:blog-post,guide,listicle,review,comparison,pillar`, `length` int 300-5000 (default 1500), `tone` str (default "professional"), `language` `in:de,en,es,fr` (4 vals — distinct from /projects 12-lang!). **Side-effect**: Article::create mit empty placeholders (`title='', slug='', content=''`) + `status='generating'` + target_keyword + language fallback (`req ?? project.language ?? 'de'`). **Article persists BEFORE Job runs** — wenn GenerateArticleJob fails, bleibt ein placeholder-Article in der DB. Job-dispatch args: `(article, keyword, type|'blog-post', '' [empty outline], length|1500, tone|'professional', language|chain)`. Returns 202 `{message, article_id}`. |
| POST | `/generate/image` | 5 | Inline closure `:212`. Validation: `project_id` REQ exists FK, `prompt` REQ str min:5, `style` `in:photo,illustration,3d,watercolor`. **DB-BUG**: controller passes `user_id` and `style` to `Image::create([...])` but Image fillable lacks BOTH. → `user_id` is silently dropped, `style` also dropped. Image table also has no `style` column. Returns 202 `{message, image_id}`. **DB-Cascade Layer (R4)**: `images.project_id` → ON DELETE CASCADE (Image stirbt mit Project) · `images.article_id` → ON DELETE SET NULL (Image überlebt Article-delete, FK orphaned) · `images.accepted_edit_id` → SET NULL. **Image::deleting hook** (`Image.php:13`) löscht local-storage files: `image.url` (wenn starts_with `/storage/`) + alle `image_edits.result_url` files via `Storage::disk('public')->delete($path)`. Doppelte cleanup-Sicherheit (Image::deleting + Project::deleting beide cleanen storage). `image_edits.image_id` → CASCADE, `image_edits.parent_edit_id` → SET NULL (edit-chain history preserved). |

### Content Optimizer (code+DB-verified 2026-05-14 against `ApiV1Controller.php:1471-1577` + `rankion_local.content_optimizations`)

> **DB**: `content_optimizations.status` ist DB-ENUM `('pending','processing','completed','failed')` — keine varchar-bypass-risk. Indexes auf `(article_id, status)` und `(team_id, status)` ✓ — query patterns covered. **DB-Cascade (R4-entdeckt)**: `content_optimizations.article_id` ON DELETE CASCADE (Optimization stirbt mit Article — alle Optimization-history weg) UND `content_optimizations.team_id` ON DELETE CASCADE (Team-delete bringt alle Optimizations weg). Caller die Optimization-history erhalten wollen → vorher exportieren. **Index-Coverage Drift (R5)**: **kein `created_at` index** → `latest()` sort filesorts results. Plus **keine `keyword/url` indexes** → filter-queries scan team-rows. Performance-Hotspot bei high-volume teams.

> **Idempotency-Gap (R5)**: weder `/content-optimizer/analyze` noch `/generate/image` haben idempotency-key support. Double-submission durch Caller (z.B. double-click) → 2 separate Optimizations/Images + 2× Credit-Charge (10cr/10cr). Caller sollten own dedup-Key vor POST checken oder request-deduplication (z.B. via Sanctum-Token + request-hash).

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/content-optimizer` | - | List. Filters: `?article_id=int&status=string&keyword=exact&url=exact&per_page=N` (default 25). Laravel-classic-pagination response. `:1471`. |
| POST | `/content-optimizer/analyze` | 5 | Validation: `url` required_without:text url, `text` required_without:url str min:50, `keyword` nullable max:200, `article_id` nullable exists FK (**permissive — KEIN cross-team check, nur existence; Caller könnte article_id eines fremden Teams setzen, optimization erbt aber eigene team_id; article-content bleibt 403-geschützt über /articles/{id} aber article_id selbst leakt minor**). Creates ContentOptimization with `status='pending'`, dispatches `OptimizeContentJob($optId, $userId)`. Returns 202 `{message, id, optimization_id, poll_url}` mit **`Location: /api/v1/content-optimizer/{id}` header auf v1 UND v2**. `:1497`. |
| GET | `/content-optimizer/{id}` | - | Returns full optimization (13 fields). 403 if cross-team. **Aliases**: `/content-optimizer/optimizations/{id}` (GET) UND `/content-optimizer/optimizations/{id}/apply` (POST) — beide route auf gleiche Controller-Methode (Subsystem-B-P0.3-Hardening: turns "404 caller-bug" into "both paths just work"). `:1540` + `routes/api.php:325-326`. |
| POST | `/content-optimizer/{id}/apply` | 5 | Validation: `suggestion_ids` REQ array min:1, `suggestion_ids.*` **string** (NOT integer — companion was wrong). 400 if status != completed (`"Optimization not completed yet."`). 422 with `{"error":"No matching suggestions found"}` if filter matches none. Returns 200 `{optimized_content, applied_count}`. v2 wraps in `data`. Content-Source-Fallback: `scraped_content ?? article->content ?? ''`. **Service-Layer Behavior** (`ContentOptimizerService::applyOptimizations:374`): word-cap auf `MAX_ANALYSIS_WORDS=10000` (truncated wenn länger — partial optimization), calls Claude mit `maxTokens=8000`, **uncaught exception bei Claude-fail → 500** zum Caller (Log-Eintrag aber kein graceful error-body). **Alias path** `/content-optimizer/optimizations/{id}/apply` resolves to same controller. `:1547`. |

### Keywords (code+DB-verified 2026-05-15 against `ApiV1Controller.php:241-282,2339-2373` + `routes/api.php:166-169,383-384` + `rankion_local.keywords`)

> **DB-Schema Gotcha — TWO intent fields:** keywords table hat `intent` varchar(255) UND `search_intent` ENUM (`KN,DO,WE,VV,PENDING`). `createKeyword` validiert NUR `intent` (`in:informational,navigational,commercial,transactional`). search_intent ist die DB-canonical-enum, wird aber NICHT von API gesetzt — bleibt auf default `PENDING`. Folge: API-erstellte Keywords haben `intent='informational'` aber `search_intent='PENDING'`. Wer Filter auf search_intent will, kann API-erstellte Keywords nicht ohne weiteren Job ausschließen.

> **Keyword Model Casts** (`Keyword.php:43`): `cpc` ist `float` cast (NICHT decimal!) — DB column is `decimal(8,2)`, App-level konvertiert zu PHP float bei jedem read → **finance-precision-loss-risk**. Wer CPC für aggregations/sums nutzt, sollte direct-DB-query mit DECIMAL-arithmetic verwenden, nicht Eloquent. Other casts: search_volume/difficulty/kd_score/traffic_potential int, article_titles/sub_keywords/serp_urls/monthly_searches array (JSON), auto_fusion/is_deep_research bool.

> **DB-Schema Gotcha — 13-state processing_status:** ENUM mit lifecycle `pending → expanding → expansion_done → volume_fetching → serp_pending → serp_analyzing → serp_done → clustering → clustered → generating_titles → titles_done → intent_pending → ready → failed`. `createKeyword` setzt explizit `processing_status='ready'` (sonst hängt Keyword als "Läuft" in UI — KeywordResearch.php:131 mapped !=ready zu is_running=true). Wenn ein Caller `processing_status` selbst überschreibt, gerät Keyword in Limbo. Companion-Caller sollten dieses Feld NICHT setzen.

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/projects/{id}/keywords` | - | List paginated 50/page (Laravel-classic). **Projection**: SELECTS nur `id, keyword, search_volume, difficulty, cpc, intent, language, created_at` (8 cols). NICHT in Response: search_intent enum, processing_status, kd_score, parent_topic, traffic_potential, serp_urls, monthly_searches, article_titles. Für vollständigen Keyword-State: direkt `keywords.id` via DB joinen oder neuen Endpoint anfragen. `ApiV1Controller.php:241`. |
| POST | `/projects/{id}/keywords` | - | Validation `:251`: `keyword` REQ str max:255, `search_volume` nullable int, `difficulty` nullable int 0-100, `cpc` nullable numeric, `intent` nullable str `in:informational,navigational,commercial,transactional`, `language` nullable max:5. Auto-sets `processing_status='ready'`. Returns 201 with full Keyword. **⚠ Inkonsistente Dedup-Pattern (R5)**: `createKeyword` macht direct-create OHNE dedup-check. Vergleich: `TrackingKeyword` (siehe activate-wizard) nutzt `firstOrCreate(['tracking_project_id', 'keyword', 'language', 'country'])` idempotent. **`keywords` table hat KEIN UNIQUE constraint** auf (project_id, keyword, language, country) → Duplikate akkumulieren bei Caller mit double-submit oder same-data-retries. |
| DELETE | `/keywords/{id}` | - | `:274`. 403 cross-team. Returns 204 No Content. **⚠ CASCADE-TREE-BLAST-RADIUS (R4-entdeckt — R1+R2+R3 missed)**: `keywords.parent_keyword_id` ON DELETE CASCADE (self-ref) → wenn parent-keyword gelöscht, **ALL expansion-children werden DESTROY!** Plus 3 weitere CASCADE-tables: `serp_results, keyword_list_items, keyword_related` — **ALLE SERP-Research-Daten gehen verloren**. **2 SET NULL preserve**: `gsc_query_metrics.keyword_id` (GSC analytics survive als orphan) + `keywords.main_keyword_id` (cluster-relations preserved). Asymmetrisches Self-Ref-Cascade: `parent_keyword_id` CASCADE (tree-children wipe) vs `main_keyword_id` SET NULL (cluster preserved). Caller die research-data behalten wollen → vorher exportieren! |
| POST | `/keywords/research` | 5 | Inline closure `routes/api.php:169`. Validation: `keyword` REQ min:2, `language` `in:de,en,es,fr`, `country` accepted ungültig-validiert (Controller: strtoupper, Service-internal: strtolower für gl param). **3rd-party dependency**: `KeywordResearchService::research()` ruft **Serper.dev autocomplete API** (X-API-KEY header). `Http::timeout(30)`. **Throws RuntimeException uncaught bei Serper-failure → 500** zum Caller mit `"Serper error (status): message"` Log. Returns suggestions[] direkt aus Serper response. **No async** — synchronous. Wenn Serper rate-limited oder API-key expired → 500 trotz validem Caller. |
| POST | `/keywords/{id}/expand` | 10 | `:383` → `triggerKeywordExpansion :2339`. No body validation (uses route-model-binding). Dispatches `KeywordExpansionJob(keyword_id, project_id, language, country)`. Returns 202 `{message, keyword_id}`. **WARNING**: 10 cr per call, even on retry. |
| GET | `/keywords/{id}/expansions` | - | `:384` → `listKeywordExpansions :2363`. Returns ALL children where `parent_keyword_id={id}`, ordered by `search_volume DESC`. **NOT paginated** — risk for keywords with 1000+ children-expansions. Use direct DB query with pagination if expansion was deep-research. |

### AI Visibility Tracking Core (code+DB-verified 2026-05-15 against `ApiV1Controller.php:1419-1741,4455-4793` + `rankion_local.tracking_{projects,keywords,prompts}`)

> **⚠ HIGH: NO `DELETE /tracking-projects/{id}` API endpoint exists** (R4 entdeckt). Companion-R1-R3 implizierten Delete via standard-CRUD-Pattern aber Route fehlt. Caller können Tracking-Projects nicht via API löschen — nur via team-admin-cascade oder DB-admin. **Wenn Team gelöscht** (via Team-CASCADE) → tracking_projects löst **22-table NUCLEAR-CASCADE** aus: AVI history (ai_dimension_scores, ai_visibility_indices), **6 backlinks-tables** (backlinks, backlink_anchors, backlink_events, backlink_projects, backlink_timeseries_points, referring_domains), **5 LLM-tracking-tables** (llm_response_atoms, llm_response_claims, llm_results, narrative_consensus, persona_gap_matrix), 4 competitor research tables (competitor_cooccurrence, cc_bulk_audits, gap_domain_keywords, gap_opportunities), 2 alert tables (tracking_alerts, tracking_alert_rules), tracking_cited_sources, prompt_check_runs. **5 SET NULL preserve audits**: content_audits, grounding_audits, grounding_batches, page_audits, site_crawls.

> **DB-Schema Gotcha — DUAL category fields on tracking_prompts:**
> - `prompt_type` DB ENUM (7 vals): `category_discovery, comparison, use_case, brand, transactional, informational, custom` — default `category_discovery`. Index `MUL`.
> - `prompt_category` varchar(30) — TrackingPrompt::CATEGORIES const (8 vals): `recommendation, comparison, use_case, purchase, experience, alternative, feature, custom` — default `recommendation`.
> Same split-brain pattern wie keywords intent/search_intent. `createTrackingPrompt` + `activateTrackingProject` validieren gegen CATEGORIES (prompt_category), NICHT gegen prompt_type. Wer beide korrekt setzen will → direkt-DB.

> **DB-Schema Gotcha — TrackingProject 5-enum-state-machine:**
> - `status` enum 3 vals: `setup → active → paused`
> - `tracking_frequency` enum 5 vals: `daily, every_3_days, weekly, biweekly, monthly`
> - 6 modul-toggles bool: `track_serp, track_aio, track_llm, rank_tracker_enabled, gap_enabled, reality_check_enabled`
> - 2 LLM-toggles bool: `llm_with_search, llm_without_search`
> - `llm_platforms` JSON, validated `in:chatgpt,perplexity,claude,gemini,copilot` (5)

> **TrackingKeyword `search_intent` is DB-CANONICAL ENUM** (KN/DO/WE/VV/PENDING), NICHT die varchar-aus-`keywords` table. tracking_keywords ist sauber, keywords nicht.

> **DB UNIQUE-Constraint Pattern (R5-entdeckt — best-practice)**: `tracking_keywords` hat **`tk_unique_keyword` UNIQUE-Constraint** auf `(tracking_project_id, keyword, language, country)` — DB-level race-safety enforcement. Complementing den activate-Wizard's `firstOrCreate` app-Level-idempotency. Wenn 2 concurrent requests dieselbe Keyword-Kombination einfügen → DB INSERT atomar rejected, kein race-bug. **Im Gegensatz zu `keywords` table** die KEIN solches UNIQUE-Constraint hat (Drift #20 aus R5 C4). Exemplarisches Pattern für race-safe-create-design.

> **Index-Coverage tracking_prompts excellent**: 4 composite-indexes (`(tracking_project_id, is_active)`, `idx_tp_priority` 3-col mit `(tracking_project_id, monitor_status, priority_score)`, `idx_tp_monitor_due` für cron-due-queries, `idx_tp_funnel_stage`). Beispielhaft für monitor-queue + priority-sort + funnel-classification queries. **tracking_projects missing `(team_id, next_track_at)` index** → scheduled-tracking-due cron scans alle team-projects.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/tracking-projects` | List. `:301`. |
| POST | `/tracking-projects/analyze` | **Wizard Step 1+2**. `:302` → `:4455`. Validation: `mode` REQ `in:domain,keyword`, `domain` required_if mode=domain str 3-255, `keyword` required_if mode=keyword str 2-255, `name` nullable max:255, `language` size:2 (default 'de'), `country` size:2 (default 'DE'). Creates TrackingProject with status='setup' + onboarding_data{input_mode, seed_keyword, wizard_step:2, analysis_status:'running'}. Dispatches AnalyzeDomainJob. Returns 202 `{project_id, status: "queued", analysis_status: "running"}`. |
| GET | `/tracking-projects/{id}` | `:303`. Detail mit KPIs. |
| PUT | `/tracking-projects/{id}` | **Settings update** `:304` → `:4541`. Validates 18 fields (alle sometimes): name, brand_name, brand_aliases[], competitor_domains[], rank_tracker_enabled, gap_enabled, **backlinks_enabled** (VIRTUAL — propagiert zu BacklinkProject delta/paused, NICHT auf TrackingProject persistiert), industry max:120, target_audiences[].max:255, products_services[].max:255, usps[].max:255, tracking_frequency 5-enum, llm_platforms[].`in:chatgpt,perplexity,claude,gemini,copilot`, llm_with_search bool, llm_without_search bool, track_aio bool, track_serp bool, track_llm bool, reality_check_enabled bool. |
| GET | `/tracking-projects/{id}/analysis-status` | **Wizard polling** `:305`. Returns `{tracking_project_id, project_id (@deprecated 2026-05-10), analysis_status, analysis_phase, analysis_error, suggested_keywords[], suggested_competitors[], suggested_prompts[], suggested_personas[], classification}`. Liest aus `onboarding_data` JSON. |
| POST | `/tracking-projects/{id}/activate` | **Wizard Step 5** `:306` → `:4600`. **⚠ NICHT TRULY ATOMIC**: `activateTrackingProject` ist **NICHT in DB::transaction gewrappt** — wenn mid-way ein TrackingKeyword/Prompt/Competitor-create fails (z.B. DB-constraint, deadlock), bleibt das Projekt in partiellem state (manche prompts created, andere nicht). **Anti-Bug**: soft-cast für unbekannte prompt categories → `'custom'` + `_original_category` preserved (Agentic-Sessions #565-#569). Validation: keywords[] str max:255, competitors[].{domain REQ max:255, brand_name nullable} → wenn brand_name leer → **defaults to domain-value** als brand_name. **prompts[] REQ min:1** of {prompt str 10-1000, category in TrackingPrompt::CATEGORIES (8 vals), persona max:100, intent `in:low,medium,high` (default `medium`), is_active bool (**inactive werden SKIPPED**, nicht created!), is_custom bool}, platforms[] REQ min:1 `in:chatgpt,perplexity,claude,gemini,copilot`, frequency REQ `in:daily,every_3_days,weekly,biweekly,monthly`, with_search/without_search/track_aio/reality_check_enabled bool, generate_briefs bool, briefs_count int 1-30. **Category→prompt_type Mapping** beim Create: recommendation→category_discovery, comparison→comparison, use_case→use_case, purchase→transactional, experience→informational, alternative→transactional, feature→transactional, custom→custom (wizard-konsistent — companion-R1 falsch behauptet prompt_type sei nicht API-settable; durch activate IS via mapping). **TrackingKeyword.firstOrCreate** idempotent (lookup-keys: tracking_project_id + keyword + language + country). **nextTrack frequency-to-date**: daily=+1d, every_3_days=+3d, weekly=+1w, biweekly=+2w, monthly=+1mo. **`reality_check_enabled` feature-gate**: team muss `hasFeature('ai-reality-check')` haben — sonst silent ignored. |
| POST | `/tracking-projects/{id}/run` | `:307` → `:1419`. **400 `"Tracking project is not active."`** wenn `!$trackingProject->isActive()` (status muss 'active' sein). Dispatches `RunScheduledTrackingJob`. Returns 202 `{message: "Tracking run dispatched", tracking_project_id}`. |
| GET | `/tracking-projects/{id}/runs` | `:308`. List runs with status/progress. |
| POST | `/tracking-projects/{id}/sync-sources` | `:309` → `:1450`. Uses `CitedSourceSyncService::syncForProject()`. Returns `{message: "Cited sources synced", sources_count: int}` (sync count vom service). Synchronous. |
| POST | `/tracking-projects/{id}/prompts/import` | **CSV/Excel multipart** `:310` → `:4794`. Validation: `file` REQ file `mimes:csv,txt,xlsx` **max:5120 KB (5MB)**. `mapping` nullable array mit 6 integer-keys: `prompt_text, persona_label, prompt_category, commercial_intent, language, prompt_type`. Falls mapping leer → `PromptImporter::autoDetectMapping($headers)` versucht columns aus headers zu mappen. Returns `{imported, skipped, errors, mapping_used}`. |
| GET | `/tracking-projects/{id}/keywords` | `:329`. |
| POST | `/tracking-projects/{id}/keywords` | `:330` → `:1598`. Validation: keyword REQ str max:200, keyword_group nullable max:100, tags nullable array, search_volume nullable int min:0, cpc nullable numeric min:0, difficulty nullable int 0-100, `search_intent` nullable `in:informational,navigational,transactional,commercial`, language nullable max:5, country nullable max:5. **DB has 17 cols incl. `keyword_location_code`, `keyword_group`, `is_active` default true.** ⚠ **PRODUCTION-BUG (verified live 2026-05-15)**: Validator akzeptiert **semantische** search_intent-Namen (informational/navigational/transactional/commercial), DB enum erwartet aber **Codes** (KN/DO/WE/VV/PENDING). Live-Test mit `search_intent='informational'` → 15s timeout + leerer body, KEIN DB-row created. Direkter Code `KN` wird vom Validator zurückgewiesen (422). **WORKAROUND**: search_intent komplett aus body weglassen (nullable → DB default PENDING). |
| PUT | `/tracking-keywords/{id}` | `:331` → `:1619`. **Validation-Asymmetry**: Update erlaubt nur **5 Felder** (keyword sometimes max:200, keyword_group nullable max:100, tags nullable array, search_volume nullable int min:0, is_active sometimes bool) — vs createTrackingKeyword's 9 Felder. cpc/difficulty/search_intent/language/country/keyword_location_code/source/discovered_from_url **NICHT updateable** via API → silent-drop bei Caller-Versuch. |
| DELETE | `/tracking-keywords/{id}` | `:332`. 204 No Content. **⚠ Cascade-Blast**: `tracking_aio_results.tracking_keyword_id` CASCADE (AIO-history weg) + `tracking_serp_results.tracking_keyword_id` CASCADE (SERP-research weg). `tracking_prompts.tracking_keyword_id` SET NULL (Prompts preserve mit null-FK). |
| GET | `/tracking-projects/{id}/prompts` | `:333` → `:1641`. **NOT paginated** — get() statt paginate(). Filters: `?active_only=bool` (filter is_active=true) + `?category=<str>` (filter prompt_category exact). orderByDesc(id). **Unbounded response** bei Projekten mit 100+ prompts — bug-risk wie /keywords/{id}/expansions. |
| POST | `/tracking-projects/{id}/prompts` | `:334` → `:1659`. **Soft-cast** für unknown prompt_category → `'custom'` (same pattern wie activateTrackingProject — verhindert agentic-init-422s). Validation: `prompt_text` REQ str 10-1000, `prompt_type` nullable str max:50 (**IS API-settable** entgegen companion-R1-claim, aber NICHT enum-validated — DB-enum-mismatch-Risk!), `persona_label` max:100, `prompt_category` nullable `in:TrackingPrompt::CATEGORIES` (8 vals), `commercial_intent` nullable `in:low,medium,high`, `tracking_keyword_id` nullable exists FK, `language` nullable max:5. |
| GET | `/tracking-prompts/{id}` | `:335`. |
| PUT | `/tracking-prompts/{id}` | `:336` → `:1699`. **Validation-Asymmetry**: Update erlaubt nur **4 Felder** (prompt_text sometimes 10-1000, persona_label nullable max:100, prompt_category nullable in CATEGORIES, is_active sometimes bool). **prompt_type wird HIER silent-dropped** (im Gegensatz zu createTrackingPrompt wo prompt_type IS API-settable). Auch tracking_keyword_id, language, commercial_intent, monitor_status etc. nicht updateable via API. |
| DELETE | `/tracking-prompts/{id}` | `:337`. 204. **⚠ Cascade-Blast**: `llm_results.tracking_prompt_id` CASCADE → **LLM-tracking-history für diesen Prompt komplett weg**. Caller die historical LLM-results behalten wollen → vorher exportieren! |
| GET | `/tracking-projects/{id}/scores` | `?from=&to=`. `:354`. |
| GET | `/tracking-projects/{id}/cited-sources` | `?domain=&outreach_status=&is_own_domain=`. `:355`. |
| GET | `/tracking-projects/{id}/platform-breakdown` | `?from=&to=`. `:356`. |

### Content Audit (code+DB-verified 2026-05-15 `ApiV1Controller.php:794-2280` + `content_audits` + `content_audit_pages`)

> **Variante 1 Rename (2026-05-10):** Legacy `project_id` field is **REJECTED with 422** on POST body, GET query, AND `filter.project_id` in bulk-update — explicit error message. Use `tracking_project_id` (FK `tracking_projects.id`) canonical.

> **⚠ NO DELETE /content-audits/{id} API endpoint** (R4 entdeckt — verifiziert wie tracking_projects, competitor_analyses). Caller können Audits nicht via API löschen — nur via team-cascade ODER DB-admin. **DB-Cascade**: `content_audit_pages.content_audit_id` ON DELETE CASCADE (Pages verschwinden mit Audit). `content_audits.tracking_project_id` ON DELETE SET NULL (audit preserve wenn tracking_project gelöscht). content_audits.team_id ON DELETE CASCADE — team-delete = full audit-history loss.

> **DB `content_audits`** (18 cols): status enum 3-tier (`running, completed, failed` — kein "pending"!), type varchar(20) free, score-aggregates (avg_seo_score, avg_quality_score, avg_word_count, total_issues), summary JSON. Tracking-FK `tracking_project_id` UND legacy `project_id` beide existieren (project_id ist FK→projects.id, nicht zu verwechseln mit tracking_project_id).

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/content-audits` | - | `:279` → `:848`. **Rejects `?project_id=` with 422**. Filters: tracking_project_id, source_url (exact-match). Projection SELECT 9 cols `id, tracking_project_id, source_url, type, status, total_pages, avg_seo_score, total_issues, created_at`. Latest order. |
| POST | `/content-audits` | 10 | `:280` → `:794`. **Rejects body.project_id with 422** + Migration-Hint. Validation: `source_url` REQ url, `type` `in:full,quick`, `tracking_project_id` nullable exists FK. Returns 202 `{audit_id, message}`. **No idempotency** — 2 concurrent POST mit same source_url + tracking_project_id → 2 audits + 2× 10cr charge. Caller-double-click amplifier (R5). |
| GET | `/content-audits/{id}` | - | `:281` → `:842`. Loads `pages` relation. 403 cross-team. |
| GET | `/content-audits/{id}/pages` | - | `:376` → `:2070`. Rich filters: `?status=open\|solved` (open is canonical alias for is_solved=false), `?priority=critical\|high\|medium\|low`, `?min_seo_score=0..100&max_seo_score=0..100`, `?issue_type=string` (matches any `issues[].type` via JSON-LIKE — robust fallback for MariaDB+MySQL), `?sort=url\|title\|seo_score\|content_quality_score\|word_count\|priority\|is_solved`, `?dir=asc\|desc`, `?per_page=50`. Response: `{progress:{total, solved, open, percent}, pages:{data:[...], current_page, last_page, ...}}`. **⚠ Index-Coverage Gap (R5)**: Nur 2 von 7 sort-Fields indexed (`is_solved, status` via composites). Sort by `seo_score, content_quality_score, word_count, priority, url, title` → filesort scan within audit-rows. Bei großen audits (1000+ pages) → slow. |
| GET | `/content-audits/{id}/export` | - | `:375` → `:2223`. **4 Formats**: `?format=json\|csv\|xlsx\|markdown` (default json). CSV/XLSX via ExportColumn-builder (12 columns inkl. issues count + JSON). **Markdown** generiert strukturierten Report mit Header (URL, Datum, ø-SEO-Score, solved/open counts) + chunked Page-listing (sortiert nach priority high→low + seo_score asc) + issue-categorization mit `[FEHLER]/[WARNUNG]/[INFO]` Markers. **TableExporter behavior (CSV/XLSX)** (`:151`): InvalidArgumentException für format ≠ csv\|xlsx, schreibt zu tempnam in sys_get_temp_dir → StreamedResponse mit `Content-Type: text/csv; charset=UTF-8` (CSV) or `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` (XLSX), `Content-Disposition: attachment; filename="<slug>_<teamSlug>_<Y-m-d_Hi>.<format>"` (addslashes-escaped), `Cache-Control: no-store, no-cache`. **Auto-cleanup**: tempfile via `@unlink` nach readfile. XLSX-Sheet-Title sanitized (Excel-illegal `\/?*[]:` chars entfernt, max 31 char). |
| PATCH | `/content-audits/{id}/pages/bulk` | - | `:377` → `:2142`. **Throttle 30/min**. Anti-Bug: **rejects filter.project_id with 422** (pages have no project FK — silent-drop war the old bug causing mass-update on wrong scope). Validation: `is_solved` **REQUIRED bool** (target value to set), filter.priority `in:critical,high,medium,low`, filter.status `in:open,solved`, filter.min_seo_score+max_seo_score int 0-100, filter.issue_type max:60. Response: `{affected, is_solved}` (v1) or `{data: {affected, is_solved}, meta: {applied_filter}}` (v2). issue_type filter uses JSON-LIKE (`"type":"..."`). |
| GET | `/content-audit-pages/{id}` | - | `:373`. Single page detail. |
| PATCH | `/content-audit-pages/{id}/solved` | - | `:374` → `:2051`. Accepts `is_solved` bool OR toggles current. Returns `{id, is_solved}`. |

### Competitor Analysis (code+DB-verified 2026-05-15 `ApiV1Controller.php:1191-1320` + `competitor_analyses` + `competitor_content_gaps`)

> **⚠ NO DELETE /competitor-analyses/{id} API endpoint** (R4 entdeckt). Wie content_audits + tracking_projects — Caller kann Analysen nicht löschen via API. **DB-Cascade**: `competitor_analysis_competitors.competitor_analysis_id` + `competitor_content_gaps.competitor_analysis_id` BEIDE CASCADE. team-delete = full competitor-research-loss.

> **⚠ Production-Bug — keyword_limit Default-Drift:** DB `competitor_analyses.keyword_limit` column default = **300**. Controller default = **100** (line 1213). Wer POST `/competitor-analyses` ohne `keyword_limit` schickt bekommt 100, NICHT 300. Caller-Misalignment.

> **DB `competitor_analyses`**: status enum **6-state lifecycle** `pending → discovering → analyzing → clustering → completed → failed`. Phase tracking via `phase` varchar + `phase_progress/phase_total` smallint. `credits_charged` bigint (mC). `summary` + `error_message` text/json.

> **DB `competitor_content_gaps`**: rich opportunity data — `keyword, search_volume, cpc, competitor_count, competitor_domains[], best_position, avg_position (decimal 5,2), opportunity_score, topic_cluster, suggested_title, suggested_type, sample_urls[]`.

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/competitor-analyses` | - | `:295`. |
| POST | `/competitor-analyses` | 20 | `:296` → `:1191`. Validation: `project_id` REQ exists, `domain` REQ str max:255, `language` `in:de,en,es,fr` (4 vals), `country` max:5, `competitor_limit` int 1-20 (default 5), `keyword_limit` int **10-500** (default 100 — drift vs DB-default 300). Creates `CompetitorAnalysis(status="pending")` + dispatches `RunCompetitorAnalysisJob`. Returns **202** `{message: "Competitor analysis queued", analysis_id}` (v1) or `{analysis_id, meta:{message}}` (v2). **No idempotency** — 2 concurrent POST mit same domain → 2 analyses + 2× 20cr charge (40cr total). Caller-double-click amplifier (R5). |
| GET | `/competitor-analyses/{id}` | - | `:297`. Loads `['competitors', 'contentGaps']` relations. |
| POST | `/competitor-analyses/{id}/rerank-competitors` | - | `:298` → `:1270`. **3-state response**: (1) **200 `{status:"skipped", reason:"no_auto_competitors", message}`** wenn `competitors.where('is_manual', false)` empty. (2) **502 `{status:"failed", reason, message: "KI-Klassifikation konnte nicht durchgeführt werden"}`** wenn `CompetitorRelevanceReranker` debug.ai_ok=false. (3) **200 `{status:"ok", updated, competitors:[{id, domain, is_manual, avg_position, ai_relevance_score, ai_classification, ai_reasoning}]}`** wenn erfolgreich. **CompetitorRelevanceReranker::rerank Behavior** (`:27`): 2-step pipeline → (a) `scrapeUserContext($userDomain)` first → wenn fails: returns unfiltered candidates mit debug.reason='scrape_failed'. (b) `classifyWithAi` → wenn null: returns mit debug.reason='ai_failed'. 4 mögliche debug.reasons: `untouched, no_candidates, scrape_failed, ai_failed`. Debug shape: `{scraped: bool, ai_ok: bool, kept: int, rejected: int, reason: str}`. |
| GET | `/competitor-analyses/{id}/gaps` | - | `:380`. Filters `?topic_cluster=&min_score=` against rich gap-shape (12 fields). |

### AI Scanner & Humanizer (code+DB-verified 2026-05-15 `ApiV1Controller.php:631-790` + `ai_detection_scans` + `humanize_batches` + `humanize_documents`)

> **⚠ Production-Bug — Credit-Error-Contract-Drift:** `/humanize` uses **legacy hard-cut**: 403 `{error: "Insufficient credits", required: int}` (line 696-697 — `$team->hasCredits($credits)`). CreditsV2-Module (`/generate-brief`, `/diagnose`) use **402** `{error: "insufficient_credits", required: {milli, display, ...}, balance: {milli, ...}}`. Caller can't generically handle both. → Sollte konsolidiert auf 402 + milli-envelope.

> **⚠ User-Delete-Pattern Inconsistency (R4 entdeckt)**: `ai_detection_scans.user_id` ON DELETE **CASCADE** + `humanize_batches.user_id` ON DELETE **CASCADE** + `humanize_documents.batch_id` CASCADE (chain). Wenn ein User aus dem Team gelöscht wird → **ALLE AI-Detection-Scans + Humanize-Batches + Documents des Users verschwinden**. Im Gegensatz zu `articles.user_id` ON DELETE **SET NULL** (Articles preserved). Asymmetrisches User-Delete-Verhalten je nach Modul. Plus: keine DELETE-API für humanize_batches/documents oder ai_detection_scans — Caller können diese History nicht selektiv löschen.

> **⚠ Index-Coverage Gap (R5)**: humanize_batches + humanize_documents + ai_detection_scans haben **NUR FK-indexes** (team_id/user_id/project_id). **Keine status + created_at indexes** → list-queries filesort + status-scans slow bei high-volume teams. scan_type + score filter/sort auf ai_detection_scans ebenfalls unindexed.

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| POST | `/ai-scanner/detect` | 2 | Sync. `:270` → `:631`. Validation: `text` REQ min:50, `scan_type` `in:quick,deep` (default `quick`), `language` `in:de,en,es,fr` (default `de`). Returns AiDetectionService output: quick `{details, message, metrics, score}` · deep adds `{llm_score, patterns, quick_score}`. **quickScan early-return**: wenn `word_count < 10` → `{score:100, details:[], metrics:[], message}` (100% human-confidence shortcut für trivial-short text — Caller die kurze Texte testen kriegen 100). **deepScan pipeline**: wraps quickScan first (baseline), splits text in **500-word chunks**, analyzes each chunk via LLM (Claude), merges sentence-scores + patterns → 6s+ Latency (live-iter-3 6.4s). DB stores in `ai_detection_scans` with score, scan_type, processing_status default `completed`. |
| POST | `/ai-scanner/humanize` | 5 | Async. `:271` → `:650`. Validation: `article_id` REQ exists FK, `level` `in:light,standard,deep` (default `standard`). Dispatches `HumanizeArticleJob`. **Auto-side-effects**: setzt `article.processing_status='humanizing'` (enum) + lädt `project.brand_voice.protected_terms` als brandNames für LLM-prompt + respektiert `article.language ?? 'de'` (korrekt — im Gegensatz zu /humanize Bug #14). Returns 202 `{message, article_id, level}`. |
| POST | `/humanize` | **VARIABLE** | Async. `:274` → `:679`. **Cost-Calculation per word**: `credits = max(1, ceil(word_count/1000) * 8)`. NOT fixed 8! Validation: `text` REQ min:50 chars, `project_id` nullable exists FK. **Word-Cap**: 422 `{error: "Text too long (max 10,000 words)"}` wenn >10000 words. Returns 202 `{message, batch_id, document_id, estimated_credits: {milli, display, display_ceil, display_floor}}`. **⚠ V1/V2-HYBRID**: error-path uses V1 (403 + int `required`), success-path uses V2 milli-envelope. **🔥 Production-Bug-Kandidat #14: HumanizeDocumentJob hardcodet language='de' + level='deep'** (`HumanizeDocumentJob:44+51`) — API akzeptiert text in beliebigem Lang, Job behandelt ALLE inputs als German + always 'deep'-level. Englisch/Französisch/Spanisch text wird falsch humanized. Vergleich: HumanizeArticleJob respektiert article.language korrekt. Auto-side-effect: setzt document.status='processing' sofort + batch.status='processing' wenn erstes document startet + lädt brand_voice.protected_terms aus project. |
| GET | `/humanize/{batch_id}` | - | `:275` → `:742`. Returns `{id, status (pending\|processing\|completed\|failed), total_credits_used: {milli, display, display_ceil, display_floor}, documents:[{id, title, status, word_count, ai_score_before, ai_score_after}]}`. **NOTE**: `total_credits_used` in DB ist `decimal(8,2)` display-credits, in Response wrapped als milli-object via CreditsResource. Code-Layer-Conversion. |
| GET | `/humanize/{batch_id}/documents/{id}` | - | `:276` → `:768`. 404 wenn cross-batch. Returns `{id, title, status, original_content, humanized_content, word_count, ai_score_before, ai_score_after, passes_used}`. **passes_used** tinyint zeigt wie oft das Document den Humanize-Loop durchgelaufen ist. **source_type** enum in DB: `text_input, docx_upload` — der API-Endpoint setzt nur text_input; DOCX-Upload via Web-UI. |

### Content Freshness (code+DB-verified 2026-05-15 + `content_freshness_checks`)

> **DB `content_freshness_checks`** (NOT `article_freshness_checks`): `article_id` FK + `freshness_score` tinyint unsigned + `age_days` int unsigned + `issues` JSON + `recommendations` JSON + `checked_at` timestamp (auto-updates on INSERT).

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/content-freshness` | `:398` → `:2599`. Laravel-classic paginator (verified Live-Iter 1), per_page default 25. **Filters `status='published'` only** (companion-R1 missed). Eager-loads `freshnessChecks` (latest 1 by checked_at). Optional `?project_id=` scope. |
| GET | `/articles/{id}/freshness` | `:157` → `:991`. Returns 4-field current snapshot `{article_id, freshness_score, freshness_status, last_check}` (companion-R1 just said "current score"). NOT a list — current article.* attributes only. |
| POST | `/articles/{id}/freshness/check` | `:399` → `:2614`. Dispatches `ContentRefreshJob`. Returns 202 `{message: "Freshness check started", article_id}`. ⚠ **Job-Side-Effect (HIGH — companion previously missed)**: `ContentRefreshJob:42` creates ArticleVersion backup BEFORE refresh → AI-rewrite via `ContentGeneratorService::rewriteSection` → updates article.content + word_count + **forces `status='draft'` selbst wenn article previously published war!** → auto-dispatches `ScoreArticleJob` zur Re-Scoring. Caller die published articles refreshen → werden zurück zu draft gesetzt unerwartet. |
| GET | `/articles/{id}/freshness/history` | `:400` → `:2633`. **IS PAGINATED** (companion-R1 sagte unpaginated). per_page default 20, ordered by `checked_at DESC`. Laravel-classic-paginator response. |

### Internal Linking (code+DB-verified 2026-05-15 `ApiV1Controller.php:1010-2426` + `internal_link_suggestions`)

> **DB table is `internal_link_suggestions`** (NOT `link_suggestions`). Status enum 4-state: `pending, accepted, rejected, applied`. `applied_at` timestamp set auto when status flips to applied. Relations: sourceArticle, targetArticle (both Article FKs). **DB-Cascade (R4)**: `source_article_id` CASCADE + `target_article_id` CASCADE (bi-directional cleanup wenn either article deleted). `run_id` SET NULL (suggestions preserve wenn internal_linking_run deleted). Defensive design.

> **Exemplary Race-Safe Pattern (R5 entdeckt — 3. Vorzeige-Beispiel)**: `internal_link_suggestions` HAT **`unique_link` UNIQUE constraint** auf `(source_article_id, target_article_id, anchor_text)` — verhindert duplicate-suggestions im DB-Level. Plus `(source_article_id, status)` composite + `target_article_id` index für bi-directional queries + `run_id` index. **Genau wie tracking_keywords tk_unique_keyword + tracking_cited_sources (tp, url_hash) UNIQUE patterns.** Companion-R4 noted bi-directional CASCADE aber **missed UNIQUE-Backup**.

> **⚠ Production-Bug — `analyzeInternalLinks` N+1 Sync-Pattern (PHP-FPM-Timeout Risk):** `:2377` iterates ALL published articles synchronously in a single HTTP request, calling `InternalLinkingService::analyzeLinkingOpportunities` per-article. Bei großen Projekten (1000+ Artikel) → PHP-FPM timeout (typically 60s). 5 Credits werden nur 1× geladen via middleware, nicht pro-Artikel. **Sollte async (Job) sein, ist aber sync.** Caller mit großen Projekten sollten direkt-DB-Query oder Pagination via API-roadmap requesten.

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/articles/{id}/link-suggestions` | - | `:158` → `:1010`. Returns suggestions WHERE source_article_id=article_id **OR** target_article_id=article_id (bi-directional!). Loads `sourceArticle:id,title, targetArticle:id,title` relations. Eager-loaded projection: 2 cols pro relation. |
| POST | `/projects/{id}/internal-links/analyze` | 5 | `:387` → `:2377`. **422 wenn keine published articles**. **Sync** über alle published Articles (PHP-FPM-timeout risk). Returns `{message: "Internal link analysis completed", project_id, articles_analyzed, suggestions_generated}`. |
| PUT | `/link-suggestions/{id}` | - | `:388` → `:2412`. Validation: `status` REQ `in:pending,accepted,rejected,applied`. Wenn status='applied' → auto-set `applied_at = now()`. 403 via sourceArticle.project.team_id cross-check (nicht direkt via suggestion). |

### Content Intelligence (code-verified 2026-05-15 + live-verified iter 1)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/content-intelligence` | `:403` → `:2646`. Returns team-wide aggregation. **Filters `Article::where('status', 'published')`** (companion-R1 missed) — nur published artikel zählen. 9-field response: `{total_articles, avg_seo_score, avg_geo_score, avg_eeat_score, avg_freshness_score, freshness_checks, link_suggestions_total, link_suggestions_pending, link_suggestions_applied}`. **Werte `round(value ?? 0, 1)`** — 1-decimal precision + null→0 fallback. link_suggestions selectRaw aggregiert COUNT + SUM(status='pending') + SUM(status='applied') in 1 query. |

### Storylines (code+DB-verified 2026-05-15 `ApiV1Controller.php:2687-2745` + `storylines`)

> **DB `storylines`**: 14 cols incl. `article_id` FK, `structure` JSON, `research_data` JSON, `seo_keywords` JSON, `tags` JSON, `total_word_count_estimate` int, **`approved_at` + `approved_by` FK** (workflow). status varchar(255) default 'draft' (NOT enum at DB; app-validated 4-enum on update).

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/storylines` | `:406` → `:2687`. Paginated 25/page Laravel-classic. Loads `article:id,title,status` relation. |
| POST | `/projects/{id}/storylines` | `:407` → `:2699`. Validation: `title` REQ max:200, `keyword` REQ max:200, `structure` nullable array, `seo_keywords` nullable array, `total_word_count_estimate` nullable int min:100. Auto: project_id, user_id, status='draft'. Returns 201. |
| GET | `/storylines/{id}` | `:408` → `:2721`. Eager-loads `blocks` + `article:id,title,status` relations. 403 cross-team via project.team_id. |
| PUT | `/storylines/{id}` | `:409` → `:2728`. Validation: title/keyword `sometimes` max:200, `status` `sometimes` `in:draft,approved,generating,completed` (4-state). **NOTE**: `approved_at` + `approved_by` DB-cols exist aber NICHT API-settable — silent-drop. 403 cross-team via project.team_id. |
| DELETE | `/storylines/{id}` | `:410` → `:2745`. **Cascade-delete**: zuerst `$storyline->blocks()->delete()` dann `$storyline->delete()` — entfernt alle storyline_blocks rows. Returns 204 No Content. |

### Style Profiles (code+DB-verified 2026-05-15 `ApiV1Controller.php:312-400` + `style_profiles`)

> **DB**: source_type varchar default 'upload', pending_review bool (workflow gate), style_data JSON, scraped_pages JSON, status varchar default 'processing'. 8 endpoints (companion war 4) — von-URL-scrape mit Review-Workflow. **DB-Cascade (R4)**: project_id + team_id + **user_id** ALLE CASCADE → user-delete wipes ALL user's style-profiles (DSGVO!). Reverse: `style_training_documents.style_profile_id` CASCADE (training docs gone with profile), `articles.style_profile_id` SET NULL preserve, `editorial_calendar.style_profile_id` SET NULL preserve.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/style-profiles` | `:231` → `listStyleProfiles`. |
| POST | `/projects/{id}/style-profiles` | `:232` → `:312`. Validation: name REQ max:255, description nullable str, style_data nullable array. Auto: project_id, user_id, team_id. Returns 201. |
| PUT | `/style-profiles/{id}` | `:233` → `:328`. Validation: name max:255, description, style_data array. **If pending_review=true** → auto-clears pending_review + sets reviewed_at=now() (workflow gate). 403 cross-team. |
| DELETE | `/style-profiles/{id}` | `:234` → `:344`. 204 No Content. |
| POST | `/projects/{id}/style-profiles/from-url` | `:235` → `:351`. **`credits:25` middleware**. Validation: source_url REQ url max:2048 starts_with:http/https, name nullable max:255. Auto-name: `"Stil von $host"`. Creates with status='pending', source_type='url'. Dispatches `AnalyzeStyleFromUrlJob`. Returns 202 `{data: {id, status, source_url, message: "Analyse gestartet — dauert 2–4 Minuten."}}`. **Job-Workflow (`:33`)**: 4-state transition `pending → scraping → completed/failed`. Uses `StyleSiteScrapeService::scrape($url)`. Wenn `count(pages) < MIN_PAGES_FOR_ANALYSIS` → status='failed' + `failure_reason='no_pages_found'`. Sonst writes scraped_pages JSON summary + continues mit AI analysis. |
| GET | `/style-profiles/{id}/scrape-status` | `:238` → `:385`. 5-field response: `{status, source_url, scraped_pages (JSON), failure_reason, pending_review}`. 403 cross-team. |
| POST | `/style-profiles/{id}/accept` | `:240` → `:397`. Workflow: setzt `pending_review = false` + `reviewed_at = now()`. Returns updated profile fresh. **No precondition check** — accept clears pending_review regardless of current state. |
| POST | `/style-profiles/{id}/discard` | `:242` → `:407`. **422 wenn nicht pending_review** (`"Nur Profile mit pending_review=true können verworfen werden."`). Hard-delete. Returns `{deleted: true}` 200. |

### Knowledge Base (code+DB-verified 2026-05-15 `ApiV1Controller.php:425-444` + `knowledge_documents`)

> **DB**: 14 cols incl. `filename, file_path, file_type, source_url, file_size bigint, tags JSON, folder, status varchar default "processing", processed_at`. Two POST-endpoints für manual vs URL-scraped. **Index-Coverage (R5)**: `(project_id, status)` composite + `source_url_idx` als **prefix-191-Index** (BTREE on varchar(2048) — exemplary partial-index pattern für long-URL columns wo full-column-BTREE nicht funktioniert). Plus user_id FK.

> **🔥 Production-Bug-Kandidat #15 (R4-entdeckt): Storage-Orphan beim Knowledge-Document-Delete.** `KnowledgeDocument` Model **hat KEIN `booted/deleting` hook** trotz `file_path` field (PDF uploads in storage/). Wenn document gelöscht via cascade (user_id CASCADE, project_id CASCADE, team_id CASCADE) ODER direkt DELETE /knowledge-base/{id} → **file bleibt orphaned in storage/**. Im Gegensatz zu `Project::deleting` (cleans up images) und `Image::deleting` (cleans up image-edits). Sollte gefixt werden mit ähnlichem hook.

> **DB-Cascade**: knowledge_documents user_id+project_id+team_id alle CASCADE. Reverse-refs: `article_knowledge_links.document_id` CASCADE + `knowledge_chunks.document_id` CASCADE (RAG-chunks verschwinden mit doc).

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/knowledge-base` | `:246` → `:419`. **8-col projection**: `id, project_id, title, filename, file_type, file_size, status, created_at`. Content not exposed in list (too large). |
| POST | `/projects/{id}/knowledge-base` | `:247` → `:425`. **Column-Aliasing**: Body `{title, content, type: text\|url\|pdf}` — controller stores `content` in DB-column `description` (NOT a `content` column!). Filename synthesized as `$title.txt`. file_type aus `type`. Status='active' override. Returns 201. |
| POST | `/knowledge-base/url` | `:249` → `:453`. **`credits-auto:5` middleware**. Validation: `project_id` REQ int exists FK, `url` REQ url max:2048 starts_with:http/https, `folder` nullable str max:255, `description` nullable str. Uses **`KnowledgeService::processUrl()`** (`:555`). **422 `Unsafe URL: <reason>`** wenn `UnsafeUrlException` (extends RuntimeException — z.B. private-IP, localhost, blocked-host). **Auto-side-effects**: SHA256-url-hash (first 16 chars) → file_path pattern `knowledge/{project_id}/url-{hash}.md`, file_size=0 initial, status='processing'. **3rd-party-Dependency**: `config('services.scraperapi.key')` — ohne key wirft `RuntimeException('ScraperAPI key not configured')` → 500. Wenn folder/description present → 2-pass-update nach processUrl. Returns 202 `{document_id, status}`. Named route: `api.v1.knowledge-base.url.create`. |
| DELETE | `/knowledge-base/{id}` | `:248`. |

### Link Lists (code+DB-verified 2026-05-15 `ApiV1Controller.php:493-520` + `link_lists` + `link_list_items`)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/link-lists` | `:254`. |
| POST | `/projects/{id}/link-lists` | `:255` → `:493`. Validation: `name` REQ max:255, `description` nullable, **`links` REQ array min:1** of `{url REQ url, anchor_text REQ max:255, description nullable}`. Creates LinkList + iterates items via $list->items()->create($link). Companion war zu vague. |
| DELETE | `/link-lists/{id}` | `:256`. |

### Goals / ContentGoal (code+DB-verified 2026-05-15 `ApiV1Controller.php:536-556` + `content_goals`)

> **DB-Cascade Anomalie (R4)**: `content_goals.user_id` ON DELETE **SET NULL** (NICHT CASCADE!) — wenn User gelöscht wird, **bleiben seine custom goals erhalten** (user_id wird null). Im Gegensatz zu storylines/style_profiles/knowledge_docs/ai_detection_scans/humanize_batches wo user_id CASCADE wipt. content_goals ist die EINZIGE Ausnahme im User-CASCADE-Pattern. Begründung vermutlich: Goals sind "templates" die team-weit nützlich bleiben. Plus: KEIN team_id oder project_id FK — Goals sind global/user-owned, nicht team-scoped.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/goals` | `:259`. |
| POST | `/goals` | `:260` → `:536`. Validation: `name` REQ max:255, `description` nullable, `prompt_instructions` nullable, `metrics` nullable array. Auto: user_id, is_system=false (System-Goals via Seeder gepflegt). Returns 201. |
| DELETE | `/goals/{id}` | `:261` → `:551`. **403 wenn nicht user-owner ODER is_system=true** (System-Goals nicht löschbar). |

### Editorial Calendar (code+DB-verified 2026-05-15 `ApiV1Controller.php:575-625` + `editorial_calendar`)

> **DB `editorial_calendar`** (16 cols): planned_date date REQ, status varchar default 'planned', plus **Production-Brief-Felder**: `article_type, target_length smallint, tone, style_profile_id FK, content_goal_id FK, competitor_content_gap_id FK`. Pre-Fix `:585` notiert: "Production-Brief-Felder (vorher silent-gedroppt — DB-Spalten existierten, Validator nicht). Erlauben Caller, Autor-Brief direkt mit der Planung zu setzen (wird später vom Bulk-Generator gelesen)." — defensive Anti-Silent-Drop-Pattern.

> **DB-Cascade Preserve-Pattern (R4)**: editorial_calendar hat 6 FKs aber nur project_id CASCADE — 5 FKs SET NULL: article_id, assigned_to (user_id), competitor_content_gap_id, content_goal_id, style_profile_id. **Calendar ist sehr preserve-friendly** — nur Project-delete wipes calendar-entries. Begründung: Calendar ist Plan-/Workflow-Daten die unabhängig von gelöschten Article/Goals/Profiles weiter genutzt werden sollen.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{id}/calendar` | `:264` → `listCalendar`. Query `?from=&to=` date range. |
| POST | `/projects/{id}/calendar` | `:265` → `:575`. Validation **9 Felder**: `title` REQ max:255, `planned_date` REQ date, `notes` nullable, `assigned_to` nullable exists:users FK, `status` `in:planned,in_progress,review,published` (4-state), `article_id` nullable exists FK, `competitor_content_gap_id` nullable exists FK, `article_type` nullable max:32, `target_length` nullable int 0-65535, `tone` nullable max:32, `style_profile_id` nullable exists FK, `content_goal_id` nullable exists FK. |
| PUT | `/calendar/{id}` | `:266` → `:601`. Gleiche 12-Feld Validation. 403 cross-team via project.team_id. |
| DELETE | `/calendar/{id}` | `:267`. |

### Module Store (code-verified 2026-05-15 `ApiV1Controller.php:2849-3060`)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/modules` | `:416` → `:2849`. Returns `{modules: [{slug, label, description, category, is_core, is_active, is_available, minimum_plan}], active_count, plan}`. Iterates `Module::cases()` enum + checks team's active+available sets. **Module-Enum-Detail**: `requiredPlanFeature()` returns null für plan-unrestricted modules (Articles/Keywords/Images — auf allen plans verfügbar) oder feature-string (z.B. 'calendar' → 'calendar' feature). `minimumPlan()` returns 'free' wenn feature null, sonst plan-string der das feature freischaltet. |
| POST | `/modules/{slug}/activate` | `:417` → `:2882`. **403 wenn nicht team-owner** ("Nur der Team-Owner kann Module aktivieren"). 422 wenn unbekanntes module OR module isCore (core nicht aktivierbar/deaktivierbar). **403 mit `{error: "Plan-Upgrade erforderlich", minimum_plan}` wenn feature-not-available**. **Storage-Layer (`Team::activateModule:216`)**: Module-state ist in **`Settings` table** (key='active_modules', value=JSON-array) gespeichert — NICHT in einer `team_modules` table wie der Name suggerieren würde. Updates `activeModulesCache=null` zur cache-invalidation. **Idempotent**: bereits-aktive module → silent no-op. **DB-Race-Safe (R5 entdeckt — 4. exemplary pattern)**: `settings` table HAT **`(team_id, key)` UNIQUE constraint** → `Setting::updateOrCreate(['team_id', 'key' => 'active_modules'], ...)` ist DB-level race-safe (concurrent updates kollidieren atomar auf UNIQUE). Companion-R4 noted updateOrCreate aber missed UNIQUE-Backup. |
| POST | `/modules/{slug}/deactivate` | `:418` → `:3040`. **403 owner-only**. 422 wenn unbekanntes module OR core. Returns `{message, module}`. |

### Grounding — Two-Score Model + Evidence Tiers (introduced 2026-05-16)

Every grounding audit now returns **TWO scores** instead of one:

- `technical_score` — **Technical Eligibility**. High-confidence, Google-confirmed crawl/index
  gate (`noindex`, canonical, hreflang, html `lang`, AI bots in `robots.txt`).
- `content_score` — **Content/GEO Signals**. Probabilistic (entity clarity, freshness,
  structured data, `llms.txt`, etc.).
- `score` (legacy headline) is re-weighted: Tier-B (hypothesis) checks count at **half weight**
  so the headline number leans on Google-confirmed signals.

Each finding carries `evidence_tier`:

- **Tier A (etabliert)** — signal confirmed by ≥1 engine OR observed in real citation data.
- **Tier B (Hypothese)** — plausible but unconfirmed; engine-specific belief.

Treat Tier-B as a plausible suggestion, not a fact. The Engines endpoint (`/v1/engines`)
exposes the per-engine matrix that powers these tier assignments.

### Grounding Page Analyzer (LLM-Citation-Readiness)
| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| POST | `/grounding/analyze` | 0 | Analyse a URL against 3 frameworks: Grounding Page Standard v1.5 (Hanns Kronenberg), Google E-E-A-T, Google People-First/AI-Features. Body: `{url, entity_name?, language?, include_site_root_checks?}`. Throttle: 30/min POST. Returns 202 `{audit_id, status:"pending", poll_url}` — poll `GET /v1/grounding/audits/{id}` until `status:"completed"`. Use this when answering "why is this page not cited by ChatGPT/Claude/Gemini?". |
| GET  | `/grounding/audits/{id}` | 0 | Poll an audit by id. Same response shape as below. Throttle: 120/min (poll-friendly). |

**Response (`GET /v1/grounding/audits/{id}`, `status=completed`):**

```jsonc
{
  "data": {
    "id", "audit_id", "tracking_project_id", "status", "url", "url_hash",
    "frameworks_requested",
    "score": "<int 0-100, legacy headline — severity × evidence-tier weighted (Tier-B at half weight)>",
    "tier": {"name": "...", "threshold_min": 0, "threshold_max": 100},  // not-grounded | weak | partial | grounded
    "technical_score":     "<int 0-100 | null>",     // Technical Eligibility (hard, Google-confirmed gate)
    "content_score":       "<int 0-100 | null>",     // Content/GEO Signals (probabilistic)
    "score_model_version": "v2 | null",              // null = legacy v1 audit
    "scores_detail": {
      "model_version": "v2",
      "matrix_version": "<engine-matrix version>",
      "technical": {"score": 0, "passed": 0, "total": 0},
      "content":   {"score": 0, "passed": 0, "total": 0},
      "tiers":     {"A": {"passed": 0, "total": 0}, "B": {"passed": 0, "total": 0}},
      "legacy":    {"score": 0, "formula": "severity_weighted_tier_factor_v2"}
    },
    "duration_ms", "credits_used", "created_at", "started_at", "completed_at", "expires_at", "batch_id",
    "frameworks": { /* per-framework breakdown */ },
    "findings": [
      {
        "id":            "gp.<area>.<check>",        // e.g. gp.h1.entity_only
        "severity":      "critical | high | medium | low",
        "framework":     "v1.5 | eeat | people-first",
        "dimension":     "technical | content",
        "evidence_tier": "A | B",                    // A = etabliert (confirmed by ≥1 engine OR observed in citation data); B = Hypothese
        "signal":        "<engine-matrix signal key> | null",
        "title": "...", "description": "...",
        "fix":      {"type": "...", "action": "...", "details": {"hint": "..."}},
        "spec_url": "..."
      }
    ],
    "raw_text":   "<Markdown rendering>"
  }
}
```

> **DEPRECATED — old shape (`data.sources.{grounding_page_standard, google_eeat, google_people_first}`) is no longer returned.** Surface `technical_score` and `content_score` separately to users; **prioritize Tier-A findings over Tier-B** (Tier B = plausible but unconfirmed). The poll endpoint `GET /v1/grounding/audits/{id}` returns the same shape.

### Citation-Causality Validation

Measure WHICH `gp.*` grounding checks actually correlate with real LLM citations on the team's
own data. Compares cited pages (`tracking_cited_sources`) vs CCE SERP-counterfactuals
(`was_cited=false`), computes per-check `lift = P(pass | cited) / P(pass | counterfactual)`
with a two-proportion z-test. Mirrors the Citation-Causality-Engine's `PatternAggregator`
methodology — `lift` is **NULL** (never the `999.0` sentinel) when the counterfactual
pass-rate is 0.

| Method | URL                                                   | Description                                                  | Credits | Admin? |
|--------|-------------------------------------------------------|--------------------------------------------------------------|---------|--------|
| POST   | `/v1/grounding/check-validations`                     | Start a run (sample_size default 150, max 400). Returns 202 + `{run_id, status:"pending", poll_url}`. Throttle 10/min. | 0 | no |
| GET    | `/v1/grounding/check-validations`                     | List the team's runs (latest 50).                            | 0       | no |
| GET    | `/v1/grounding/check-validations/{run}`               | Run detail + per-check results (ordered by `lift` desc).     | 0       | no |
| POST   | `/v1/grounding/signal-observations/{id}/promote`      | Promote a Phase-2 candidate observation → Tier A in the matrix (gated by `feedback_loop_enabled` config). | 0 | yes |
| POST   | `/v1/grounding/signal-observations/{id}/reject`       | Reject a candidate observation.                              | 0       | yes |

Run state machine: `pending → running → completed | failed`. Result rows (`results[]`) carry
per-check `lift`, `lift_undefined`, `z_score`, `p_value`, `verdict` ∈
`{positive, neutral, negative, insufficient_data}`. Phase 2 (feedback loop) is OFF by default;
signal observations land as `candidate` and only `promoted` ones affect the Engine Capability
Matrix.

Use when a team has CCE pattern data (`counterfactual_source` returns `'cc_counterfactuals'`);
without CCE data the run completes with all verdicts = `insufficient_data` and source=`none`.

### Bulk Generation & Autopilot (code-verified 2026-05-15 `ApiV1Controller.php:1022-1230`)

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/bulk-generations` | - | `:284`. |
| POST | `/bulk-generations` | 15 (credits-auto) | `:285` → `:1022`. Validation: `project_id` REQ exists, `name` REQ max:255, `keywords` REQ array min:1 max:50, `keywords.*` min:2, `type` `in:blog-post,guide,listicle` (3 vals — schmäler als `/articles/{id}/generate` 8 vals!), `language` `in:de,en,es,fr`. |
| GET | `/bulk-generations/{id}` | - | `:286`. |
| GET | `/autopilot` | - | `:289`. |
| POST | `/autopilot` | - | `:290` → `:1124`. Validation: `project_id` REQ exists, `name` REQ max:255, `frequency` REQ `in:daily,weekly,biweekly,monthly`, `day_of_week` int 0-6, `time_of_day` `H:i` format, `keyword_source` `in:manual,keyword_list,ai_suggest`, `keyword_list_id` nullable exists, `keywords` nullable array, `settings` nullable array, `is_active` bool. |
| PUT | `/autopilot/{id}` | - | `:291`. |
| DELETE | `/autopilot/{id}` | - | `:292`. |

## Common Patterns

### Throttle-Inventory (R6-entdeckt — Cross-Cutting)

**12 endpoints haben spezielle throttle-middleware** (über Sanctum-default hinaus):

| Endpoint | Throttle | Note |
|---|---|---|
| POST `/chat-shares` | 30/min | Share-creation rate-limited |
| GET `/agentic/sessions/{id}` | 120/min | Poll-frequency ok |
| POST `/agentic/sessions/{id}/feedback` | 60/min | Plus EnsureFrontendRequestsAreStateful |
| POST `/grounding/analyze` | 30/min | + credits:1 middleware |
| GET `/grounding/audits` | 60/min | List endpoint |
| GET `/grounding/audits/{id}` | 120/min | Poll-frequency ok |
| POST `/grounding/batch` | **5/min** | Tight rate-limit, R1-noted |
| POST `/grounding/sitemap-audit` | **5/min** | Tight, R3-noted |
| GET `/grounding/batches/{id}` | 120/min | Poll-frequency |
| GET `/grounding/batches/{id}/results.ndjson` | 30/min | Stream-endpoint |
| PATCH `/content-audits/{audit}/pages/bulk` | 30/min | R2-noted |
| PATCH `/site-audit/{crawl}/issues/bulk` | 30/min | R3-noted |
| POST `/v1/webhooks/reviews/{source}` | custom `reviews-webhook` | Public-key-auth + custom rate-limit |

**Alle anderen endpoints**: Sanctum-default throttle (60/min per token typically). Headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`, `Retry-After` exposed.

### Pagination — INCONSISTENT across modules (live-verified 2026-05-14)

Three different response shapes exist in production:

1. **Raw indexed array** — `/projects`, `/tracking-projects`, `/keywords/research`:
   ```json
   [{...}, {...}, {...}]
   ```
   No `data` wrapper. No pagination meta. `?per_page=` is accepted but doesn't change shape — clients must slice client-side or rely on server default limit.

2. **Laravel classic paginator** — `/content-freshness`:
   ```json
   {"current_page":1, "data":[...], "first_page_url":"...", "from":1, "last_page":5,
    "last_page_url":"...", "links":[...], "next_page_url":"...", "path":"...",
    "per_page":15, "prev_page_url":null, "to":15, "total":67}
   ```
   Full Laravel pagination metadata at root.

3. **Minimal `{data, meta}`** — `/grounding/audits`:
   ```json
   {"data":[...], "meta":{"page":1, "per_page":25, "total":N}}
   ```

Before iterating any list endpoint, `curl` it once and inspect `jq 'type'` — `"array"` vs `"object"` tells you which pattern. When in doubt, check `/api-docs/markdown § <module>`.

### Date Filtering
Use `?from=2026-01-01&to=2026-12-31` on score/trend endpoints.

### Error Codes
- `200` Success
- `201` Created
- `202` Accepted (async job queued)
- `403` Forbidden (wrong team or insufficient credits)
- `404` Not found
- `422` Validation error

---

## Additional Modules (NOT enumerated above — use /api-docs/markdown for body shapes)

These modules exist in production but aren't tabled in this skill. Treat the URLs below as the entry index; fetch `/api-docs/markdown` for request bodies, status codes, and response shapes.

### Live Prompt Check (code+DB-verified 2026-05-15 `PromptCheckApiController` + `prompt_check_runs`)

Parallel-LLM-Test mit/ohne Web-Search. Persistente Runs, Sentiment + Brand-Extract.

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/prompt-check` | List runs. `routes/api.php:37`. |
| POST | `/prompt-check` | Validation `PromptCheckApiController:79`: `tracking_project_id` REQ int, `prompt` REQ str 5-5000, `platforms` REQ array min:1, `platforms.*` `in:chatgpt,perplexity,claude,gemini,copilot,grok` (**6 vals**), `with_search` array, `title` nullable max:200. firstOrFail wenn tracking_project nicht in team → 404. Dispatch via **`LivePromptCheckService::startRun`** (correct service-class name — companion-R1+R2 hadn't specified). |
| GET | `/prompt-check/{id}` | Show run with responses + project (loads `responses, project:id,name,brand_name`). 404 wenn cross-team. `:123`. |
| POST | `/prompt-check/{id}/rerun` | Re-dispatch run mit gleichen params. Returns 202 `{run_id, status}`. `:135`. |
| GET | `/prompt-check/{id}/export?format=csv\|json` | Streamed. **CSV cols** (`:147` fputcsv): `platform, mode, status, model, sentiment_label, sentiment_score, is_recommended, brands_mentioned (\|-joined), tokens (=tokens_total), cost_cents, response_text (newlines collapsed)`. Default format=json returns run with `with('responses')` eager-load. |
| DELETE | `/prompt-check/{id}` | Hard-delete run + cascade responses + shares. Returns `{deleted: true}`. `:42`. **DB-Cascade (R4)**: `prompt_check_responses.prompt_check_run_id` CASCADE + **`prompt_check_shares.prompt_check_run_id` CASCADE** — alle public share-URLs für den Run werden invalid (404 nach delete). Caller die Run-share-Links extern verteilt haben → User-Surprise. |

> **DB `prompt_check_runs`**: `status` enum `pending,running,completed,partial,failed` (5). `total_cost_cents` int unsigned — **CENTS**, NOT mC milli wie CreditsV2. Anderer Unit-Standard. Indexes: tracking_project_id, user_id, team_id MUL.

> **⚠ LLM-PLATFORM-ENUM DRIFT zwischen Modulen (Production-Bug):**
> - `/tracking-projects PUT.llm_platforms[]` → 5 vals: `chatgpt,perplexity,claude,gemini,copilot`
> - `/prompt-check POST.platforms[]` → 6 vals (+grok)
> - DB `ai_dimension_scores.llm_platform` enum → 7 vals (+google_ai)
> Wer einen TrackingProject auf "track grok" stellen will: über `/prompt-check` möglich, aber `/tracking-projects PUT` rejected ihn. Tracking Runs werden dann nicht für grok ausgeführt, prompt-check schon. **3 disjoint enums** in 3 Schichten.

### AVI Reality Check (code+DB-verified 2026-05-15 `ApiV1Controller.php:4014-4150` + `ai_visibility_indices` + `ai_dimension_scores`)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/tracking-projects/{id}/avi` | `:367` → `:4014`. Latest AVI by computed_at. 404 `{error: "no_reality_check_run"}` wenn keine. Returns **13-field** payload: `{id, tracking_project_id, tracking_run_id, avi_score, avi_raw, tier (red\|yellow\|green), dimension_scores JSON, platforms_tracked, platforms_outlier, recommendations JSON, blind_pass_summary JSON, grounded_pass_summary JSON, computed_at}`. |
| GET | `/tracking-projects/{id}/dimensions` | `:368` → `:4049`. Loads latest AVI, then AiDimensionScore where tracking_run_id matches. Ordered by llm_platform, dimension. Returns array of `{llm_platform, dimension, score, confidence, is_outlier, raw_data}`. **DB dimensions enum**: existence, accuracy, sentiment, recommendation, positioning, recency (6 vals). |
| POST | `/tracking-projects/{id}/reality-check-report` | `:369` → `:4090`. **credits:0 middleware** (free). 404 wenn keine AVI. Dispatches GenerateRealityCheckReportJob. Returns 202 `{tracking_run_id, status: "queued"}`. |
| GET | `/tracking-projects/{id}/reality-check-report/status` | `:370` → `:4129`. Checks Storage disk 'local' for PDF at `reports/ai-reality-check/{tracking_run_id}.pdf`. **2-state Response** (NICHT 4-state wie companion-R1 claimed): `generating` (PDF noch nicht in storage) → `{status:"generating", avi_id, tracking_run_id}` · `ready` (PDF exists) → `{status:"ready", avi_id, tracking_run_id, size_bytes, last_modified (ISO 8601), download_url (named route reports.ai-reality-check.download)}`. 404 `{error: "no_reality_check_run"}` wenn keine AVI. |

> **DB `ai_visibility_indices`**: tracking_run_id UNI (one AVI per run), tier enum 3-tier, dimension_scores JSON. avi_score AND avi_raw — **2 score fields** (normalisiert vs raw). **DB-Cascade Double-Path (R4)**: `tracking_project_id` CASCADE + `tracking_run_id` CASCADE — 2 redundante FK-paths beide löschen den Index. Defensive design: AVI verschwindet bei Project-OR-Run-delete. Same gilt für ai_dimension_scores. **Index-Coverage (R5)**: `(tracking_project_id, computed_at)` composite — perfekt für `latest()->first()` AVI-query. `ai_dimension_scores` hat 3 composites: `(tracking_run_id, dimension)`, `(tracking_project_id, dimension, created_at)`, `(llm_platform, dimension)` für cross-cuts.
> **DB `ai_dimension_scores`**: llm_platform enum 7 vals incl. `google_ai, grok` (mehr als andere Module — siehe Live Prompt Check warning).

### Insights (code-verified 2026-05-15 `ApiV1Controller.php:3812-4007`)

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET | `/tracking-projects/{id}/insights/low-hanging-fruit` | - | `:359` → `:3812`. Query: `days=30, min_possibility=60, limit=5`. Returns array via `AiVisibilityScoreService::getLowHangingFruitPrompts`. |
| GET | `/tracking-projects/{id}/insights/platform-gap` | - | `:360` → `:3844`. Query: `days=30`. Returns analysis dict. |
| POST | `/tracking-projects/{id}/insights/platform-gap/{platform}/diagnose` | 1 | `:362` → `:3895`. **422 `platform_not_active`** wenn `$platform` nicht in `tracking_project.llm_platforms`. Hard-cut 402 mit CreditsResource envelope `{error: "insufficient_credits", required: {milli, display, display_ceil, display_floor}, balance: {...}}`. Bei erfolg: schreibt zu `tracking_projects.platform_gap_diagnoses` JSON (`$diagnoses[$platform] = {status: 'generating', started_at: now-iso8601}`) + dispatches `DiagnosePlatformGapJob`. Returns 202 `{tracking_project_id, project_id (@deprecated 2026-05-10), platform, status: "generating"}`. ⚠ **Production-Bug-Kandidat #13 (Race-Condition)**: Read-modify-write auf `platform_gap_diagnoses` JSON ohne row-lock — concurrent diagnoses für verschiedene platforms können sich gegenseitig overwriten (second write gewinnt). Atomares JSON_SET fehlt. |
| GET | `/tracking-projects/{id}/insights/potential-hero` | - | `:363` → `:3943`. Query: `days=30`. **W6-Fix**: Service kann null/all-null liefern → normalisiert auf `{eligible: false}` für konsistenten Vertrag (sonst Caller-Confusion). |
| POST | `/tracking-projects/{id}/prompts/{prompt}/generate-brief` | 3 | `:361` → `:3859`. **404** wenn prompt nicht in tracking_project. Hard-cut 402 mit CreditsResource envelope. Dispatches Job. |
| POST | `/tracking-projects/{id}/brand-aliases` | - | `:364` → `:3977`. Validation: `alias` REQ str 1-100. **422 `alias_empty`** wenn empty nach trim. Dedup via mb_strtolower(trim()) — case-insensitive uniqueness. |

### Cited Sources advanced (code+DB-verified 2026-05-15 + `tracking_cited_sources`)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/tracking-projects/{id}/cited-sources` | `:355` → `:1997`. **8 sort options** via $sortMap: `reach` (default, value*possibility), `citations`, `value`, `possibility`, `opportunity` (value+possibility), `last_seen`, `first_seen`, `domain`. Filters: `?domain=&outreach_status=&is_own_domain=&platform=&search=`. |
| GET | `/tracking-projects/{id}/cited-sources/export` | `:311` → `:4834`. CSV stream. Query: `?from=YYYY-MM-DD&to=YYYY-MM-DD&status=&platform=&search=&sort=` (default from=30d-ago, to=today). |
| POST | `/tracking-projects/{id}/cited-sources/analyze` | `:312` → `:5103`. Body: `source_ids[]` nullable int array (exists FK). Without source_ids → filters `is_own_domain=false` AND `analyzed_at IS NULL` (unanalyzed only). Selects only `id` column for efficient iteration. **Dispatches one AnalyzeCitationPossibilityJob PER source** (foreach loop). Returns 202 `{queued: count, estimated_seconds: ceil(count × 3), message: "Analysis queued"}` — 3s/source ETA. |
| PATCH | `/tracking-projects/{id}/cited-sources/{source_id}` | `:313` → `:5147`. Validation: `outreach_status` `in:neu,anfrage_gestellt,verlinkt,abgelehnt,ignoriert`, `notes` nullable str max:5000. 404 wenn cross-project. |

> **DB `tracking_cited_sources`**: outreach_status enum 5 vals, source_type enum `llm,aio,both`, citation_value_score + citation_possibility_score tinyint unsigned, site_type/content_type varchar, is_own_domain bool, url_hash varchar(64) für dedup. Date columns `first_seen_at, last_seen_at`. Rich opportunity-scoring infrastructure. **DB-Cascade Chain (R4)**: tracking_cited_source delete cascades zu **3 tables**: `cited_source_claims, cc_citation_dimensions, cc_sandbox_citation_dimensions` — Citation-Causality-Engine (CCE)-Daten verschwinden mit der Source.

> **Index-Coverage Exemplary (R5-entdeckt)**: tracking_cited_sources HAT **`(tracking_project_id, url_hash)` UNIQUE constraint** für race-safe dedup. Plus **5 composite indexes** decken ALLE 8 listCitedSources sort options: (tp, domain), (tp, outreach_status), (tp, citation_value_score), (tp, citation_possibility_score), (tp, site_type), (tp, deep_scan_status). **Genauso vorbildlich wie tracking_keywords tk_unique_keyword pattern.** Beispiel für race-safe + query-optimiertes design.

### Data-Mining Insights (R6-added 2026-05-15 — companion-gap entdeckt · `routes/api.php:777-783` · `DataMiningController.php`)

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/data-mining/insights/{project}` | `:778` → `DataMiningController::insights`. Liefert vollständigen Data-Mining-Snapshot für tracking-project (all sections). Named-route `api.v1.data-mining.insights`. |
| POST | `/data-mining/insights/{project}/refresh` | `:779` → `refresh`. Re-mined section dispatch. Named-route `api.v1.data-mining.refresh`. |
| GET | `/data-mining/insights/{project}/section/{section}` | `:780` → `section`. Einzelne Insight-section-data (paginated). Named-route `api.v1.data-mining.section`. |
| GET | `/data-mining/exports/{project}.pdf` | `:781` → `pdf`. PDF-Export der Insights für Project. Named-route `api.v1.data-mining.export.pdf`. |
| GET | `/data-mining/exports/{project}.zip` | `:782` → `zip`. ZIP-Export (alle assets) für Project. Named-route `api.v1.data-mining.export.zip`. |
| POST | `/data-mining/outreach/{source}/draft-mail` | `:783` → `draftMail`. AI-generierter Outreach-mail-Entwurf für eine cited-source. Named-route `api.v1.data-mining.outreach.draft-mail`. |

> **Coverage-Gap-Notice**: Diese 6 Endpoints waren in companion-R1-R5 **komplett fehlend** (R6-entdeckt 2026-05-15). Separater `DataMiningController` (NICHT ApiV1Controller). Für Detail-Validation per endpoint → `/api-docs/markdown` directly oder DataMiningController.php inspect.

### SEO Suite (code-verified 2026-05-15 — **URL-CORRECTIONS gegenüber Companion-iter2**)

> **⚠ Companion-URL-Drift (Production-Naming):** Die folgenden Prefixe in iter-2-companion waren falsch:
> - companion sagte `/keyword-explorer` → real ist `/explorer` (kürzer)
> - companion sagte `/keyword-gap` → real ist `/gap`
> - companion sagte `/site-audits` → real ist **`/site-audit`** (singular)
> Diese werden jetzt mit canonical routes dokumentiert.

| Module | Routes (code-verified) | Controller |
|---|---|---|
| **Rank Tracker** | `/rank-tracker/` GET (index) · `/rank-tracker/{project}` GET (show) · `/rank-tracker/{project}/keywords` GET+POST · `/rank-tracker/{project}/refresh` POST · `/rank-tracker/keywords/{keyword}/history` GET | `SeoSuite\RankTrackerApiController` |
| **Keyword Explorer** (NICHT `/keyword-explorer`!) | `/explorer/` POST seed (**`credits:50` Middleware**) · `/explorer/{keyword:keyword}` GET show (scoped binding via keyword-text statt ID) · `/explorer/{keyword:keyword}/related` GET | `SeoSuite\ExplorerApiController` |
| **Keyword Gap** (NICHT `/keyword-gap`!) | `/gap/` GET · `/gap/{project}` GET · `/gap/{project}/opportunities` GET · `PATCH /gap/opportunities/{opportunity}` · POST `/gap/{project}/refresh` | `SeoSuite\GapApiController` |
| **Site Audit** (singular!) | `/site-audit/` GET+POST (startCrawl `:157`) · `/site-audit/{crawl}` GET · `/site-audit/{crawl}/issues` GET · `PATCH /site-audit/{crawl}/issues/bulk` (throttle 30/min) · `PATCH /site-audit/issues/{issue}` · POST `/site-audit/issues/{issue}/generate-brief` | `SeoSuite\SiteAuditApiController` |

> **Site Audit startCrawl details**: `:157` rejects `project_id` body field with **422 + Migration-Hint** — Variante 1 Rename. Validation: `start_url` REQ url max:2048, `max_pages` 1-5000 (default 500), `crawl_depth` 1-10 (default 5), `crawl_mode` `in:bfs,sitemap` (default 'bfs'), `tracking_project_id` nullable exists FK. **Variable Cost-Calculation**: `cost = ceil(max_pages / 5)` — bei default 500 pages = 100 credits. **CreditsV2 hard-cut 402** mit milli-envelope wenn !hasCredits. **Cross-Team-Schutz**: wenn tracking_project_id provided → 404 wenn `tp->team_id !== team->id`. Creates `SiteCrawl` mit status='queued'.

> **Explorer seed details**: `:22` Validation: `seed` REQ 2-255, `language` size:2 (lowercased), `country` size:2 (UPPERCASED), `project_id` nullable exists, **`cache` bool** (default true). **Cache hit logic**: wenn `cache=true` (default) UND existing Keyword mit `processing_status='ready'` UND `updated_at > now-60min` → return cached (no credits). `cache=false` → dispatcht fresh exploration mit **`credits:50`**.

> **Explorer show details**: `:102` `?cache=false` charges 50 credits + dispatches refresh, returns current cached + `refreshing: true` flag. `?include=related,questions` (comma-sep) eager-loads related-keywords or question-keywords.
| **Backlinks** (**20 endpoints**, Phase 5+6) | Listed below | `SeoSuite\BacklinksApiController` |

> **Backlinks DB-Race-Safe Patterns (R5 entdeckt — 2 weitere exemplary)**: `backlink_anchors` HAT `(tracking_project_id, anchor_text)` UNIQUE constraint (5. pattern) + `referring_domains` HAT `(tracking_project_id, domain)` UNIQUE constraint (6. pattern). Beide für idempotent-create via firstOrCreate on (tp, anchor) / (tp, domain). Plus backlinks-table hat **4 composite indexes** für (tracking_project_id, first_seen/lost_at/last_seen/refdom) — comprehensive für link-velocity queries.

**Backlinks (Phase 5+6, code-verified routes/api.php:625-650):**
- `GET /backlinks/` (index) · `POST /backlinks/` (activate)
- **by-domain-Variante** (kein Project nötig, Phase-6-DFS-Shared): `GET /backlinks/by-domain/{domain}` · `POST /backlinks/by-domain/{domain}/pull` · `POST /backlinks/by-domain/{domain}/enrich-spam-scores` · `GET .../domains` · `GET .../anchors` · `GET .../spam-scores` · `GET .../disavow-export` · `GET .../timeseries`
- **project-Variante**: `GET /backlinks/{project}` · `GET /backlinks/{project}/domains` · `GET .../anchors` · `GET .../events` · `POST /backlinks/{project}/refresh` · `GET .../spam-scores` · `POST .../spam-scores/refresh` · `GET .../disavow-export` · `GET .../timeseries` · `POST .../anchors/refresh` · `POST /backlinks/{project}/full-refresh`
- **Subscription/admin**: `GET /teams/current/backlinks-subscription` · `POST /admin/teams/{team}/backlinks-subscription`

> **Backlinks index** `GET /backlinks/`: filters **`mode='delta'`** only (paused projects ausgeblendet). Eager-loads `trackingProject:id,name,domain`. Paginated default 25, max 100. **Backlinks activate** `POST /backlinks/`: Body `tracking_project_id` REQ exists FK. **403 wenn cross-team**. **422 `"Already active"`** wenn existing BacklinkProject mit mode='delta'. Sonst creates oder reactivates mit mode='delta'.

> **DOMAIN regex**: by-domain routes use `where('domain', '[a-zA-Z0-9.\-]+')` regex constraint. Underscores in domain → 404.

### Articles → Versions, Standalone, Freshness (advanced), Internal Linking (advanced)
- `GET /articles/{id}/versions` · `POST /articles/{id}/versions/{vid}/restore`
- Standalone: `/standalone-articles`, `/standalone-articles/{id}/score`
- `POST /articles/{id}/freshness/check` (async)
- `GET /articles/{id}/freshness/history`
- `POST /projects/{id}/internal-links/analyze`
- `PUT /link-suggestions/{id}` — `{status: pending|accepted|rejected|applied}`

### Page-Deep-Audit (Vision + KI-Render)
Visueller Audit mit Vision-Model + KI-Render-Vergleich. Eigene Pipeline.
- `POST /page-deep-audits` · `GET /page-deep-audits/{id}` · `GET /page-deep-audits/{id}/screenshots`

### Action Center (code-verified 2026-05-15 — Todos sub-namespace + SEO-Detectors)

| Method | Endpoint | Description |
|---|---|---|
| GET | `/action-center/seo-detectors` | `:656`. SEO-Detector recommendations index. |
| GET | `/action-center/todos/{todo}` | `:560`. |
| POST | `/action-center/todos/{todo}/complete` | `:561`. |
| POST | `/action-center/todos/{todo}/snooze` | `:562`. |

### Site Monitor (code-verified 2026-05-15 — **REAL URL IS SINGULAR `/site-monitor`**)

> **⚠ Companion-iter2-Drift fix**: companion said `/site-monitors` (plural). Real route prefix is `/site-monitor` (singular). 10 endpoints, NICHT 3.

> **DB-Cascade Multi-Chain (R4)**: monitored_sites team_id CASCADE + project_id SET NULL (preserve). Reverse refs: **4 tables CASCADE** wenn monitored_site deleted: `site_checks, site_incidents, site_lighthouse_scores, site_monitor_reports`. DELETE wipes ALL monitoring-history für die Site.

> **Index + UNIQUE Patterns (R5)**: `monitored_sites.statuspage_slug` UNIQUE (single-col — 7. exemplary pattern für public-facing slugs). Plus `(team_id, is_active)` + `(is_active, last_checked_at)` composite-indexes für cron-due-queries. `site_checks` hat `(monitored_site_id, type, created_at)` 3-col composite + `(monitored_site_id, created_at)` — multiple sort-paths covered.

| Method | Endpoint | Description |
|---|---|---|
| GET | `/site-monitor` | List `:490`. |
| POST | `/site-monitor` | Create `:491` → `:3081`. Validation: `url` REQ url max:2048, `name` REQ max:100, `check_interval` `in:1,5,15,30` (minutes), `lighthouse_frequency` `in:hourly,daily,weekly`, `deep_seo_frequency` `in:daily,weekly`, `project_id` nullable exists, `notification_channels` array, `escalation_rules` array, `statuspage_enabled` bool, `statuspage_slug` nullable max:100, `tags[]` str max:50. **Cross-Team-FK-Pollution-Schutz** (`:3105`): `project_id` muss zum Team gehören → **422** `"project_id does not belong to current team"` (Anti-Bug: ohne diesen Check könnte Caller fremde projects.id verwenden → bei `getMonitoredSite::load('project')` würde fremdes Project in Response leaken). |
| GET | `/site-monitor/{id}` | `:492`. |
| PUT | `/site-monitor/{id}` | `:493`. |
| POST | `/site-monitor/{id}/pause` | `:495`. |
| POST | `/site-monitor/{id}/resume` | `:496`. |
| POST | `/site-monitor/{id}/check-now` | `:497`. Trigger immediate check. |
| GET | `/site-monitor/{id}/checks` | `:498`. List check history. |
| GET | `/site-monitor/{id}/incidents` | `:499`. List detected incidents. |
| GET | `/site-monitor/{id}/lighthouse` | `:500`. Lighthouse scores history. |
| DELETE | `/site-monitor/{id}` | `:494` (R6-C10 added). Hard-delete monitored site + cascade history. |

### Image Gallery (team-scoped, modern) + Legacy per-project (R6-C10 final-sync 2026-05-15 · `routes/api.php:55-90, 180-182` · ImagesApiController + ApiV1Controller)

**Modern team-scoped gallery** (16 endpoints, `routes/api.php:55-90` prefix-group → `ImagesApiController`). Cross-team IDs return `404` (no existence leak):

| Method | Endpoint | Credits | Description |
|--------|----------|---------|-------------|
| GET    | `/v1/images` | 0 | List with filters: `?q`, `?model`, `?from`, `?to`, `?favorite`, `?tags[]`, `?sort=newest\|oldest\|edited`, `?page`, `?per_page` |
| GET    | `/v1/images/trash` | 0 | Soft-deleted listing |
| GET    | `/v1/images/{id}` | 0 | Detail with `variants[]` and `parent` tree |
| POST   | `/v1/images/{id}/edit` | 10 | Async edit via OpenAI `/v1/images/edits`. Returns `{job_id, edit_session_id}` (HTTP 202). Poll `GET /v1/images/{id}` for new variants |
| DELETE | `/v1/images/{id}` | 0 | Soft-delete (30-day trash, then hard-delete) |
| POST   | `/v1/images/{id}/restore` | 0 | Restore from trash |
| DELETE | `/v1/images/{id}/forever` | 0 | Permanent delete (only from trash, else 409) |
| POST   | `/v1/images/{id}/favorite` | 0 | Toggle `is_favorite` |
| PATCH  | `/v1/images/{id}` | 0 | Metadata update: `{title?, tags?[]}` |
| POST   | `/v1/images/bulk/delete` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/v1/images/bulk/restore` | 0 | Body `{ids:[]}` max 200 |
| POST   | `/v1/images/bulk/export` | 0 | Body `{ids:[]}` max 500. Async 202 → `{job_id}` for ZIP |
| GET    | `/v1/images/exports/{job_id}` | 0 | Export status: `{status: queued\|ready\|failed, download_url?}` |
| POST   | `/v1/images/{id}/open-in-chat` | 0 | Creates `AiChatSession` with `gallery_image_id={id}`. Returns `{session_id}` for redirect to `/chat/{session_id}` |
| POST   | `/v1/images/{id}/share` | 0 | Idempotent public-share token. Body: `{expires_at?}`. Returns `{token, public_url, expires_at, view_count, is_active}` |
| PATCH  | `/v1/images/{id}/share` | 0 | Update expiry. Body: `{expires_at: ISO\|null}` (404 if no active share) |
| DELETE | `/v1/images/{id}/share` | 0 | Revoke share (view-count preserved). Stateful middleware (SPA cookie-based auth, not Bearer). |

**Legacy per-project** (3 endpoints, `routes/api.php:180-182` → ApiV1Controller — kept for old integrations):

| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/projects/{project}/images` | `:180` `listImages`. Liste aller Images des Projects (legacy). Use `/v1/images?project_id=` via team-scoped path instead. |
| GET | `/images/{image}` | `:181` `showImage`. Single image (legacy, identisch zu `/v1/images/{id}`). |
| DELETE | `/images/{image}` | `:182` `deleteImage`. (Legacy hard-delete). **⚠ Image::deleting boot-hook (R4 entdeckt)**: cleans up storage-files inkl. image-edits. Im Gegensatz zu KnowledgeDocument (Bug #15 storage-orphan). |

> **Coverage-Gap-Resolution (R6-C10 → final-sync)**: companion-R1-R5 hatte 0 Image-Section. R6-C10-Patch hatte nur die 3 legacy-endpoints. Final-sync R6-FINAL nutzt vollständige team-scoped Tabelle (16 endpoints) aus Repo-skill — modern path ist canonical, legacy für backward-compat erwähnt.

### Community Monitor
Reddit/Forum/Quora Mention-Tracking für Brand-Surveillance.
- `GET /community-monitors` · `POST /community-monitors/{id}/sync`

### Google Integrations (code-verified 2026-05-15 — **8 endpoints, GSC + GA4 separated**)

> **⚠ Companion-iter2-Drift fix**: companion said `/google/connect` + `/google/connections/{id}/sync` + `/projects/{id}/gsc-data`. Real routes: GSC and GA4 are **separate prefixes** with own property+sync+metrics endpoints under `/google/gsc/properties/` and `/google/ga4/properties/`.

| Method | Endpoint | Description |
|---|---|---|
| GET | `/google/connections` | `:530` `listConnections`. |
| GET | `/google/connections` | `:530` `listConnections`. (R6-C9 added) Listet alle verbundenen Google-Accounts des Teams. |
| DELETE | `/google/connections/{connection}` | `:531` `deleteConnection`. (R6-C9 added) Disconnect Google-Account. |
| GET | `/google/gsc/properties` | `:533` `listGscProperties`. |
| POST | `/google/gsc/properties/{prop}/link` | `:534` `linkGscProperty`. |
| DELETE | `/google/gsc/properties/{prop}/link` | `:535` `unlinkGscProperty`. (R6-C9 added) |
| POST | `/google/gsc/properties/{prop}/sync` | `:536` `syncGscProperty`. **`credits:1` middleware**. |
| GET | `/google/gsc/properties/{prop}/metrics` | `:537` `gscMetrics`. |
| GET | `/google/ga4/properties` | `:539` `listGa4Properties`. |
| POST | `/google/ga4/properties/{prop}/link` | `:540` `linkGa4Property`. |
| DELETE | `/google/ga4/properties/{prop}/link` | `:541` `unlinkGa4Property`. (R6-C9 added) |
| GET | `/google/ga4/properties/{prop}/metrics` | `:542` `ga4Metrics`. |
| GET | `/integrations/google/reviews/status` | `:667` `googleReviewsStatus` (ReviewSourceController). (R6-C9 added) Check connection-status für Google-Reviews. |

### Review Sources (code-verified 2026-05-15 — 8 endpoints with webhook + rotate-key + connect/disconnect lifecycle)

| Method | Endpoint | Description |
|---|---|---|
| GET | `/review-sources` | `:659` index. |
| POST | `/review-sources/webhook` | `:660` createWebhook (incoming Review-Source-Daten-Endpoint). |
| POST | `/review-sources/waitlist` | `:661` waitlistAdd. |
| GET | `/review-sources/{source}` | `:662` show. |
| POST | `/review-sources/{source}/rotate-key` | `:663` rotateKey (webhook secret rotation). |
| POST | `/review-sources/{source}/disconnect` | `:664`. |
| POST | `/review-sources/{source}/reconnect` | `:665`. |
| DELETE | `/review-sources/{source}` | `:666`. |

### Rankion OS (code-verified 2026-05-15 — **NICHT "AI Workflow Orchestration", sondern File-System+Desktop+Spotlight**)

> **🔥 Production-Bug-Kandidat #16 (R4-entdeckt): OsFile Storage-Orphan beim Delete.** `OsFile` Model **hat KEIN booted/deleting hook** trotz file-storage-references. **DB-Cascade**: `os_files.user_id` CASCADE + `os_files.team_id` CASCADE → user-delete oder team-cascade löscht DB-row aber storage-files bleiben orphaned. **Vergleich**: Project + Image haben hooks. OsFile MISSING (analog #15 KnowledgeDocument). `os_files.folder_id` SET NULL (orphan-at-root wenn folder deleted), `os_files.workspace_id` (=project_id alias) SET NULL preserve.

> **OsFile Index + UNIQUE Patterns (R5)**: `os_files.share_token` UNIQUE (single-col — 8. exemplary pattern für share-URLs). Plus `(team_id, folder)` + `(team_id, workspace_id)` composites für UI-queries. Race-safe für share-token-generation (UNIQUE catches accidental collision).

> **⚠ Companion-iter2-Drift fix**: companion said "Workflow + AI Agent Orchestration" with `/os/workflows`. Real: Rankion OS is a **Desktop-style file/folder management** module with Spotlight-search, preferences, file sharing — NOT workflow orchestration.

| Method | Endpoint | Description |
|---|---|---|
| GET | `/os/preferences` | `:504`. |
| POST | `/os/preferences` | `:505`. |
| GET | `/os/files` · POST · `/{id}` GET · `/{id}/move` POST | `:506-511`. |
| GET | `/os/spotlight` | `:512` → `:3532`. Validation: `q` REQ str min:2. Searches **modules** (`config('rankion-os.apps')` config-driven) + files + folders. |
| GET | `/os/folders` · POST · `/{folder}` GET · `/{folder}/move` POST | `:515-520`. **moveFolder** (`:3728`): 404 cross-team + **403 `"Cannot move desktop root."`** wenn `is_root_desktop=true`. Validation: `parent_id` REQ int exists, `x/y` nullable int (coordinates). Cross-team check auf new-parent. |
| GET | `/os/desktop` | `:523`. |
| POST | `/os/files/{file}/share` | `:526` → `:3777`. 404 cross-team. Generates 64-char random `share_token` wenn noch nicht existiert (idempotent). Returns `{token, share_url: route('public-file.show', token)}`. |

### Pipelines (code-verified 2026-05-15 `ApiV1Controller.php:2988-3010`)

> **DB-Cascade (R4)**: content_pipelines team_id + project_id + **user_id ALLE CASCADE** — User-delete oder Team-delete wipes pipelines. article_id + cms_integration_id SET NULL preserve refs.

> **⚠ Production-Bug — Charge-After-Dispatch Race (`createPipeline:3006`):** Code-Reihenfolge: (1) `dispatch(RunContentPipelineJob)` (Line 3006), (2) `$team->deductCredits(20)` (Line 3008). Job läuft ANIM bevor credit-charge passiert. Plus route hat middleware `credits:20`. Wenn middleware greift → credits werden via middleware abgezogen UND code-deduktiv erneut → potential **double-deduct**. Wenn middleware nicht greift (Race-Window) → Job läuft ohne credit-Charge.

| Method | Endpoint | Description |
|---|---|---|
| GET | `/pipelines` | `:471`. |
| POST | `/pipelines` | `:472` → `:2988`. **`credits:20` middleware** + explicit deductCredits. Validation: `project_id` REQ int (NOT exists FK — uses firstOrFail by team_id later), `keyword` REQ max:255, `options` nullable array. Dispatches RunContentPipelineJob. |
| GET | `/pipelines/{id}` | `:473`. |
| POST | `/pipelines/{id}/retry` | `:474`. **NICHT in companion vorher!** |

### Reports (code-verified 2026-05-15 — only 2 endpoints, NICHT 4 wie companion claimed)

| Method | Endpoint | Description |
|---|---|---|
| GET | `/tracking-projects/{trackingProject}/reports` | `:573` `listTrackingProjectReports`. |
| GET | `/reports/{report}` | `:574` `getTrackingProjectReport`. |

> **Companion-iter2-Drift**: `/reports` (list), `/reports/generate`, `/reports/{id}/download`, `/reports/correlations` — **alle 4 existieren NICHT**. Real ist Report-Listing project-scoped + single-report-detail. Generate ist intern, kein API-endpoint.

### Blog (Admin)
- `GET /admin/blog/posts` · `POST /admin/blog/posts` · `POST /admin/blog/posts/{id}/publish`

---

> **Drift-Hinweis:** Dieser Skill wurde zuletzt 2026-05-09 enumeriert. Production hat seit dem mehrere neue Module/Endpoints. Wenn `grep` im Skill nichts findet: `curl /api-docs/markdown` ist canonical.
